Merge branch 'stable-3.5' into stable-3.6

* stable-3.5:
  Reset pageResultSize when PaginationType NONE back-fill results
  Add test paginationType NONE to query only if there are more results
  Fix paginationType NONE to run further queries only if needed
  Run tests in FakeQueryChangesTest for paginationType NONE
  Fix detection of invalid SSH key algorithm
  Demonstrate SshKeyUtil fails to validate invalid SSH keys
  Move comment of PaginatingSource.read()
  Fix visibility filter for changes when paginationType NONE
  Add tests for pagination to back fill the results
  Allow plugins to use PredicateArgs

Release-Notes: skip
Change-Id: I8e166780d069267e3f25c454dc947ebe1d32d949
diff --git a/.bazelrc b/.bazelrc
index 8cf525c..e63d1b5 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -40,7 +40,6 @@
 build --announce_rc
 
 test --build_tests_only
-test --test_output=errors
-test --java_toolchain=//tools:error_prone_warnings_toolchain_java11
+test --test_output=all
 
 import %workspace%/tools/remote-bazelrc
diff --git a/.gitignore b/.gitignore
index 95f94ba..8edc10e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 # Keep following lines sorted according to `LC_COLLATE=C sort`
+*.code-workspace
 *.eml
 *.iml
 *.log
diff --git a/BUILD b/BUILD
index 9633c0f..984fd955 100644
--- a/BUILD
+++ b/BUILD
@@ -13,8 +13,8 @@
 genrule(
     name = "gen_version",
     outs = ["version.txt"],
-    cmd = ("cat bazel-out/volatile-status.txt bazel-out/stable-status.txt | " +
-           "grep STABLE_BUILD_GERRIT_LABEL | cut -d ' ' -f 2 > $@"),
+    cmd = ("(cat bazel-out/volatile-status.txt bazel-out/stable-status.txt | " +
+           "grep STABLE_BUILD_GERRIT_LABEL | cut -d ' ' -f 2) > $@ || echo 'UNKNOWN' > $@"),
     stamp = 1,
 )
 
diff --git a/Documentation/BUILD b/Documentation/BUILD
index af355ca..85ddbe7 100644
--- a/Documentation/BUILD
+++ b/Documentation/BUILD
@@ -126,3 +126,13 @@
     directory = DOC_DIR,
     searchbox = False,
 )
+
+genasciidoc_zip(
+    name = "searchfree_safe",
+    srcs = SRCS,
+    attributes = documentation_attributes(),
+    backend = "html5",
+    directory = DOC_DIR,
+    searchbox = False,
+    webfonts = False,
+)
diff --git a/Documentation/cmd-copy-approvals.txt b/Documentation/cmd-copy-approvals.txt
deleted file mode 100644
index ba5344f..0000000
--- a/Documentation/cmd-copy-approvals.txt
+++ /dev/null
@@ -1,60 +0,0 @@
-= gerrit copy-approvals
-
-== NAME
-gerrit copy-approvals - Copy all inferred approvals labels to the latest patch-set.
-
-== SYNOPSIS
-[verse]
---
-_ssh_ -p <port> <host> _gerrit copy-approvals_
-  [--verbose | -v]
-  [PROJECT]...
---
-
-== DESCRIPTION
-Gerrit has historically computed votes using an inference algorithm that
-was cumulating them from all the patch-sets. That was not efficient since
-it had to take into account copied votes from very old patchsets.
-E.g, votes sometimes need to be copied from ps1 to ps10.
-
-Gerrit copy the approvals from the inferred votes to the latest patch-sets
-once a change receives a new label update.
-
-The copy-approval command scans all the changes of a project and looks for
-all votes that have not been copied yet, calculate the inferred score and
-apply that as copied label to the latest patch-set.
-
-NOTE: The label copied as part of this process receives the grant date of
-the timestamp of the copy-approval command execution, not the one associated
-with the inferred vote.
-
-== OPTIONS
-
---verbose::
--v::
-	Display projects/changes impacted by the label copy operation.
-
-== ACCESS
-Only the user with MAINTAIN_SERVER permissions can run this command.
-
-== SCRIPTING
-This command is intended to be used in scripts.
-
-== EXAMPLES
-
-Copy all inferred labels on the project 'foo'
-----
-$ ssh -p 29418 review.example.com gerrit copy-approvals foo
-----
-
-Copy all inferred labels on all projects
-----
-$ ssh -p 29418 review.example.com gerrit copy-approvals
-----
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index 7f1a6e8..ce3a024 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -58,9 +58,6 @@
 link:cmd-ban-commit.html[gerrit ban-commit]::
 	Bans a commit from a project's repository.
 
-link:cmd-copy-approvals.html[gerrit copy-approvals]::
-	Copy all inferred approvals labels to the latest patch-set.
-
 link:cmd-create-branch.html[gerrit create-branch]::
 	Create a new project branch.
 
diff --git a/Documentation/cmd-ls-projects.txt b/Documentation/cmd-ls-projects.txt
index 1dd6720..ebd365a 100644
--- a/Documentation/cmd-ls-projects.txt
+++ b/Documentation/cmd-ls-projects.txt
@@ -58,6 +58,21 @@
 	Displays project inheritance in a tree-like format.
 	This option does not work together with the show-branch option.
 
+[NOTE]
+If the calling user does not meet any of the following criteria:
+
+* The state of the parent project is either "ACTIVE" or "READ ONLY",
+and the calling user has READ permission to at least one ref.
+* The state of the parent project is "HIDDEN" and the calling user
+has READ permission for 'refs/meta/config'.
+
+Then the 'parent' field will be labeled as '?-N', where N represents the
+nesting level within the project's tree structure. In the provided example,
+'All-Projects' corresponds to level 1, 'parent-project' to level 2, and
+'child-project' to level 3.
+
+The output format to display the results should be `json` or `json_compact`.
+
 --type::
 	Display only projects of the specified type.  If not
 	specified, defaults to `all`. Supported types:
diff --git a/Documentation/concept-patch-sets.txt b/Documentation/concept-patch-sets.txt
index 274fbb0..e39d091 100644
--- a/Documentation/concept-patch-sets.txt
+++ b/Documentation/concept-patch-sets.txt
@@ -88,8 +88,8 @@
 evolves, such as "Added more unit tests." Unlike the change description, a patch
 set description does not become a part of the project's history.
 
-To add a patch set description, click *Add a patch set description*, located in
-the file list, or provide it link:user-upload.html#patch_set_description[on upload].
+To add a patch set description provide it
+link:user-upload.html#patch_set_description[on upload].
 
 GERRIT
 ------
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index d942aa3..45d4fc6 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -37,9 +37,10 @@
 
 [[accountPatchReviewDb.url]]accountPatchReviewDb.url::
 +
-The url of accountPatchReviewDb. Supported types are `H2`, `POSTGRESQL`,
-`MARIADB`, and `MYSQL`. Drop the driver jar in the lib folder of the site path
-if the Jdbc driver of the corresponding Database is not yet in the class path.
+The url of accountPatchReviewDb. Supported types are `CLOUDSPANNER`, `H2`,
+`POSTGRESQL`, `MARIADB`, and `MYSQL`. Drop the driver jar in the lib folder of
+the site path if the Jdbc driver of the corresponding Database is not yet in
+the class path.
 +
 Default is to create H2 database in the db folder of the site path.
 +
@@ -960,12 +961,6 @@
 +
 If direct updates are made to `All-Users`, this cache should be flushed.
 
-cache `"approvals"`::
-+
-Cache entries contain approvals for a given patch set. This includes
-approvals granted on this patch set as well as approvals copied from
-earlier patch sets.
-
 cache `"adv_bases"`::
 +
 Used only for push over smart HTTP when branch level access controls
@@ -1066,12 +1061,6 @@
 away from the defaults. The cache may be persisted by setting
 `diskLimit`, which is only recommended if cold start performance is
 problematic.
-+
-`external_ids_map` supports computing the new cache value based on a
-previously cached state. This applies modifications based on the Git
-diff and is almost always faster.
-`cache.external_ids_map.enablePartialReloads` turns this behavior on
-or off. The default is `true`.
 
 cache `"git_tags"`::
 +
@@ -1208,6 +1197,9 @@
 cache should be flushed.  Newly inserted projects do not require
 a cache flush, as they will be read upon first reference.
 
+NOTE: This cache should be disabled or set with a low refreshAfterWrite
+in a cluster setup using multiple primary or multiple replica nodes.
+
 cache `"prolog_rules"`::
 +
 Caches parsed `rules.pl` contents for each project. This cache uses the same
@@ -1218,6 +1210,14 @@
 Result of checking if one change or commit is a pure/clean revert of
 another.
 
+cache `"soy_sauce_compiled_templates"`::
++
+Caches compiled soy templates. Stores at most only one key-value pair with
+a constant key value and the value is a compiled SoySauce templates. The value
+is reloaded automatically every few seconds if there are reads from the cache.
+If cache is not used for 1 minute, the item is removed (i.e. emails can be send
+with templates which are max 1 minute old).
+
 cache `"sshkeys"`::
 +
 Caches unpacked versions of user SSH keys, so the internal SSH daemon
@@ -1226,6 +1226,9 @@
 As each individual user account may configure multiple SSH keys,
 the total number of keys may be larger than the item count.
 
+NOTE: This cache should be disabled or set with a low refreshAfterWrite
+in a cluster setup using multiple primary or multiple replica nodes.
+
 cache `"web_sessions"`::
 +
 Tracks the live user sessions coming in over HTTP.  Flushing this
@@ -1242,6 +1245,9 @@
 +
 Session storage is relatively inexpensive. The average entry in
 this cache is approximately 346 bytes.
++
+The `maxAge` configuration is also used for as maximum lifetime
+of the HTTP servlet container session.
 
 See also link:cmd-flush-caches.html[gerrit flush-caches].
 
@@ -1541,8 +1547,7 @@
 branch, the changes on which the change depends are silently merged
 into the new branch, although these changes have not been moved to that
 branch (see details in
-link:https://bugs.chromium.org/p/gerrit/issues/detail?id=9877[issue
-9877]).
+link:https://issues.gerritcodereview.com/issues/40009784[issue 40009784]).
 +
 By default true.
 
@@ -1657,6 +1662,21 @@
 +
 Default is 5 minutes.
 
+[[change.skipCurrentRulesEvaluationOnClosedChanges]]
++
+If false, Gerrit will always take latest project configuration to
+compute submit labels. This means that, closed changes (either merged
+or abandoned) will be evaluated against the latest configuration which
+may produce different results. Especially for merged changes, they may
+look like they didn't meet the submit requirements.
++
+When true, evaluation will be skipped and Gerrit will show the
+exact status of submit labels when change was submitted. Post-review
+votes will only be allowed on labels that were configured when change
+was closed.
++
+Default it false.
+
 [[changeCleanup]]
 === Section changeCleanup
 
@@ -2068,6 +2088,14 @@
 +
 Default is 1 hour.
 
+[[dashboard]]
+=== Section dashboard
+
+[[dashboard.submitRequirementColumns]]dashboard.submitRequirementColumns::
++
+The list of submit requirement names that should be displayed as separate
+columns in the dashboard.
+
 [[download]]
 === Section download
 
@@ -2209,15 +2237,6 @@
 For this reason `zip` format is always excluded from formats offered
 through the `Download` drop down or accessible in the REST API.
 
-[[download.maxBundleSize]]download.maxBundleSize::
-+
-Specifies the maximum size of a bundle in bytes that can be downloaded.
-As bundles are kept in memory this setting is to protect the server
-from a single request consuming too much heap when generating
-a bundle and thereby impacting other users.
-+
-Defaults to 100MB.
-
 [[gc]]
 === Section gc
 
@@ -3338,11 +3357,6 @@
 configured to 5 and maxPageSize to 2000, the next query will have a limit of
 2000 (instead of 2500).
 +
-When `index.type` is set to `ELASTICSEARCH`, this value should not exceed
-the `index.max_result_window` value configured on the Elasticsearch
-server. If a value is not configured during site initialization, defaults to
-10000, which is the default value of `index.max_result_window` in Elasticsearch.
-+
 _Note: ignored when paginationType is `NONE`_
 +
 Defaults to no limit.
@@ -3658,7 +3672,7 @@
 If you want to configure multiple ldap servers you can try to put
 multiple ldap urls separated by a space:
 `server = ldaps://ldap1 ldaps://ldap2`
-See https://bugs.chromium.org/p/gerrit/issues/detail?id=10841[issue 10841].
+See https://issues.gerritcodereview.com/issues/40010644[issue 40010644].
 
 [[ldap.startTls]]ldap.startTls::
 +
@@ -5016,16 +5030,6 @@
   replicate = replication start
 ----
 
-[[ssh]]
-=== Section ssh
-
-[[ssh.clientImplementation]]ssh.clientImplementation::
-+
-JCraft JSch client is supported in addition to Apache MINA SSH client.
-To use JSch client set the value to `JSCH`.
-+
-By default, `APACHE`.
-
 [[sshd]]
 === Section sshd
 
@@ -5246,22 +5250,27 @@
 +
 Supported ciphers:
 +
-* `aes128-ctr`
-* `aes192-ctr`
-* `aes256-ctr`
 * `aes128-cbc`
+* `aes128-ctr`
+* `aes128-gcm@openssh.com`
 * `aes192-cbc`
+* `aes192-ctr`
 * `aes256-cbc`
-* `blowfish-cbc`
-* `3des-cbc`
+* `aes256-ctr`
+* `aes256-gcm@openssh.com`
 * `arcfour128`
 * `arcfour256`
+* `blowfish-cbc`
+* `chacha20-poly1305@openssh.com`
+* `3des-cbc`
 * `none`
 +
-By default, all supported ciphers except `none` are available.
-+
 If your setup allows for it, it's recommended to disable all ciphers except
 the AES-CTR modes.
++
+See also link:https://github.com/apache/mina-sshd/tree/master#ciphers[ciphers,role=external,window=_blank].
++
+By default, all supported ciphers except `none` are available.
 
 [[sshd.mac]]sshd.mac::
 +
@@ -5279,6 +5288,11 @@
 * `hmac-sha1-96`
 * `hmac-sha2-256`
 * `hmac-sha2-512`
+* `hmac-sha1-etm@openssh.com`
+* `hmac-sha2-256-etm@openssh.com`
+* `hmac-sha2-512-etm@openssh.com`
++
+See also link:https://github.com/apache/mina-sshd/tree/master#macs[macs,role=external,window=_blank].
 +
 By default, all supported MACs are available.
 
@@ -5307,6 +5321,9 @@
 * `ecdh-sha2-nistp521`
 * `ecdh-sha2-nistp384`
 * `ecdh-sha2-nistp256`
+* `curve25519-sha256`
+* `curve25519-sha256@libssh.org`
+* `curve448-sha512`
 * `diffie-hellman-group-exchange-sha256`
 * `diffie-hellman-group18-sha512`
 * `diffie-hellman-group17-sha512`
@@ -5317,12 +5334,14 @@
 See link:#sshd.enableDeprecatedKexAlgorithms[sshd.enableDeprecatedKexAlgorithms]
 for deprecated key algorithms and how to enable them.
 
-By default, all supported key exchange algorithms are available.
-
 It is strongly recommended to disable at least `diffie-hellman-group1-sha1`
 as it's known to be vulnerable (logjam attack). Additionally, if your setup
 allows for it, it is recommended to disable the remaining two `sha1` key
 exchange algorithms.
+
+See also link:https://github.com/apache/mina-sshd/tree/master#key-exchange[key exchange,role=external,window=_blank].
+
+By default, all supported key exchange algorithms are available.
 --
 
 [[sshd.kerberosKeytab]]sshd.kerberosKeytab::
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 5889c75..3fa84b1 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -6,7 +6,10 @@
 link:access-control.html#category_review_labels[access controls].  Gerrit
 comes pre-configured with the Code-Review label that can be granted to
 groups within projects, enabling functionality for that group's members.
-
+Project owners and admins might also need to configure rules which require
+labels to be voted before a change can be submittable. See the
+link:config-submit-requirements.html[submit requirements] documentation to
+configure such rules.
 
 [[label_Code-Review]]
 == Label: Code-Review
@@ -18,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
@@ -28,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
@@ -61,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`
@@ -97,7 +100,7 @@
       value = -1 Fails
       value = 0 No score
       value = +1 Verified
-      copyAllScoresIfNoCodeChange = true
+      copyCondition = changekind:NO_CODE_CHANGE
 ----
 
 The range of values is:
@@ -176,6 +179,11 @@
 The name for a label, consisting only of alphanumeric characters and
 `-`.
 
+[[label_description]]
+=== `label.Label-Name.description`
+
+The label description. This field can provide extra information of what the
+label is supposed to do.
 
 [[label_value]]
 === `label.Label-Name.value`
@@ -200,7 +208,12 @@
 
 
 [[label_function]]
-=== `label.Label-Name.function`
+=== `label.Label-Name.function (deprecated)`
+
+Label functions dictate the rules for requiring certain label votes before a
+change is allowed for submission. Label functions are **deprecated**, favour
+using link:config-submit-requirements.html[submit requirements] instead,
+except if it's needed to set the value to `PatchSetLock`.
 
 The name of a function for evaluating multiple votes for a label.  This
 function is only applied if the default submit rule is used for a label.
@@ -265,6 +278,9 @@
 [[label_copyAnyScore]]
 === `label.Label-Name.copyAnyScore`
 
+*DEPRECATED: use `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.
 
@@ -284,29 +300,37 @@
 
 Matches if the diff between two patch sets was of a certain change kind.
 
+`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`.
+
+`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
+`changekind:MERGE_FIRST_PARENT_UPDATE` (only if the change is for a
+merge commit).
+
 ==== is:{MIN,MAX,ANY}
 
 Matches votes that are equal to the minimal or maximal voting range. Or any votes.
 
-==== approverin:link:rest-api-groups.html#group-id[\{group-id\}]
+==== 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\}]
 
 Matches votes granted by a user who is a member of
-link:rest-api-groups.html#group-id[\{group-id\}].
+link:#group-id[\{group-id\}].
 
-Avoid using a group name with spaces (if it has spaces, use the group uuid).
-Although supported for convenience, it's better to use group uuid than group
-name since using names only works as long as the names are unique (and future
-groups with the same name will break the query).
+[[uploaderin]]
+==== uploaderin:link:#group-id[\{group-id\}]
 
-==== uploaderin:link:rest-api-groups.html#group-id[\{group-id\}]
-
-Matches votes where the new patch set was uploaded by a member of
-link:rest-api-groups.html#group-id[\{group-id\}].
-
-Avoid using a group name with spaces (if it has spaces, use the group uuid).
-Although supported for convenience, it's better to use group uuid than group
-name since using names only works as long as the names are unique (and future
-groups with the same name will break the query).
+Matches all votes if the new patch set was uploaded by a member of
+link:#group-id[\{group-id\}].
 
 ==== has:unchanged-files
 
@@ -314,6 +338,29 @@
 
 Only 'unchanged-files' is supported for 'has'.
 
+[[group-id]]
+==== Group ID
+
+Some predicates (link:#approverin[approverin], link:#uploaderin[uploaderin])
+expect a group ID as value. This group ID can be any of the
+link:rest-api-groups.html#group-id[group identifiers] that are supported in the
+REST API: group UUID, group ID (for Gerrit internal groups only) and group name
+
+It's preferred to reference groups by UUID, rather than name. Referencing
+groups by name is not recommended because:
+
+* Groups may be renamed and then the group reference can no longer be resolved.
+  If this happens another group with different members can take over the group
+  name, so that exemptions which have been granted by this predicate apply to
+  the other group. This is a security concern.
+* Group names that contain spaces are not supported.
+* Ambiguous group names cannot be resolved. This means if another group with
+  the same name gets created at a later point in time, the group name can no
+  longer be resolved and the predicate breaks.
+
+Using the group UUID has a small drawback though, since it makes the condition
+less human-readable.
+
 ==== Example
 
 ----
@@ -323,6 +370,9 @@
 [[label_copyMinScore]]
 === `label.Label-Name.copyMinScore`
 
+*DEPRECATED: use `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.
@@ -330,6 +380,9 @@
 [[label_copyMaxScore]]
 === `label.Label-Name.copyMaxScore`
 
+*DEPRECATED: use `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
@@ -338,6 +391,9 @@
 [[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.
 
@@ -354,6 +410,9 @@
 [[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
@@ -371,6 +430,9 @@
 [[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
@@ -388,6 +450,9 @@
 [[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
@@ -402,6 +467,9 @@
 [[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
@@ -415,6 +483,9 @@
 [[label_copyValue]]
 === `label.Label-Name.copyValue`
 
+*DEPRECATED: use `is:<value>` predicate in
+link:config-labels.html#label_copyCondition[copyCondition] instead*
+
 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.
@@ -457,7 +528,7 @@
 `${shardeduserid}`.
 
 [[label_ignoreSelfApproval]]
-=== `label.Label-Name.ignoreSelfApproval`
+=== `label.Label-Name.ignoreSelfApproval (deprecated)`
 
 If true, the label may be voted on by the uploader of the latest patch set,
 but their approval does not make a change submittable. Instead, a
@@ -465,6 +536,13 @@
 
 Defaults to false.
 
+The `ignoreSelfApproval` attribute is **deprecated**, favour
+using link:config-submit-requirements.html[submit requirements] and
+define the `submittableIf` expression with the `label` operator and
+the `user=non_uploader` argument. See the
+link:config-submit-requirements.html#code-review-example[Code Review] submit
+requirement example.
+
 [[label_example]]
 === Example
 
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 029877b..8bd5dc7 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -226,6 +226,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 4dff685..29a4b9d 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -128,9 +128,10 @@
 
 - `Hidden`:
 +
-The project is hidden and only visible to project owners. Other users
-are not able to see the project even if they have read permissions
-granted on the project.
+The project is hidden; It will not appear in any searches and is only visible
+to project owners by going directly to the repository admin page. Other users
+are not able to see the project even if they have read permissions granted on
+the project.
 
 
 [[receive-section]]
@@ -142,7 +143,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`
@@ -481,6 +482,12 @@
 
 Please refer to link:config-labels.html#label_custom[Custom Labels] documentation.
 
+[[submit-requirement-section]]
+=== Submit Requirement section
+
+Please refer to link:config-submit-requirements.html[Configuring Submit
+Requirements] documentation.
+
 [[branchOrder-section]]
 === branchOrder section
 
@@ -526,7 +533,7 @@
 A boolean indicating if reviewers and CCs that do not currently have a Gerrit
 account can be added to a change by providing their email address.
 +
-This setting only takes affect for changes that are readable by anonymous users.
+This setting only takes effect for changes that are readable by anonymous users.
 +
 Default is `INHERIT`, which means that this property is inherited from
 the parent project. If the property is not set in any parent project, the
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
new file mode 100644
index 0000000..2686f39
--- /dev/null
+++ b/Documentation/config-submit-requirements.txt
@@ -0,0 +1,308 @@
+= Gerrit Code Review - Submit Requirements
+
+As part of the code review process, project owners need to configure rules that
+govern when changes become submittable. For example, an admin might want to
+prevent changes from being submittable until at least a “+2” vote on the
+“Code-Review” label is granted on a change. Admins can define submit
+requirements to enforce submittability rules for changes.
+
+[[configuring_submit_requirements]]
+== Configuring Submit Requirements
+
+Site administrators and project owners can define submit requirements in the
+link:config-project-config.html[project config]. A submit requirement has the
+following fields:
+
+
+[[submit_requirement_name]]
+=== submit-requirement.Name
+
+A name that uniquely identifies the submit requirement. Submit requirements
+can be overridden in child projects if they are defined with the same name in
+the child project. See the link:#inheritance[inheritance] section for more
+details.
+
+[[submit_requirement_description]]
+=== submit-requirement.Name.description
+
+A detailed description of what the submit requirement is supposed to do. This
+field is optional. The description is visible to the users in the change page
+upon hovering on the submit requirement to help them understand what the
+requirement is about and how it can be fulfilled.
+
+[[submit_requirement_applicable_if]]
+=== submit-requirement.Name.applicableIf
+
+A link:#query_expression_syntax[query expression] that determines if the submit
+requirement is applicable for a change. For example, administrators can exclude
+submit requirements for certain branch patterns. See the
+link:#exempt-branch-example[exempt branch] example.
+
+This field is optional, and if not specified, the submit requirement is
+considered applicable for all changes in the project.
+
+[[submit_requirement_submittable_if]]
+=== submit-requirement.Name.submittableIf
+
+A link:#query_expression_syntax[query expression] that determines when the
+change becomes submittable. This field is mandatory.
+
+
+[[submit_requirement_override_if]]
+=== submit-requirement.Name.overrideIf
+
+A link:#query_expression_syntax[query expression] that controls when the
+submit requirement is overridden. When this expression is evaluated to true,
+the submit requirement state becomes `OVERRIDDEN` and the submit requirement
+is no longer blocking the change submission.
+This expression can be used to enable bypassing the requirement in some
+circumstances, for example if the change owner is a power user or to allow
+change submission in case of emergencies. +
+
+This field is optional.
+
+[[submit_requirement_can_override_in_child_projects]]
+=== submit-requirement.Name.canOverrideInChildProjects
+
+A boolean (true, false) that determines if child projects can override the
+submit requirement. +
+
+The default value is `false`.
+
+[[evaluation_results]]
+== Evaluation Results
+
+When submit requirements are configured, their results are returned for all
+changes requested by the REST API with the
+link:rest-api-changes.html#submit-requirement-result-info[SubmitRequirementResultInfo]
+entity. +
+
+Submit requirement results are produced from the evaluation of the submit
+requirements in the project config (
+See link:#configuring_submit_requirements[Configuring Submit Requirements])
+as well as the conversion of the results of the legacy submit rules to submit
+requirement results. Legacy submit rules are label functions
+(see link:config-labels.html[config labels]), custom and
+link:prolog-cookbook.html[prolog] submit rules.
+
+The `status` field can be one of:
+
+* `NOT_APPLICABLE`
++
+The link:#submit_requirement_applicable_if[applicableIf] expression evaluates
+to false for the change.
+
+* `UNSATISFIED`
++
+The submit requirement is applicable
+(link:#submit_requirement_applicable_if[applicableIf] evaluates to true), but
+the evaluation of the link:#submit_requirement_submittable_if[submittableIf] and
+link:#submit_requirement_override_if[overrideIf] expressions return false for
+the change.
+
+* `SATISFIED`
++
+The submit requirement is applicable
+(link:#submit_requirement_applicable_if[applicableIf] evaluates to true), the
+link:#submit_requirement_submittable_if[submittableIf] expression evaluates to
+true, and the link:#submit_requirement_override_if[overrideIf] evaluates to
+false for the change.
+
+* `OVERRIDDEN`
++
+The submit requirement is applicable
+(link:#submit_requirement_applicable_if[applicableIf] evaluates to true) and the
+link:#submit_requirement_override_if[overrideIf] expression evaluates to true.
++
+Note that in this case, the change is overridden whether the
+link:#submit_requirement_submittable_if[submittableIf] expression evaluates to
+true or not.
+
+* `BYPASSED`
++
+The change was merged directly bypassing code review by supplying the
+link:user-upload.html#auto_merge[submit] push option while doing a git push.
+
+* `ERROR`
++
+The evaluation of any of the
+link:#submit_requirement_applicable_if[applicableIf],
+link:#submit_requirement_submittable_if[submittableIf] or
+link:#submit_requirement_override_if[overrideIf] expressions resulted in an
+error.
+
+
+[[query_expression_syntax]]
+== Query Expression Syntax
+
+All applicableIf, submittableIf and overrideIf expressions use the same syntax
+and operators available for link:user-search.html[searching changes]. In
+addition to that, submit requirements support extra operators.
+
+
+[[submit_requirements_operators]]
+=== Submit Requirements Operators
+
+[[operator_authoremail]]
+authoremail:'EMAIL_PATTERN'::
++
+An operator that returns true if the change author's email address matches a
+specific regular expression pattern. The
+link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns.
+
+[[operator_distinctvoters]]
+distinctvoters:'[Label1,Label2,...,LabelN],value=MAX,count>1'::
++
+An operator that allows checking for distinct voters across more than one label.
++
+2..N labels are supported, filtering by a value (MIN,MAX,integer) is optional.
+Count is mandatory.
++
+Examples:
+`distinctvoters:[Code-Review,Trust],value=MAX,count>1`
++
+`distinctvoters:[Code-Review,Trust,API-Review],count>2`
+
+[[operator_is_true]]
+is:true::
++
+An operator that always returns true for all changes. An example usage is to
+redefine a submit requirement in a child project and make the submit requirement
+always applicable.
+
+[[operator_is_false]]
+is:false::
++
+An operator that always returns false for all changes. An example usage is to
+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
+
+Some operators are not supported with submit requirement expressions.
+
+[[operator_is_submittable]]
+is:submittable::
++
+Cannot be used since it will result in recursive evaluation of expressions.
+
+[[inheritance]]
+== Inheritance
+
+Child projects can override a submit requirement defined in any of their parent
+projects. Overriding a submit requirement overrides all of its properties and
+values. The overriding project needs to define all mandatory fields.
+
+Submit requirements are looked up from the current project up the inheritance
+hierarchy to “All-Projects”. The first project in the hierarchy chain that sets
+link:#submit_requirement_can_override_in_child_projects[canOverrideInChildProjects]
+to false prevents all descendant projects from overriding it.
+
+If a project disallows a submit requirement from being overridden in child
+projects, all definitions of this submit requirement in descendant projects are
+ignored.
+
+To remove a submit requirement in a child project, administrators can redefine
+the requirement with the same name in the child project and set the
+link:#submit_requirement_applicable_if[applicableIf] expression to `is:false`.
+Since the link:#submit_requirement_submittable_if[submittableIf] field is
+mandatory, administrators need to provide it in the child project but can set it
+to anything, for example `is:false` but it will have no effect anyway.
+
+
+[[trigger-votes]]
+== Trigger Votes
+
+Trigger votes are label votes that are not associated with any submit
+requirement expressions. Trigger votes are displayed in a separate section in
+the change page. For more about configuring labels, see the
+link:config-labels.html[config labels] documentation.
+
+
+[[examples]]
+== Examples
+
+[[code-review-example]]
+=== Code-Review Example
+
+To define a submit requirement for code-review that requires a maximum vote for
+the “Code-Review” label from a non-uploader without a maximum negative vote:
+
+----
+[submit-requirement "Code-Review"]
+	description = A maximum vote from a non-uploader is required for the \
+	              'Code-Review' label. A minimum vote is blocking.
+	submittableIf = label:Code-Review=MAX,user=non_uploader AND -label:Code-Review=MIN
+	canOverrideInChildProjects = true
+----
+
+[[exempt-branch-example]]
+=== Exempt a branch Example
+
+We could exempt a submit requirement from certain branches. For example,
+project owners might want to skip the 'Code-Style' requirement from the
+refs/meta/config branch.
+
+----
+[submit-requirement "Code-Style"]
+  description = Code is properly styled and formatted
+  applicableIf = -branch:refs/meta/config
+  submittableIf = label:Code-Style=+1 AND -label:Code-Style=-1
+  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]
+
+SEARCHBOX
+---------
diff --git a/Documentation/config-themes.txt b/Documentation/config-themes.txt
index a83c747..73cfc55 100644
--- a/Documentation/config-themes.txt
+++ b/Documentation/config-themes.txt
@@ -4,11 +4,12 @@
 the browser, allowing organizations to alter the look and
 feel of the application to fit with their general scheme.
 
-== HTML Header/Footer and CSS
+== HTML Header/Footer and CSS for login screens
 
-The HTML header, footer and CSS may be customized for login
-screens (LDAP, OAuth, OpenId) and the internally managed
-Gitweb servlet.
+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.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
 uses them to customize the HTML page it sends to clients:
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 668a846..a2bd6fc 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -225,6 +225,19 @@
   bazel-bin/Documentation/searchfree.zip
 ----
 
+To use local fonts with the searchfree target:
+
+----
+  bazel build Documentation:searchfree_safe
+----
+
+The html files will be bundled into `searchfree.zip` or `searchfree_safe.zip` in this location:
+
+----
+  bazel-bin/Documentation/searchfree.zip
+  bazel-bin/Documentation/searchfree_safe.zip
+----
+
 To generate HTML files skipping the zip archiving:
 
 ----
@@ -282,18 +295,6 @@
   bazel test //javatests/com/google/gerrit/acceptance/rest/account:rest_account
 ----
 
-To run SSH tests using JSch ssh client:
-
-----
-  bazel test --test_env=SSH_CLIENT_IMPLEMENTATION=JSCH //...
-----
-
-To run SSH tests using Apache MINA ssh client:
-
-----
-  bazel test --test_env=SSH_CLIENT_IMPLEMENTATION=APACHE //...
-----
-
 To run only tests that do not use SSH:
 
 ----
diff --git a/Documentation/dev-community.txt b/Documentation/dev-community.txt
index 7488f74..adb69ff 100644
--- a/Documentation/dev-community.txt
+++ b/Documentation/dev-community.txt
@@ -13,7 +13,7 @@
 * link:https://www.gerritcodereview.com/codeofconduct.html[Code of Conduct,role=external,window=_blank]
 * link:https://www.gerritcodereview.com/releases-readme.html[Release Versions,role=external,window=_blank]
 * link:https://gerrit.googlesource.com/gerrit[Source,role=external,window=_blank]
-* link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking,role=external,window=_blank]
+* link:https://issues.gerritcodereview.com/issues?q=is:open[Issue Tracking,role=external,window=_blank]
 * link:https://gerrit-review.googlesource.com/q/status:open+project:gerrit[Change Review,role=external,window=_blank]
 * link:dev-design.html[System Design]
 * Processes
diff --git a/Documentation/dev-core-plugins.txt b/Documentation/dev-core-plugins.txt
index 6b777d3..ffce6c2 100644
--- a/Documentation/dev-core-plugins.txt
+++ b/Documentation/dev-core-plugins.txt
@@ -95,7 +95,7 @@
 
 1. Propose a plugin as core plugin:
 +
-File a link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Core+Plugin+Request[
+File a link:https://issues.gerritcodereview.com/issues/new?component=1371029&template=1834214[
 Core Plugin Request] in the issue tracker and provide the information that is
 being asked for in the request template.
 
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 c50a293..8e5463d 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -102,8 +102,7 @@
 === SSH keys
 
 If you are running SSH commands, the private keys of the users used for testing need to go in
-`/tmp/ssh-keys`. The keys need to be generated this way (JSch won't validate them
-link:https://stackoverflow.com/questions/53134212/invalid-privatekey-when-using-jsch[otherwise,role=external,window=_blank]):
+`/tmp/ssh-keys`. The keys need to be generated this way and won't be validated.
 
 ----
 mkdir /tmp/ssh-keys
@@ -174,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,
@@ -195,6 +197,13 @@
 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`
+
 ==== Automatic properties
 
 The link:#_input_file[example keywords,role=external,window=_blank] also include `_PROJECT`,
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index dce5eb0..e1cc7de 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -81,6 +81,11 @@
 link:dev-build-plugins.html#_bundle_custom_plugin_in_release_war[bundling in release.war]
 and run `tools/eclipse/project.py`.
 
+If a plugin requires additional test dependencies (not available in the Gerrit),
+then in order to execute tests directly from Eclipse, that plugin must be also
+added to `CUSTOM_PLUGINS_TEST_DEPS` list in `tools/bzl/plugins.bzl` and Eclipse
+project configuration needs to be updated by running `tools/eclipse/project.py`.
+
 == Java Versions
 
 Java 11 is supported as a default, but some adjustments must be done for other JDKs:
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index 149b14a..ab2082f 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -197,7 +197,7 @@
 use the instructions of <<dev-readme#run_daemon,Running the Daemon>> in
 combination with <<remote-debug,Debugging a remote Gerrit server>>.
 
-(link:https://bugs.chromium.org/p/gerrit/issues/detail?id=11360[Issue 11360,role=external,window=_blank])
+(link:https://issues.gerritcodereview.com/issues/40011100[Issue 40011100,role=external,window=_blank])
 ====
 
 Copy `$(gerrit_source_code)/tools/intellij/gerrit_daemon.xml` to
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 eb94ef7..11f85dd 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
@@ -2308,6 +2342,25 @@
 
 will cause the metrics to be recorded under `my-metrics/${metric-name}`.
 
+Note that metric name can be expressed through a limited set of characters
+`[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*` therefore one should either ensure it
+or call
+link:https://gerrit.googlesource.com/gerrit/+/master/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java[
+sanitizeMetricName,role=external,window=_blank] to have it enforced.
+
+The
+link:https://gerrit.googlesource.com/gerrit/+/master/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java[
+sanitizeMetricName,role=external,window=_blank] performs the following
+modifications:
+
+* leading `/` is replaced with `_`
+* doubled (or repeated more times) `/` are reduced to a single `/`
+* ending `/` is removed
+* all characters that are not `/a-zA-Z0-9_-` are replaced with
+  `\_0x[HEX CODE]_` (code is capitalized)
+* if the replacement prefix (`_0x`) is found then it is prepended with another
+  replacement prefix
+
 See the replication metrics in the
 link:https://gerrit.googlesource.com/plugins/replication/+/master/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationMetrics.java[
 replication plugin,role=external,window=_blank] for an example of usage.
@@ -2770,6 +2823,23 @@
 prevent callers using ETags from potentially seeing outdated submittability
 information.
 
+`SubmitRule` interface will soon deprecated. Instead, a global `SubmitRequirement`
+can be bound by plugin.
+
+[source, java]
+----
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.inject.AbstractModule;
+
+public class MyPluginModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(SubmitRequirement.class).annotatedWith(Exports.named("myPlugin"))
+        .toInstance(myPluginSubmitRequirement);
+  }
+}
+----
+
 [[change-etag-computation]]
 == Change ETag Computation
 
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index e43e021..175a159 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -208,7 +208,7 @@
 === How to report a security vulnerability?
 
 To report a security vulnerability file a
-link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Security+Issue[
+link:https://issues.gerritcodereview.com/issues/new?component=1371046[
 security issue,role=external,window=_blank] in the Gerrit issue tracker. The visibility of issues that are
 created with the `Security Issue` template is automatically restricted to
 Gerrit maintainers and a few long-term contributors. This means as a reporter
@@ -366,7 +366,10 @@
 * If the library is used within Google, the version of the library must be compatible with the
   version that is used at Google.
 
-Only maintainers from Google can vote on the `Library-Compliance` label.
+Only maintainers from Google can vote on the `Library-Compliance` label. The
+Gerrit team at Google uses this
+link:https://gerrit-review.googlesource.com/q/label:%2522Library-Compliance%253Dneed%2522+-ownerin:google-gerrit-team+status:open+project:gerrit+-age:4week+-is:wip+-is:private+label:Code-Review%252B2[change query]
+to find changes that require a `Library-Compliance` approval.
 
 Gerrit's library dependencies should only be upgraded if the new version contains
 something we need in Gerrit. This includes new features, API changes as well as bug
@@ -378,6 +381,12 @@
 that they are vetted long enough before they go into a release and we can be sure
 that the update doesn't introduce a regression.
 
+[[escalation-channel-to-google]]
+== Escalation channel to Google
+
+If anything urgent is blocking that requires the attention of a Googler you may
+escalate this by writing an email to Han-Wen Nienhuys: hanwen@google.com
+
 [[deprecating-features]]
 == Deprecating features
 
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 0849c56..30b7f58 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -94,19 +94,30 @@
 to the new version.
 
 [[update-issues]]
-=== Update the Issues
+[[link-fixed-issues]]
+=== Link fixed issues in the Release Notes
 
-Update the issues by hand. There is no script for this.
+Link the fixed issues by hand. There is no script for this.
 
-Our current process is an issue should be updated to say `Status =
-Submitted, FixedIn-$version` once the change is submitted, but before the
-release.
+All issues that are listed in commit messages since the previous version tag
+have been fixed in the new release and should be linked in the release notes.
 
-The updated issues are the ones listed in commit messages since the
-previous version tag. Mention each updated issue in the uploaded change,
-following the examples from the previous version notes. Add updated issue
-owners as reviewers of the uploaded change. More reviewers can be added
-or cc'ed, to further coordinate the final release contents.
+In addition the fixed issues should be added to the corresponding
+`FixedIn-$version` hotlist. Ideally issues are already added to this hotlist at
+the moment when a fix is submitted and the status of the issue is set to
+`Fixed`, but people may forget about this. Hence check whether there are issues
+that need to be added to this hotlist.
+
+[NOTE]
+If you link:https://issues.gerritcodereview.com/hotlists/new[create a new
+hotlist] for a release add it to this
+link:https://issues.gerritcodereview.com/bookmark-groups/763138/edit[bookmark
+group] so that people can find it easily.
+
+Mention each issue that has been fixed in the release in the uploaded change,
+following the examples from the previous version notes. Optionally, add the
+issue owners as reviewers to the uploaded change. More reviewers can be added or
+cc'ed, to further coordinate the final release contents.
 
 Similarly to issues, also mention every noteworthy change done after the
 previous release. Again, previous notes should be used as template examples.
@@ -343,14 +354,6 @@
 
 Submit any previously uploaded notes change on the homepage project.
 
-[[finalize-issues]]
-==== Update the Issues
-
-After the release is actually made, you can search (in Monorail) for
-`Status=Submitted FixedIn=$version` and then batch update these changes
-to say `Status=Released`. Make sure the pulldown says `All Issues`
-because `Status=Submitted` is considered a closed issue.
-
 [[announce]]
 ==== Announce on Mailing List
 
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index cecaedc..c097f33 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -21,7 +21,7 @@
 * attend community events like user summits (see
   link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[
   community calendar,role=external,window=_blank])
-* report link:https://bugs.chromium.org/p/gerrit/issues/list[issues,role=external,window=_blank]
+* report link:https://issues.gerritcodereview.com/issues?q=is:open[issues,role=external,window=_blank]
   and help to clarify existing issues
 * provide feedback on
   link:https://www.gerritcodereview.com/releases-readme.html[new
@@ -53,7 +53,7 @@
   comments and by voting (preferably on the `Verified` label,
   permissions to vote on the `Verified` label are granted by request,
   see below)
-* file issues in the link:https://bugs.chromium.org/p/gerrit/issues/list[
+* file issues in the link:https://issues.gerritcodereview.com/issues?q=is:open[
   issue tracker,role=external,window=_blank] and comment on existing issues
 * support the
   link:dev-processes.html#design-driven-contribution-process[
@@ -66,7 +66,9 @@
 link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list):
 
-* become member of the `gerrit-verifiers` group, which allows to:
+* become member of the
+  link:https://groups.google.com/g/gerritcodereview-trusted-contributors[gerrit-trusted-contributors]
+  group, which allows to:
 ** vote on the `Verified` and `Code-Style` labels
 ** edit hashtags on all changes
 ** edit topics on all open changes
@@ -75,7 +77,7 @@
   link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
   mailing list
 * administrate issues in the
-  link:https://bugs.chromium.org/p/gerrit/issues/list[issue tracker,role=external,window=_blank]
+  link:https://issues.gerritcodereview.com/issues?q=status:open[issue tracker,role=external,window=_blank]
 
 Supporters can become link:#contributor[contributors] by signing a
 contributor license agreement and contributing code to the Gerrit
@@ -106,7 +108,8 @@
 have. In addition they have signed a link:dev-cla.html[contributor
 license agreement] which enables them to push changes.
 
-Regular contributors can ask to be added to the `gerrit-verifiers`
+Regular contributors can ask to be added to the
+link:https://groups.google.com/g/gerritcodereview-trusted-contributors[gerrit-trusted-contributors]
 group, which allows to:
 
 * add patch sets to changes of other users
@@ -187,7 +190,7 @@
 * nominate new maintainers and vote on nominations (see below)
 * administrate the link:https://groups.google.com/d/forum/repo-discuss[
   mailing list,role=external,window=_blank], the
-  link:https://bugs.chromium.org/p/gerrit/issues/list[issue tracker,role=external,window=_blank]
+  link:https://issues.gerritcodereview.com/issues?q=is:open[issue tracker,role=external,window=_blank]
   and the link:https://www.gerritcodereview.com/[homepage,role=external,window=_blank]
 * gain permissions to do Gerrit releases and publish release artifacts
 * create new projects and groups on
@@ -255,9 +258,9 @@
 requests in a timely manner.
 
 Community members may submit new items under the
-link:https://bugs.chromium.org/p/gerrit/issues/list?q=component:ESC[ESC component,role=external,window=_blank]
-in the issue tracker, or add that component to existing items, to raise them to
-the attention of ESC members.
+link:https://issues.gerritcodereview.com/issues?q=componentid:1371029[SteeringCommittee component,role=external,window=_blank]
+in the issue tracker, or move existing issues to that component, to raise them
+to the attention of ESC members.
 
 Community members may contact the ESC members directly using
 mailto:gerritcodereview-esc@googlegroups.com[this mailing list].
@@ -316,7 +319,7 @@
 * serve as contact person for community issues
 
 Community members may submit new items under the
-link:https://bugs.chromium.org/p/gerrit/issues/list?q=component:Community[Community component,role=external,window=_blank]
+link:https://issues.gerritcodereview.com/issues?q=componentid:1371048[Community component,role=external,window=_blank]
 backlog, for community managers to refine. Only public topics should be
 issued through that backlog.
 
diff --git a/Documentation/dev-starter-projects.txt b/Documentation/dev-starter-projects.txt
index eef4806..92de84d 100644
--- a/Documentation/dev-starter-projects.txt
+++ b/Documentation/dev-starter-projects.txt
@@ -2,8 +2,8 @@
 = Gerrit Code Review - Starter Projects
 
 We have created a
-link:https://bugs.chromium.org/p/gerrit/issues/list?can=2&q=label%3AStarterProject[StarterProject,role=external,window=_blank]
-category in the issue tracker and try to assign easy hack projects to it. If in
+link:https://issues.gerritcodereview.com/hotlists/5052926[StarterProject,role=external,window=_blank]
+hotlist in the issue tracker and try to assign easy hack projects to it. If in
 doubt, do not hesitate to ask on the developer
 link:https://groups.google.com/forum/#!forum/repo-discuss[mailing list,role=external,window=_blank].
 
diff --git a/Documentation/error-has-duplicates.txt b/Documentation/error-has-duplicates.txt
index 9f9c8a8..1c22f64 100644
--- a/Documentation/error-has-duplicates.txt
+++ b/Documentation/error-has-duplicates.txt
@@ -11,7 +11,7 @@
 
 Since this error should never occur in practice, you should inform
 your Gerrit administrator if you hit this problem and/or
-link:https://bugs.chromium.org/p/gerrit/issues/list[open a Gerrit issue,role=external,window=_blank].
+link:https://issues.gerritcodereview.com/issues/new[open a Gerrit issue,role=external,window=_blank].
 
 In any case to not be blocked with your work, you can simply create a
 new Change-Id for your commit and then push it as new change to
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 782a6a9..e0ece47 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -43,6 +43,7 @@
 == Project Management
 . link:project-configuration.html[Project Configuration]
 .. link:config-labels.html[Review Labels]
+.. link:config-submit-requirements.html[Submit Requirements]
 .. link:config-project-config.html[Project Configuration File Format]
 . link:access-control.html[Access Controls]
 . Multi-project management
@@ -95,7 +96,7 @@
 * link:licenses.html[Licenses and Notices]
 * link:https://www.gerritcodereview.com/[Homepage,role=external,window=_blank]
 * link:https://gerrit-releases.storage.googleapis.com/index.html[Downloads,role=external,window=_blank]
-* link:https://bugs.chromium.org/p/gerrit/issues/list[Issue Tracking,role=external,window=_blank]
+* link:https://issues.gerritcodereview.com/issues?q=is:open[Issue Tracking,role=external,window=_blank]
 * link:https://gerrit.googlesource.com/gerrit[Source Code,role=external,window=_blank]
 * link:https://www.gerritcodereview.com/about.md[A History of Gerrit Code Review,role=external,window=_blank]
 
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-user.txt b/Documentation/intro-user.txt
index 1f0dfd0..c3abedb 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -662,6 +662,10 @@
 * If you have a series of private changes and share one with reviewers,
   the reviewers can also see the commits of the predecessor private
   changes through the commit parent relationship.
+* Users who would have permission to access the change except for its
+  private status and knowledge of its commit ID (e.g. through CI logs
+  or build artifacts containing build numbers) can fetch the code
+  using the commit ID.
 
 [[ignore]]
 == Ignoring Or Marking Changes As 'Reviewed'
@@ -807,16 +811,21 @@
 +
 This setting controls the email notifications.
 +
-** `Enabled`:
-+
-Email notifications are enabled.
-+
 ** [[cc-me]]`Every comment`:
 +
 Email notifications are enabled and you get notified by email as CC
 on comments that you write yourself.
 +
-** `Disabled`:
+** `Only comments left by others`
++
+Email notifications are enabled for all activities excluding comments or
+reviews authored by you.
++
+** `Only when I am in the attention set`
++
+Email notifications are only sent if the recipient is in the attention set.
++
+** `None`:
 +
 Email notifications are disabled.
 
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index ab79c8f..5b7e073 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -1120,6 +1120,126 @@
 ----
 
 
+[[highlight_js]]
+highlight.js
+
+* highlight.js
+
+[[highlight_js_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2006, Ivan Sagalaev.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[highlightjs-closure-templates]]
+highlightjs-closure-templates
+
+* highlightjs-closure-templates
+
+[[highlightjs-closure-templates_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2006, Ivan Sagalaev.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[highlightjs-structured-text]]
+highlightjs-structured-text
+
+* highlightjs-structured-text
+
+[[highlightjs-structured-text_license]]
+----
+BSD 3-Clause License

+

+Copyright (c) 2006, Ivan Sagalaev

+All rights reserved.

+

+Redistribution and use in source and binary forms, with or without

+modification, are permitted provided that the following conditions are met:

+

+1. Redistributions of source code must retain the above copyright notice, this

+   list of conditions and the following disclaimer.

+

+2. Redistributions in binary form must reproduce the above copyright notice,

+   this list of conditions and the following disclaimer in the documentation

+   and/or other materials provided with the distribution.

+

+3. Neither the name of the copyright holder nor the names of its

+   contributors may be used to endorse or promote products derived from

+   this software without specific prior written permission.

+

+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"

+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE

+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE

+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE

+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL

+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR

+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER

+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,

+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE

+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
 [[immer]]
 immer
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 735553d..e5966de 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -32,7 +32,7 @@
 Gerrit includes an SSH daemon (Apache SSHD), to support authenticated
 uploads of changes directly from `git push` command line clients.
 
-Gerrit includes an SSH client (JSch), to support authenticated
+Gerrit includes an SSH client (Apache SSHD), to support authenticated
 replication of changes to remote systems, such as for automatic
 updates of mirror servers, or realtime backups.
 
@@ -48,10 +48,10 @@
 * commons:codec
 * commons:compress
 * commons:dbcp
-* commons:lang
 * commons:lang3
 * commons:net
 * commons:pool
+* commons:text
 * commons:validator
 * dropwizard:dropwizard-core
 * errorprone:annotations
@@ -2331,7 +2331,6 @@
 * jgit-archive
 * jgit-servlet
 * jgit-ssh-apache
-* jgit-ssh-jsch
 
 [[jgit_license]]
 ----
@@ -2376,43 +2375,6 @@
 ----
 
 
-[[jsch]]
-jsch
-
-* jsch
-
-[[jsch_license]]
-----
-Copyright (c) 2002-2012 Atsuhiko Yamanaka, JCraft,Inc.
-All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-  1. Redistributions of source code must retain the above copyright notice,
-     this list of conditions and the following disclaimer.
-
-  2. Redistributions in binary form must reproduce the above copyright
-     notice, this list of conditions and the following disclaimer in
-     the documentation and/or other materials provided with the distribution.
-
-  3. The names of the authors may not be used to endorse or promote products
-     derived from this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES,
-INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
-FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JCRAFT,
-INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT,
-INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
-OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
-LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
-NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
-EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-----
-
-
 [[jsoup]]
 jsoup
 
@@ -4061,6 +4023,126 @@
 ----
 
 
+[[highlight_js]]
+highlight.js
+
+* highlight.js
+
+[[highlight_js_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2006, Ivan Sagalaev.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[highlightjs-closure-templates]]
+highlightjs-closure-templates
+
+* highlightjs-closure-templates
+
+[[highlightjs-closure-templates_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2006, Ivan Sagalaev.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+  contributors may be used to endorse or promote products derived from
+  this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[highlightjs-structured-text]]
+highlightjs-structured-text
+
+* highlightjs-structured-text
+
+[[highlightjs-structured-text_license]]
+----
+BSD 3-Clause License

+

+Copyright (c) 2006, Ivan Sagalaev

+All rights reserved.

+

+Redistribution and use in source and binary forms, with or without

+modification, are permitted provided that the following conditions are met:

+

+1. Redistributions of source code must retain the above copyright notice, this

+   list of conditions and the following disclaimer.

+

+2. Redistributions in binary form must reproduce the above copyright notice,

+   this list of conditions and the following disclaimer in the documentation

+   and/or other materials provided with the distribution.

+

+3. Neither the name of the copyright holder nor the names of its

+   contributors may be used to endorse or promote products derived from

+   this software without specific prior written permission.

+

+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"

+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE

+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE

+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE

+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL

+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR

+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER

+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,

+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE

+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
 [[immer]]
 immer
 
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 5470709..9e62328 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -116,7 +116,7 @@
 * `receivecommits/ps_revision_missing`: errors due to patch set revision missing
 * `receivecommits/push_count`: number of pushes
 ** `kind`:
-   The push kind (direct vs. magic).
+   The push kind (magic, direct or direct_submit).
 ** `project`:
    The name of the project for which the push is done.
 ** `type`:
@@ -131,6 +131,7 @@
 * `proc/cpu/usage`: CPU time used by the Gerrit process.
 * `proc/cpu/system_load`: System load average for the last minute.
 * `proc/num_open_fds`: Number of open file descriptors.
+* `proc/jvm/memory/allocated`: Total memory allocated by all threads since Gerrit process started.
 * `proc/jvm/memory/heap_committed`: Amount of memory guaranteed for user objects.
 * `proc/jvm/memory/heap_used`: Amount of memory holding user objects.
 * `proc/jvm/memory/non_heap_committed`: Amount of memory guaranteed for classes,
@@ -361,7 +362,14 @@
 * `cache_used_per_repository` : Bytes of memory retained per repository for the
   top N repositories having most data in the cache. The number N of reported
   repositories is limited to 1000.
-** `repository_name`: The name of the repository.
+** `repository_name`: The name of the repository. Note that it is a subject of
+   sanitization in order to avoid collision between repository names. Rules
+   are:
+*** any character outside `[a-zA-Z0-9_-]+([a-zA-Z0-9_-]+)*` pattern is replaced
+    with `\_0x[HEX CODE]_` (code is capitalized) string
+*** for instance `repo/name` is sanitized to `repo_0x2F_name`
+*** if repository name contains the replacement prefix (`_0x`) it is prefixed
+    with another `_0x` e.g. `repo_0x2F_name` becomes `repo_0x_0x2F_name`
 
 === Git
 
diff --git a/Documentation/pg-plugin-checks-api.txt b/Documentation/pg-plugin-checks-api.txt
index 4e93da1..ebfb862 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
@@ -42,3 +42,23 @@
 `checksApi.announceUpdate()`
 
 Tells Gerrit to call `provider.fetch()`.
+
+[[updateResult]]
+== updateResult
+`checksApi.updateResult(run: CheckRun, result: CheckResult)`
+
+Updates an individual result. This can be used for lazy laoding detailled
+information. For example, if you are using the
+link:pg-plugin-endpoints.html#_check_result_expanded[`check-result-expanded`
+endpoint], then you can load more result details when the user expands a result
+row.
+
+The parameter `run` is only used to *find* the correct run for updating the
+result. It will only be used for comparing `change`, `patchset`, `attempt` and
+`checkName`. Its properties other than `results` will not be updated.
+
+For us being able to identify the result that you want to update you have to
+set the `externalId` property. An undefined `externalId` will result in an
+error.
+
+An example usage can be found in link:https://chromium.googlesource.com/infra/gerrit-plugins/buildbucket/+/main/web/checks-result.ts[Chromium Buildbucket Plugin].
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index dc65da6..381c3e1 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.
-
 == TypeScript API ==
 
 Gerrit provides a TypeScript plugin API.
@@ -122,32 +118,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]]
@@ -173,10 +163,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)`
 
@@ -196,7 +182,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/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index c16d0d4..2227e79 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -86,6 +86,25 @@
 labels with scores applied to the change, map of the label names to
 link:rest-api-changes.html#label-info[LabelInfo] entries
 
+=== check-result-expanded
+The `check-result-expanded` extension point is attached to a result
+of the link:pg-plugin-checks-api.html[ChecksAPI] when it is expanded. This can
+be used to attach a Web Component displaying results instead of the
+`CheckResult.message` field which is limited to raw unformatted text.
+
+In addition to default parameters, the following are available:
+
+* `result`
++
+The `CheckResult` object for the currently expanded result row.
+
+* `run`
++
+Same as `result`. The `CheckRun` object is not passed to the endpoint.
+
+The end point contains the `<gr-formatted-text>` element holding the
+`CheckResult.message` (if any was set).
+
 === robot-comment-controls
 The `robot-comment-controls` extension point is located inside each comment
 rendered on the diff page, and is only visible when the comment is a robot
@@ -228,3 +247,13 @@
 +
 current revision displayed, an instance of
 link:rest-api-changes.html#revision-info[RevisionInfo]
+
+=== account-status-icon
+The `account-status-icon` extension point adds an icon to all account chips and
+labels.
+
+In addition to default parameters, the following are available:
+
+* `accountId`
++
+the Id of the account that the status icon should correspond to.
diff --git a/Documentation/pgm-daemon.txt b/Documentation/pgm-daemon.txt
index cf6560b..586f685 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -61,7 +61,7 @@
 	Run init before starting the daemon. This will create a new site or
 	upgrade an existing site.
 
---s::
+-s::
 	Start link:dev-inspector.html[Gerrit Inspector] on the console, a
 	built-in interactive inspection environment to assist debugging and
 	troubleshooting of Gerrit code.
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 5df78cc..f270ac5 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -224,19 +224,18 @@
 
 [[labels]]
 --
-* `LABELS`: a summary of each label required for submit, and
-  approvers that have granted (or rejected) with that label
-  as well as all reviewers by state, and reviewers that may
-  be removed by the current user.
+* `LABELS`: Includes the `labels` field which contains all information about
+labels and approvers that have granted (or rejected) that label. Does not
+include the `permitted_voting_range` attribute with the list of
+link:#approval-info[ApprovalInfo] of the `all` attribute.
 --
 
 [[detailed-labels]]
 --
-* `DETAILED_LABELS`: detailed label information, including numeric
-  values of all existing approvals, recognized label values, values
-  permitted to be set by any reviewer and the change owner, all
-  reviewers by state, and reviewers that may be removed by the
-  current user.
+* `DETAILED_LABELS`: Includes the `labels` field which contains all information
+about labels and approvers that have granted (or rejected) that label, including
+the `permitted_voting_range` attribute with the list of
+link:#approval-info[ApprovalInfo] of the `all` attribute.
 --
 
 [[submit-requirements]]
@@ -795,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"
@@ -1857,7 +1856,8 @@
 
 * The given change.
 * If link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
-  is enabled, include all open changes with the same topic.
+  is enabled OR if the `o=TOPIC_CLOSURE` query parameter is passed, include all
+  open changes with the same topic.
 * For each change whose submit type is not CHERRY_PICK, include unmerged
   ancestors targeting the same branch.
 
@@ -1884,7 +1884,7 @@
 
 Standard link:#query-options[formatting options] can be specified
 with the `o` parameter, as well as the `submitted_together` specific
-option `NON_VISIBLE_CHANGES`.
+options `NON_VISIBLE_CHANGES` and `TOPIC_CLOSURE`.
 
 .Response
 ----
@@ -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"
@@ -4581,60 +4581,6 @@
 If the `path` parameter is set, the returned content is a diff of the single
 file that the path refers to.
 
-[[submit-preview]]
-=== Submit Preview
---
-'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/preview_submit'
---
-Gets a file containing thin bundles of all modified projects if this
-change was submitted. The bundles are named `${ProjectName}.git`.
-Each thin bundle contains enough to construct the state in which a project would
-be in if this change were submitted. The base of the thin bundles are the
-current target branches, so to make use of this call in a non-racy way, first
-get the bundles and then fetch all projects contained in the bundle.
-(This assumes no non-fastforward pushes).
-
-You need to give a parameter '?format=zip' or '?format=tar' to specify the
-format for the outer container. It is always possible to use tgz, even if
-tgz is not in the list of allowed archive formats.
-
-To make good use of this call, you would roughly need code as found at:
-----
- $ curl -Lo preview_submit_test.sh http://review.example.com:8080/tools/scripts/preview_submit_test.sh
-----
-.Request
-----
-  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/preview_submit?zip HTTP/1.0
-----
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Date: Tue, 13 Sep 2016 19:13:46 GMT
-  Content-Disposition: attachment; filename="submit-preview-147.zip"
-  X-Content-Type-Options: nosniff
-  Cache-Control: no-cache, no-store, max-age=0, must-revalidate
-  Pragma: no-cache
-  Expires: Mon, 01 Jan 1990 00:00:00 GMT
-  Content-Type: application/x-zip
-  Transfer-Encoding: chunked
-
-  [binary stuff]
-----
-
-In case of an error, the response is not a zip file but a regular json response,
-containing only the error message:
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  "Anonymous users cannot submit"
-----
-
 [[get-mergeable]]
 === Get Mergeable
 --
@@ -5084,6 +5030,9 @@
 with a new message, which contains the name of the user who deletes
 the comment and the reason why it's deleted.
 
+This endpoint also marks the comment as resolved since deleted comments are not
+actionable.
+
 Note that only users with the
 link:access-control.html#capability_administrateServer[Administrate Server]
 global capability are permitted to delete a comment.
@@ -5224,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'
 --
@@ -5308,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.
@@ -6529,7 +6478,14 @@
 |`topic`              |optional|The topic to which this change belongs.
 |`attention_set`      |optional|
 The map that maps link:rest-api-accounts.html#account-id[account IDs]
-to link:#attention-set-info[AttentionSetInfo] of that account.
+to link:#attention-set-info[AttentionSetInfo] of that account. Those are all
+accounts that are currently in the attention set.
+|`removed_from_attention_set`      |optional|
+The map that maps link:rest-api-accounts.html#account-id[account IDs]
+to link:#attention-set-info[AttentionSetInfo] of that account. Those are all
+accounts that were in the attention set but were removed. The
+link:#attention-set-info[AttentionSetInfo] is the latest and most recent removal
+ of the account from the attention set.
 |`assignee`           |optional|
 The assignee of the change as an link:rest-api-accounts.html#account-info[
 AccountInfo] entity.
@@ -6807,28 +6763,28 @@
 
 [options="header",cols="1,^1,5"]
 |===========================
-|Field Name         ||Description
-|`message`          |optional|
+|Field Name          ||Description
+|`message`           |optional|
 Commit message for the cherry-pick change. If not set, the commit message of
 the cherry-picked commit is used.
-|`destination`      ||Destination branch
-|`base`             |optional|
+|`destination`       ||Destination branch
+|`base`              |optional|
 40-hex digit SHA-1 of the commit which will be the parent commit of the newly created change.
 If set, it must be a merged commit or a change revision on the destination branch.
-|`parent`           |optional, defaults to 1|
+|`parent`            |optional, defaults to 1|
 Number of the parent relative to which the cherry-pick should be considered.
-|`notify`           |optional|
+|`notify`            |optional|
 Notify handling that defines to whom email notifications should be sent
 after the cherry-pick. +
 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 update as a map
 of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
-|`keep_reviewers`   |optional, defaults to false|
+|`keep_reviewers`    |optional, defaults to false|
 If `true`, carries reviewers and ccs over from original change to newly created one.
-|`allow_conflicts`  |optional, defaults to false|
+|`allow_conflicts`   |optional, defaults to false|
 If `true`, the cherry-pick uses content merge and succeeds also if
 there are conflicts. If there are conflicts the file contents of the
 created change contain git conflict markers to indicate the conflicts.
@@ -6836,7 +6792,7 @@
 `contains_git_conflicts` field in the link:#change-info[ChangeInfo]. If
 there are conflicts the cherry-pick change is marked as
 work-in-progress.
-|`topic`            |optional|
+|`topic`             |optional|
 The topic of the created cherry-picked change. If not set, the default depends
 on the source. If the source is a change with a topic, the resulting topic
 of the cherry-picked change will be {source_change_topic}-{destination_branch}.
@@ -6844,10 +6800,18 @@
 the created change will have no topic.
 If the change already exists, the topic will not change if not set. If set, the
 topic will be overridden.
-|`allow_empty`      |optional, defaults to false|
+|`allow_empty`       |optional, defaults to false|
 If `true`, the cherry-pick succeeds also if the created commit will be empty.
 If `false`, a cherry-pick that would create an empty commit fails without creating
 the commit.
+|`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.
 |===========================
 
 [[comment-info]]
@@ -7113,6 +7077,9 @@
 Additional information about whom to notify about the update as a map
 of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
+|`ignore_automatic_attention_set_rules`|optional|
+If set to true, ignore all automatic attention set rules described in the
+link:#attention-set[attention set]. When not set, the default is false.
 |=============================
 
 [[description-input]]
@@ -7164,7 +7131,11 @@
 |==========================
 |Field Name    ||Description
 |`name`        ||The name of the file.
-|`content_type`||The content type of the file.
+|`content_type`||The content type of the file. For the commit message and merge
+list the value is `text/x-gerrit-commit-message` and `text/x-gerrit-merge-list`
+respectively. For git links the value is `x-git/gitlink`. For symlinks the value
+is `x-git/symlink`. For regular files the value is the file mime type (e.g.
+`text/x-java`, `text/x-c++src`, etc...).
 |`lines`       ||The total number of lines in the file.
 |`web_links`   |optional|
 Links to the file in external sites as a list of
@@ -7433,11 +7404,10 @@
 There are two options that control the contents of `LabelInfo`:
 link:#labels[`LABELS`] and link:#detailed-labels[`DETAILED_LABELS`].
 
-* For a quick summary of the state of labels, use `LABELS`.
-* For detailed information about labels, including exact numeric votes for all
-  users and the allowed range of votes for the current user, use `DETAILED_LABELS`.
+* Using `LABELS` will skip the `all.permitted_voting_range` attribute.
+* Using `DETAILED_LABELS` will include the `all.permitted_voting_range`
+  attribute.
 
-==== Common fields
 [options="header",cols="1,^1,5"]
 |===========================
 |Field Name    ||Description
@@ -7445,12 +7415,7 @@
 Whether the label is optional. Optional means the label may be set, but
 it's neither necessary for submission nor does it block submission if
 set.
-|===========================
-
-==== Fields set by `LABELS`
-[options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
+|`description` |optional| The description of the label.
 |`approved`    |optional|One user who approved this label on the change
 (voted the maximum value) as an
 link:rest-api-accounts.html#account-info[AccountInfo] entity.
@@ -7470,16 +7435,14 @@
 "`+1`"/"`-1`".
 |`default_value`|optional|The default voting value for the label.
 This value may be outside the range specified in permitted_labels.
-|===========================
-
-==== Fields set by `DETAILED_LABELS`
-[options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
+|votes|optional|A list of integers containing the vote values applied to this
+label at the latest patchset.
 |`all`         |optional|List of all approvals for this label as a list
 of link:#approval-info[ApprovalInfo] entities. Items in this list may
 not represent actual votes cast by users; if a user votes on any label,
-a corresponding ApprovalInfo will appear in this list for all labels.
+a corresponding ApprovalInfo will appear in this list for all labels. +
+The `permitted_voting_range` attribute is only set if the `DETAILED_LABELS`
+option is requested.
 |`values`      |optional|A map of all values that are allowed for this
 label. The map maps the values ("`-2`", "`-1`", " `0`", "`+1`", "`+2`")
 to the value descriptions.
@@ -7697,22 +7660,30 @@
 === RebaseInput
 The `RebaseInput` entity contains information for changing parent when rebasing.
 
-[options="header",width="50%",cols="1,^1,5"]
+[options="header",cols="1,^1,5"]
 |===========================
-|Field Name    ||Description
-|`base`        |optional|
+|Field Name          ||Description
+|`base`              |optional|
 The new parent revision. This can be a ref or a SHA-1 to a concrete patchset. +
 Alternatively, a change number can be specified, in which case the current
 patch set is inferred. +
 Empty string is used for rebasing directly on top of the target branch,
 which effectively breaks dependency towards a parent change.
-|`allow_conflicts`|optional, defaults to false|
+|`allow_conflicts`   |optional, defaults to false|
 If `true`, the rebase also succeeds if there are conflicts. +
 If there are conflicts the file contents of the rebased patch set contain
 git conflict markers to indicate the conflicts. +
 Callers can find out whether there were conflicts by checking the
 `contains_git_conflicts` field in the returned link:#change-info[ChangeInfo]. +
 If there are conflicts the change is marked as work-in-progress.
+|`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.
 |===========================
 
 [[related-change-and-commit-info]]
@@ -8272,21 +8243,24 @@
 The `SubmitRequirementExpressionInfo` describes the result of evaluating a
 single submit requirement expression, for example `label:code-review=+2`.
 
-[options="header",cols="1,6"]
+[options="header",cols="1,^1,5"]
 |===========================
-|Field Name      |Description
-|`expression`|
+|Field Name      ||Description
+|`expression`|optional|
 The submit requirement expression as a string, for example
 `branch:refs/heads/foo and label:verified=+1`.
-|`fulfilled`|
+|`fulfilled`||
 True if the submit requirement is fulfilled for the change.
-|`passing_atoms`|
+|`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
 fulfilled for the change.
-|`failing_atoms`|
+|`failing_atoms`|optional|
 A list of failing atoms. This is similar to `passing_atoms` except that it
 contains the list of predicates that are not fulfilled for the change.
+|`error_message`|optional|
+If the submit requirement fails during evaluation, this string will contain
+an error message describing why it failed.
 |===========================
 
 [[submit-requirement-input]]
@@ -8329,7 +8303,8 @@
 Description of the submit requirement.
 |`status`||
 Status describing the result of evaluating the submit requirement. The status
-is one of (`SATISFIED`, `UNSATISFIED`, `OVERRIDDEN`, `NOT_APPLICABLE`, `ERROR`).
+is one of (`SATISFIED`, `UNSATISFIED`, `OVERRIDDEN`, `NOT_APPLICABLE`, `ERROR`,
+`FORCED`).
 |`is_legacy`||
 If true, this submit requirement result was created from a legacy
 link:#submit-record[SubmitRecord]. Otherwise, it was created by evaluating a
@@ -8338,13 +8313,18 @@
 A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
 containing the result of evaluating the applicability expression. Not set if the
 submit requirement did not define an applicability expression.
-|`submittability_expression_result`||
+Note that fields `expression`, `passing_atoms` and `failing_atoms` are always
+omitted for the `applicability_expression_result`.
+|`submittability_expression_result`|optional|
 A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
-containing the result of evaluating the submittability expression.
+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.
 |`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.
+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.
 |===========================
 
 [[submitted-together-info]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index bab3ff0..90e1884 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1569,6 +1569,8 @@
 Returns true if attention set UI features are enabled.
 |`enable_assignee` |defaults to `true`|
 Returns true if assignee related UI features are enabled.
+|`conflicts_predicate_enabled`|not set if `false`|
+link:config-gerrit.html#change.conflictsPredicateEnabled[Are conflicts enabled?].
 |=============================
 
 [[change-index-config-info]]
@@ -2008,6 +2010,10 @@
 |`default_theme`           |optional|
 URL to a default Gerrit UI theme plugin, if available.
 Located in `/static/gerrit-theme.js` by default.
+|`submit_requirement_dashboard_columns` ||
+The list of submit requirement names that should be displayed as separate
+columns in the dashboard. If empty, the default is to display all submit
+requirements that are applicable for changes appearing in the dashboard.
 |=======================================
 
 [[sshd-info]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 82b6553..fef0b08 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -268,7 +268,20 @@
 Tree(t)::
 Get projects inheritance in a tree-like format. This option does
 not work together with the branch option.
-+
+
+[NOTE]
+If the calling user does not meet any of the following criteria:
+
+* The state of the parent project is either "ACTIVE" or "READ ONLY",
+and the calling user has READ permission to at least one ref.
+* The state of the parent project is "HIDDEN" and the calling user
+has READ permission for 'refs/meta/config'.
+
+Then the 'parent' field will be labeled as '?-N', where N represents the
+nesting level within the project's tree structure. In the provided example,
+'All-Projects' corresponds to level 1, 'parent-project' to level 2, and
+'child-project' to level 3.
+
 Get all the projects with tree option:
 +
 .Request
@@ -3072,8 +3085,8 @@
       "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"
       },
@@ -3119,8 +3132,8 @@
       "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"
       },
@@ -3137,8 +3150,8 @@
       "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"
       },
@@ -3182,8 +3195,8 @@
     "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"
     },
@@ -3219,8 +3232,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,8 +3256,8 @@
     "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"
     },
@@ -3295,8 +3308,8 @@
     "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"
     },
@@ -3377,8 +3390,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"
       }
@@ -3547,14 +3560,22 @@
 
 [options="header",cols="1,^2,4"]
 |=======================
-|Field Name||Description
-|`ref`     |optional|
+|Field Name          ||Description
+|`ref`               |optional|
 The name of the branch. The prefix `refs/heads/` can be
 omitted. +
 If set, must match the branch ID in the URL.
-|`revision`|optional|
+|`revision`          |optional|
 The base revision of the new branch. +
 If not set, `HEAD` will be used as base revision.
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the ref operation
+validation listeners (e.g. can be used to skip certain validations). Which
+validation options are supported depends on the installed ref operation
+validation listeners. Gerrit core doesn't support any validation options, but
+ref operation 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.
 |=======================
 
 [[check-project-input]]
@@ -3986,6 +4007,8 @@
 |Field Name      ||Description
 |`name`          ||
 The link:config-labels.html#label_name[name] of the label.
+|`description`   |optional|
+The description of the label.
 |`project_name`  ||
 The name of the project in which this label is defined.
 |`function`      ||
@@ -4006,30 +4029,32 @@
 Whether this label can be link:config-labels.html#label_canOverride[overridden]
 by child projects.
 |`copy_any_score`|`false` if not set|
-Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
-label.
+*DEPRECATED* Whether link:config-labels.html#label_copyAnyScore[copyAnyScore]
+is set on the label.
 |`copy_condition`|optional|
 See link:config-labels.html#label_copyCondition[copyCondition].
 |`copy_min_score`|`false` if not set|
-Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
-label.
+*DEPRECATED* Whether link:config-labels.html#label_copyMinScore[copyMinScore]
+is set on the label.
 |`copy_max_score`|`false` if not set|
-Whether link:config-labels.html#label_copyMaxScore[copyMaxScore] is set on the
-label.
+*DEPRECATED* Whether link:config-labels.html#label_copyMaxScore[copyMaxScore]
+is set on the label.
 |`copy_all_scores_if_no_change`|`false` if not set|
-Whether link:config-labels.html#label_copyAllScoresIfNoChange[
+*DEPRECATED* Whether link:config-labels.html#label_copyAllScoresIfNoChange[
 copyAllScoresIfNoChange] is set on the label.
 |`copy_all_scores_if_no_code_change`|`false` if not set|
-Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
+*DEPRECATED* Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
 copyAllScoresIfNoCodeChange] is set on the label.
 |`copy_all_scores_on_trivial_rebase`|`false` if not set|
-Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
+*DEPRECATED* Whether link:config-labels.html#label_copyAllScoresOnTrivialRebase[
 copyAllScoresOnTrivialRebase] is set on the label.
 |`copy_all_scores_if_list_of_files_did_not_change`|`false` if not set|
-Whether link:config-labels.html#label_copyAllScoresIfListOfFilesDidNotChange[
+*DEPRECATED* Whether
+link:config-labels.html#label_copyAllScoresIfListOfFilesDidNotChange[
 copyAllScoresIfListOfFilesDidNotChange] is set on the label.
 |`copy_all_scores_on_merge_first_parent_update`|`false` if not set|
-Whether link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
+*DEPRECATED* Whether
+link:config-labels.html#label_copyAllScoresOnMergeFirstParentUpdate[
 copyAllScoresOnMergeFirstParentUpdate] is set on the label.
 |`copy_values`   |optional|
 List of values that should be copied forward when a new patch set is uploaded.
@@ -4059,6 +4084,8 @@
 For label creation the name is required if this `LabelDefinitionInput` entity
 is contained in a link:#batch-label-input[BatchLabelInput]
 entity.
+|`description`          |optional|
+The new description for the label.
 |`function`      |optional|
 The new link:config-labels.html#label_function[function] of the label (can be
 `MaxWithBlock`, `AnyWithBlock`, `MaxNoBlock`, `NoBlock`, `NoOp` and `PatchSetLock`.
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 1f67fc7..625942a 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -1,10 +1,5 @@
 = Gerrit Code Review - Attention Set
 
-Report a bug or send feedback using
-link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set[this Monorail template].
-You can also report a bug through the bug icon in the user hovercard and in the
-reply dialog.
-
 [[whose-turn]]
 == Whose turn is it?
 
@@ -133,24 +128,6 @@
 Gerrit-Attention: Marian Harbach <mharbach@google.com>
 ----
 
-=== Assignee
-
-While the "Assignee" feature can still be used together with the attention set,
-we do not recommend doing so. Using both features is likely confusing. The
-distinct feature of the "Assignee" compared to the attention set is that only
-one user can be the assignee at the same time. So the assignee can be used to
-single out one person or escalate, if there are multiple reviewers. Since
-*every* reviewer in the attention set is expected to take action, singling out
-is not likely to be important and also still achievable with the attention set.
-Otherwise "Assignee" and "Attention Set" are very much overlapping, so we
-recommend to only use one of them.
-
-If you don't expect action from reviewers, then consider adding them to CC
-instead.
-
-The "Assignee" feature can be turned on/off with the
-link:config-gerrit.html#change.enableAssignee[enableAssignee] config option.
-
 === Bold Changes / Mark Reviewed
 
 Before the attention set feature, changes were bolded in the dashboard when
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 0e658c7..932acab 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -11,9 +11,9 @@
 
 Those are the available recipient types:
 
-* `to`: The standard To field is used; addresses are visible to all.
-* `cc`: The standard CC field is used; addresses are visible to all.
-* `bcc`: SMTP RCPT TO is used to hide the address.
+* `TO`: The standard To field is used; addresses are visible to all.
+* `CC`: The standard CC field is used; addresses are visible to all.
+* `BCC`: SMTP RCPT TO is used to hide the address.
 
 [[user]]
 == User Level Settings
diff --git a/Documentation/user-porting-comments.txt b/Documentation/user-porting-comments.txt
index 8b6c005..86cfc76 100644
--- a/Documentation/user-porting-comments.txt
+++ b/Documentation/user-porting-comments.txt
@@ -1,7 +1,5 @@
 #  Porting Comments User Documentation
 
-Report a bug or send feedback using this [Monorail template](https://bugs.chromium.org/p/gerrit/issues/entry?template=Porting+Comments). You can also report a bug through the bug icon in the comment.
-
 Comments in Gerrit are associated with a patchset. When a new patchset is uploaded, the comments are lost since they are not associated with the newer patchset.
 
 image::images/user-porting-comments-original-comment.png["Comment left on Patchset 15", align="center"]
diff --git a/Documentation/user-request-tracing.txt b/Documentation/user-request-tracing.txt
index 303242df..d1fbb97 100644
--- a/Documentation/user-request-tracing.txt
+++ b/Documentation/user-request-tracing.txt
@@ -75,7 +75,7 @@
 
 [[auto-retry-succeeded]]
 If an auto-retry succeeds you may consider filing this as
-link:https://bugs.chromium.org/p/gerrit/issues/entry?template=GoogleSource+Issue[
+link:https://issues.gerritcodereview.com/issues/new?component=1371020[
 Gerrit issue,role=external,window=_blank] so that the Gerrit developers can fix this and treat this
 exception as recoverable.
 
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index ccc83eb..cc7d5d1 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -49,6 +49,10 @@
 returned results. Search can also be performed by typing only a
 text with no operator, which will match against a variety of fields.
 
+Characters in operator values can be escaped by enclosing the value with
+double quotes and escaping characters with a backslash. For example
+`message:"This \"is\" fixing a bug"`.
+
 [[age]]
 age:'AGE'::
 +
@@ -262,6 +266,11 @@
 often combined with 'branch:' and 'project:' operators to select
 all related changes in a series.
 
+[[prefixtopic]]
+prefixtopic:'TOPIC'::
++
+Changes whose designated topic start with 'TOPIC'.
+
 [[inhashtag]]
 inhashtag:'HASHTAG'::
 +
@@ -278,6 +287,12 @@
 Changes whose link:intro-user.html#hashtags[hashtag] matches 'HASHTAG'.
 The match is case-insensitive.
 
+[[prefixhashtag]]
+prefixhashtag:'HASHTAG'::
++
+Changes whose link:intro-user.html#hashtags[hashtag] start with 'HASHTAG'.
+The match is case-insensitive.
+
 [[cherrypickof]]
 cherrypickof:'CHANGE[,PATCHSET]'::
 +
@@ -321,6 +336,12 @@
 message:'MESSAGE'::
 +
 Changes that match 'MESSAGE' arbitrary string in the commit message body.
+By default full text matching is used, but regular expressions can be
+enabled by starting with `^`.
+The link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns. Note, that searching with
+regular expressions is limited to the first 32766 bytes of the
+commit message due to limitations in Lucene.
 
 [[comment]]
 comment:'TEXT'::
@@ -406,6 +427,12 @@
 current patch set. 'FOOTER' can be specified verbatim ('<key>: <value>', must
 be quoted) or as '<key>=<value>'. The matching is done case-insensitive.
 
+[[hasfooter-operator]]
+hasfooter:'FOOTERNAME'::
++
+Matches any change that has a commit message with a footer where the footer
+name is equal to 'FOOTERNAME'.The matching is done case-sensitive.
+
 [[star]]
 star:'LABEL'::
 +
@@ -505,6 +532,7 @@
 +
 Same as <<status,status:'STATE'>>.
 
+[[is-submittable]]
 is:submittable::
 +
 True if the change is submittable according to the submit rules for
@@ -516,8 +544,6 @@
 use the
 link:rest-api-changes.html#get-revision-actions[Get Revision Actions]
 API.
-+
-Equivalent to <<submittable,submittable:ok>>.
 
 [[mergeable]]
 is:mergeable::
@@ -563,6 +589,11 @@
 cherry-picked locally using the git cherry-pick command and then
 pushed to Gerrit.
 
+[[pure-revert]]
+is:pure-revert::
++
+True if the change is a pure revert.
+
 [[status]]
 status:open, status:pending, status:new::
 +
@@ -632,16 +663,6 @@
 email address. The special case of `committer:self` will find changes committed
 by the caller.
 
-
-[[submittable]]
-submittable:'SUBMIT_STATUS'::
-+
-Changes having the given submit record status after applying submit
-rules. Valid statuses are in the `status` field of
-link:rest-api-changes.html#submit-record[SubmitRecord]. This operator
-only applies to the top-level status; individual label statuses can be
-searched link:#labels[by label].
-
 [[rule]]
 rule:'SUBMIT_RULE_NAME'::
 +
@@ -649,8 +670,7 @@
 FORCED}. This means that the submit rule has passed and is not blocking the
 change submission. 'SUBMIT_RULE_NAME' should be in the form of
 '$plugin_name~$rule_name'. For gerrit core rules, 'SUBMIT_RULE_NAME' should
-be in the form of '$rule_name' (example: `DefaultSubmitRule`), or
-'gerrit~$rule_name' (example: `gerrit~DefaultSubmitRule`).
+be in the form of 'gerrit~$rule_name' (example: `gerrit~DefaultSubmitRule`).
 
 rule:'SUBMIT_RULE_NAME'='STATUS'::
 +
@@ -770,12 +790,41 @@
 +
 Matches changes with label voted with any score.
 
-`label:Non-Author-Code-Review=need`::
+`label:Code-Review=+1,count=2`::
++
+Matches changes with exactly two +1 votes to the code-review label. The {MAX,
+MIN, ANY} votes can also be used, for example `label:Code-Review=MAX,count=2` is
+equivalent to `label:Code-Review=2,count=2` (if 2 is the maximum positive vote
+for the code review label). The maximum supported value for `count` is 5.
+`count=0` is not allowed and the query request will fail with `400 Bad Request`.
+
+`label:Code-Review=+1,count>=2`::
++
+Matches changes having two or more +1 votes to the code-review label. Can also
+be used with the {MAX, MIN, ANY} label votes. All operators `>`, `>=`, `<`, `<=`
+are supported.
+Note that a query like `label:Code-Review=+1,count<x` will not match with
+changes having zero +1 votes to this label.
+
+`label:Non-Author-Code-Review=need` (deprecated)::
 +
 Matches changes where the submit rules indicate that a label named
 `Non-Author-Code-Review` is needed. (See the
 link:prolog-cookbook.html#NonAuthorCodeReview[Prolog Cookbook] for how
 this label can be configured.)
++
+This operator is also compatible with
+link:config-submit-requirements.html[submit requirement] results. A submit
+requirement name could be used instead of the label name. The submit record
+statuses are mapped to submit requirement result statuses as follows:
++
+  * {`need`, `reject`} -> {`UNSATISFED`}
+  * {`ok`, `may`} -> {`SATISFIED`, `OVERRIDDEN`}
++
+For example, a query like `label:Code-Review=ok` will also match changes
+having a submit requirement with a result that is either `SATISFIED` or
+`OVERRIDDEN`. Users are encouraged not to rely on this operator since submit
+records are deprecated.
 
 `label:Code-Review=+2,aname`::
 `label:Code-Review=ok,aname`::
@@ -813,7 +862,7 @@
 +
 Matches changes that are ready to be submitted according to one common
 label configuration. (For a more general check, use
-link:#submittable[submittable:ok].)
+link:#is-submittable[is:submittable].)
 
 `is:open (label:Verified-1 OR label:Code-Review-2)`::
 `is:open (label:Verified=reject OR label:Code-Review=reject)`::
diff --git a/WORKSPACE b/WORKSPACE
index 13dcddb..fe6b94e 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -31,6 +31,7 @@
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "maven_jar")
 load("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
 load("//tools:nongoogle.bzl", "declare_nongoogle_deps")
+load("//tools:deps.bzl", "CAFFEINE_VERS", "java_dependencies")
 
 http_archive(
     name = "platforms",
@@ -53,10 +54,10 @@
 
 http_archive(
     name = "com_google_protobuf",
-    sha256 = "d0f5f605d0d656007ce6c8b5a82df3037e1d8fe8b121ed42e536f569dec16113",
-    strip_prefix = "protobuf-3.14.0",
+    sha256 = "3bd7828aa5af4b13b99c191e8b1e884ebfa9ad371b0ce264605d347f135d2568",
+    strip_prefix = "protobuf-3.19.4",
     urls = [
-        "https://github.com/protocolbuffers/protobuf/archive/v3.14.0.tar.gz",
+        "https://github.com/protocolbuffers/protobuf/archive/v3.19.4.tar.gz",
     ],
 )
 
@@ -116,10 +117,10 @@
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
-    sha256 = "4d838e2d70b955ef9dd0d0648f673141df1bc1d7ecf5c2d621dcc163f47dd38a",
+    sha256 = "d6b2513456fe2229811da7eb67a444be7785f5323c6708b38d851d2b51e54d83",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
-        "https://github.com/bazelbuild/rules_go/releases/download/v0.24.12/rules_go-v0.24.12.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.30.0/rules_go-v0.30.0.zip",
+        "https://github.com/bazelbuild/rules_go/releases/download/v0.30.0/rules_go-v0.30.0.zip",
     ],
 )
 
@@ -127,14 +128,14 @@
 
 go_rules_dependencies()
 
-go_register_toolchains()
+go_register_toolchains(version = "1.17.6")
 
 http_archive(
     name = "bazel_gazelle",
-    sha256 = "222e49f034ca7a1d1231422cdb67066b885819885c356673cb1f72f748a3c9d4",
+    sha256 = "de69a09dc70417580aabf20a28619bb3ef60d038470c7cf8442fafcf627c21cb",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.tar.gz",
-        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.22.3/bazel-gazelle-v0.22.3.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.24.0/bazel-gazelle-v0.24.0.tar.gz",
+        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.24.0/bazel-gazelle-v0.24.0.tar.gz",
     ],
 )
 
@@ -159,85 +160,9 @@
     path = "modules/jgit",
 )
 
-ANTLR_VERS = "3.5.2"
+java_dependencies()
 
-maven_jar(
-    name = "java-runtime",
-    artifact = "org.antlr:antlr-runtime:" + ANTLR_VERS,
-    sha1 = "cd9cd41361c155f3af0f653009dcecb08d8b4afd",
-)
-
-maven_jar(
-    name = "stringtemplate",
-    artifact = "org.antlr:stringtemplate:4.0.2",
-    sha1 = "e28e09e2d44d60506a7bcb004d6c23ff35c6ac08",
-)
-
-maven_jar(
-    name = "org-antlr",
-    artifact = "org.antlr:antlr:" + ANTLR_VERS,
-    sha1 = "c4a65c950bfc3e7d04309c515b2177c00baf7764",
-)
-
-maven_jar(
-    name = "antlr27",
-    artifact = "antlr:antlr:2.7.7",
-    attach_source = False,
-    sha1 = "83cd2cd674a217ade95a4bb83a8a14f351f48bd0",
-)
-
-maven_jar(
-    name = "aopalliance",
-    artifact = "aopalliance:aopalliance:1.0",
-    sha1 = "0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8",
-)
-
-maven_jar(
-    name = "javax_inject",
-    artifact = "javax.inject:javax.inject:1",
-    sha1 = "6975da39a7040257bd51d21a231b76c915872d38",
-)
-
-maven_jar(
-    name = "servlet-api",
-    artifact = "javax.servlet:javax.servlet-api:3.1.0",
-    sha1 = "3cd63d075497751784b2fa84be59432f4905bf7c",
-)
-
-maven_jar(
-    name = "jzlib",
-    artifact = "com.jcraft:jzlib:1.1.1",
-    sha1 = "a1551373315ffc2f96130a0e5704f74e151777ba",
-)
-
-maven_jar(
-    name = "javaewah",
-    artifact = "com.googlecode.javaewah:JavaEWAH:1.1.6",
-    attach_source = False,
-    sha1 = "94ad16d728b374d65bd897625f3fbb3da223a2b6",
-)
-
-maven_jar(
-    name = "error-prone-annotations",
-    artifact = "com.google.errorprone:error_prone_annotations:2.3.3",
-    sha1 = "42aa5155a54a87d70af32d4b0d06bf43779de0e2",
-)
-
-maven_jar(
-    name = "gson",
-    artifact = "com.google.code.gson:gson:2.8.5",
-    sha1 = "f645ed69d595b24d4cf8b3fbb64cc505bede8829",
-)
-
-CAFFEINE_VERS = "2.8.5"
-
-maven_jar(
-    name = "caffeine",
-    artifact = "com.github.ben-manes.caffeine:caffeine:" + CAFFEINE_VERS,
-    sha1 = "f0eafef6e1529a44e36549cd9d1fc06d3a57f384",
-)
-
-CAFFEINE_GUAVA_SHA256 = "a7ce6d29c40bccd688815a6734070c55b20cd326351a06886a6144005aa32299"
+CAFFEINE_GUAVA_SHA256 = "6e48965614557ba4d3c55a197e20c38f23a20032ef8aace37e95ed64d2ebc9a6"
 
 # TODO(davido): Rename guava.jar to caffeine-guava.jar on fetch to prevent potential
 # naming collision between caffeine guava adapter and guava library itself.
@@ -257,650 +182,8 @@
     ],
 )
 
-maven_jar(
-    name = "guava-failureaccess",
-    artifact = "com.google.guava:failureaccess:1.0.1",
-    sha1 = "1dcf1de382a0bf95a3d8b0849546c88bac1292c9",
-)
-
-maven_jar(
-    name = "jsch",
-    artifact = "com.jcraft:jsch:0.1.54",
-    sha1 = "da3584329a263616e277e15462b387addd1b208d",
-)
-
-maven_jar(
-    name = "juniversalchardet",
-    artifact = "com.github.albfernandez:juniversalchardet:2.0.0",
-    sha1 = "28c59f58f5adcc307604602e2aa89e2aca14c554",
-)
-
-maven_jar(
-    name = "json-smart",
-    artifact = "net.minidev:json-smart:1.1.1",
-    sha1 = "24a2f903d25e004de30ac602c5b47f2d4e420a59",
-)
-
-maven_jar(
-    name = "args4j",
-    artifact = "args4j:args4j:2.33",
-    sha1 = "bd87a75374a6d6523de82fef51fc3cfe9baf9fc9",
-)
-
-maven_jar(
-    name = "commons-codec",
-    artifact = "commons-codec:commons-codec:1.10",
-    sha1 = "4b95f4897fa13f2cd904aee711aeafc0c5295cd8",
-)
-
-# When upgrading commons-compress, also upgrade tukaani-xz
-maven_jar(
-    name = "commons-compress",
-    artifact = "org.apache.commons:commons-compress:1.22",
-    sha1 = "691a8b4e6cf4248c3bc72c8b719337d5cb7359fa",
-)
-
-maven_jar(
-    name = "commons-lang",
-    artifact = "commons-lang:commons-lang:2.6",
-    sha1 = "0ce1edb914c94ebc388f086c6827e8bdeec71ac2",
-)
-
-maven_jar(
-    name = "commons-lang3",
-    artifact = "org.apache.commons:commons-lang3:3.8.1",
-    sha1 = "6505a72a097d9270f7a9e7bf42c4238283247755",
-)
-
-maven_jar(
-    name = "commons-text",
-    artifact = "org.apache.commons:commons-text:1.2",
-    sha1 = "74acdec7237f576c4803fff0c1008ab8a3808b2b",
-)
-
-maven_jar(
-    name = "commons-dbcp",
-    artifact = "commons-dbcp:commons-dbcp:1.4",
-    sha1 = "30be73c965cc990b153a100aaaaafcf239f82d39",
-)
-
-# Transitive dependency of commons-dbcp, do not update without
-# also updating commons-dbcp
-maven_jar(
-    name = "commons-pool",
-    artifact = "commons-pool:commons-pool:1.5.5",
-    sha1 = "7d8ffbdc47aa0c5a8afe5dc2aaf512f369f1d19b",
-)
-
-maven_jar(
-    name = "commons-net",
-    artifact = "commons-net:commons-net:3.6",
-    sha1 = "b71de00508dcb078d2b24b5fa7e538636de9b3da",
-)
-
-maven_jar(
-    name = "commons-validator",
-    artifact = "commons-validator:commons-validator:1.6",
-    sha1 = "e989d1e87cdd60575df0765ed5bac65c905d7908",
-)
-
-maven_jar(
-    name = "automaton",
-    artifact = "dk.brics:automaton:1.12-1",
-    sha1 = "959a0c62f9a5c2309e0ad0b0589c74d69e101241",
-)
-
-COMMONMARK_VERS = "0.10.0"
-
-# commonmark must match the version used in Gitiles
-maven_jar(
-    name = "commonmark",
-    artifact = "com.atlassian.commonmark:commonmark:" + COMMONMARK_VERS,
-    sha1 = "119cb7bedc3570d9ecb64ec69ab7686b5c20559b",
-)
-
-maven_jar(
-    name = "cm-autolink",
-    artifact = "com.atlassian.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERS,
-    sha1 = "a6056a5efbd68f57d420bc51bbc54b28a5d3c56b",
-)
-
-maven_jar(
-    name = "gfm-strikethrough",
-    artifact = "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERS,
-    sha1 = "40837da951b421b545edddac57012e15fcc9e63c",
-)
-
-maven_jar(
-    name = "gfm-tables",
-    artifact = "com.atlassian.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERS,
-    sha1 = "c075db2a3301100cf70c7dced8ecf86b494458a2",
-)
-
-FLEXMARK_VERS = "0.50.42"
-
-maven_jar(
-    name = "flexmark",
-    artifact = "com.vladsch.flexmark:flexmark:" + FLEXMARK_VERS,
-    sha1 = "ed537d7bc31883b008cc17d243a691c7efd12a72",
-)
-
-maven_jar(
-    name = "flexmark-ext-abbreviation",
-    artifact = "com.vladsch.flexmark:flexmark-ext-abbreviation:" + FLEXMARK_VERS,
-    sha1 = "dc27c3e7abbc8d2cfb154f41c68645c365bb9d22",
-)
-
-maven_jar(
-    name = "flexmark-ext-anchorlink",
-    artifact = "com.vladsch.flexmark:flexmark-ext-anchorlink:" + FLEXMARK_VERS,
-    sha1 = "6a8edb0165f695c9c19b7143a7fbd78c25c3b99c",
-)
-
-maven_jar(
-    name = "flexmark-ext-autolink",
-    artifact = "com.vladsch.flexmark:flexmark-ext-autolink:" + FLEXMARK_VERS,
-    sha1 = "5da7a4d009ea08ef2d8714cc73e54a992c6d2d9a",
-)
-
-maven_jar(
-    name = "flexmark-ext-definition",
-    artifact = "com.vladsch.flexmark:flexmark-ext-definition:" + FLEXMARK_VERS,
-    sha1 = "862d17812654624ed81ce8fc89c5ef819ff45f87",
-)
-
-maven_jar(
-    name = "flexmark-ext-emoji",
-    artifact = "com.vladsch.flexmark:flexmark-ext-emoji:" + FLEXMARK_VERS,
-    sha1 = "f0d7db64cb546798742b1ffc6db316a33f6acd76",
-)
-
-maven_jar(
-    name = "flexmark-ext-escaped-character",
-    artifact = "com.vladsch.flexmark:flexmark-ext-escaped-character:" + FLEXMARK_VERS,
-    sha1 = "6fd9ab77619df417df949721cb29c45914b326f8",
-)
-
-maven_jar(
-    name = "flexmark-ext-footnotes",
-    artifact = "com.vladsch.flexmark:flexmark-ext-footnotes:" + FLEXMARK_VERS,
-    sha1 = "e36bd69e43147cc6e19c3f55e4b27c0fc5a3d88c",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-issues",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-issues:" + FLEXMARK_VERS,
-    sha1 = "5c825dd4e4fa4f7ccbe30dc92d7e35cdcb8a8c24",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-strikethrough",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:" + FLEXMARK_VERS,
-    sha1 = "3256735fd77e7228bf40f7888b4d3dc56787add4",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-tables",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tables:" + FLEXMARK_VERS,
-    sha1 = "62f0efcfb974756940ebe749fd4eb01323babc29",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-tasklist",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:" + FLEXMARK_VERS,
-    sha1 = "76d4971ad9ce02f0e70351ab6bd06ad8e405e40d",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-users",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-users:" + FLEXMARK_VERS,
-    sha1 = "7b0fc7e42e4da508da167fcf8e1cbf9ba7e21147",
-)
-
-maven_jar(
-    name = "flexmark-ext-ins",
-    artifact = "com.vladsch.flexmark:flexmark-ext-ins:" + FLEXMARK_VERS,
-    sha1 = "9e51809867b9c4db0fb1c29599b4574e3d2a78e9",
-)
-
-maven_jar(
-    name = "flexmark-ext-jekyll-front-matter",
-    artifact = "com.vladsch.flexmark:flexmark-ext-jekyll-front-matter:" + FLEXMARK_VERS,
-    sha1 = "44eb6dbb33b3831d3b40af938ddcd99c9c16a654",
-)
-
-maven_jar(
-    name = "flexmark-ext-superscript",
-    artifact = "com.vladsch.flexmark:flexmark-ext-superscript:" + FLEXMARK_VERS,
-    sha1 = "35815b8cb91000344d1fe5df21cacde8553d2994",
-)
-
-maven_jar(
-    name = "flexmark-ext-tables",
-    artifact = "com.vladsch.flexmark:flexmark-ext-tables:" + FLEXMARK_VERS,
-    sha1 = "f6768e98c7210b79d5e8bab76fff27eec6db51e6",
-)
-
-maven_jar(
-    name = "flexmark-ext-toc",
-    artifact = "com.vladsch.flexmark:flexmark-ext-toc:" + FLEXMARK_VERS,
-    sha1 = "1968d038fc6c8156f244f5a7eecb34e7e2f33705",
-)
-
-maven_jar(
-    name = "flexmark-ext-typographic",
-    artifact = "com.vladsch.flexmark:flexmark-ext-typographic:" + FLEXMARK_VERS,
-    sha1 = "6549b9862b61c4434a855a733237103df9162849",
-)
-
-maven_jar(
-    name = "flexmark-ext-wikilink",
-    artifact = "com.vladsch.flexmark:flexmark-ext-wikilink:" + FLEXMARK_VERS,
-    sha1 = "e105b09dd35aab6e6f5c54dfe062ee59bd6f786a",
-)
-
-maven_jar(
-    name = "flexmark-ext-yaml-front-matter",
-    artifact = "com.vladsch.flexmark:flexmark-ext-yaml-front-matter:" + FLEXMARK_VERS,
-    sha1 = "b2d3a1e7f3985841062e8d3203617e29c6c21b52",
-)
-
-maven_jar(
-    name = "flexmark-formatter",
-    artifact = "com.vladsch.flexmark:flexmark-formatter:" + FLEXMARK_VERS,
-    sha1 = "a50c6cb10f6d623fc4354a572c583de1372d217f",
-)
-
-maven_jar(
-    name = "flexmark-html-parser",
-    artifact = "com.vladsch.flexmark:flexmark-html-parser:" + FLEXMARK_VERS,
-    sha1 = "46c075f30017e131c1ada8538f1d8eacf652b044",
-)
-
-maven_jar(
-    name = "flexmark-profile-pegdown",
-    artifact = "com.vladsch.flexmark:flexmark-profile-pegdown:" + FLEXMARK_VERS,
-    sha1 = "d9aafd47629959cbeddd731f327ae090fc92b60f",
-)
-
-maven_jar(
-    name = "flexmark-util",
-    artifact = "com.vladsch.flexmark:flexmark-util:" + FLEXMARK_VERS,
-    sha1 = "417a9821d5d80ddacbfecadc6843ae7b259d5112",
-)
-
-# Transitive dependency of flexmark and gitiles
-maven_jar(
-    name = "autolink",
-    artifact = "org.nibor.autolink:autolink:0.7.0",
-    sha1 = "649f9f13422cf50c926febe6035662ae25dc89b2",
-)
-
-GREENMAIL_VERS = "1.5.5"
-
-maven_jar(
-    name = "greenmail",
-    artifact = "com.icegreen:greenmail:" + GREENMAIL_VERS,
-    sha1 = "9ea96384ad2cb8118c22f493b529eb72c212691c",
-)
-
-MAIL_VERS = "1.6.0"
-
-maven_jar(
-    name = "mail",
-    artifact = "com.sun.mail:javax.mail:" + MAIL_VERS,
-    sha1 = "a055c648842c4954c1f7db7254f45d9ad565e278",
-)
-
-MIME4J_VERS = "0.8.1"
-
-maven_jar(
-    name = "mime4j-core",
-    artifact = "org.apache.james:apache-mime4j-core:" + MIME4J_VERS,
-    sha1 = "c62dfe18a3b827a2c626ade0ffba44562ddf3f61",
-)
-
-maven_jar(
-    name = "mime4j-dom",
-    artifact = "org.apache.james:apache-mime4j-dom:" + MIME4J_VERS,
-    sha1 = "f2d653c617004193f3350330d907f77b60c88c56",
-)
-
-maven_jar(
-    name = "jsoup",
-    artifact = "org.jsoup:jsoup:1.9.2",
-    sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
-)
-
-OW2_VERS = "9.0"
-
-maven_jar(
-    name = "ow2-asm",
-    artifact = "org.ow2.asm:asm:" + OW2_VERS,
-    sha1 = "af582ff60bc567c42d931500c3fdc20e0141ddf9",
-)
-
-maven_jar(
-    name = "ow2-asm-analysis",
-    artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-    sha1 = "4630afefbb43939c739445dde0af1a5729a0fb4e",
-)
-
-maven_jar(
-    name = "ow2-asm-commons",
-    artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-    sha1 = "5a34a3a9ac44f362f35d1b27932380b0031a3334",
-)
-
-maven_jar(
-    name = "ow2-asm-tree",
-    artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-    sha1 = "9df939f25c556b0c7efe00701d47e77a49837f24",
-)
-
-maven_jar(
-    name = "ow2-asm-util",
-    artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-    sha1 = "7c059a94ab5eed3347bf954e27fab58e52968848",
-)
-
-AUTO_VALUE_VERSION = "1.7.4"
-
-maven_jar(
-    name = "auto-value",
-    artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
-    sha1 = "6b126cb218af768339e4d6e95a9b0ae41f74e73d",
-)
-
-maven_jar(
-    name = "auto-value-annotations",
-    artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
-    sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
-)
-
-AUTO_VALUE_GSON_VERSION = "1.3.1"
-
-maven_jar(
-    name = "auto-value-gson-runtime",
-    artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
-    sha1 = "addda2ae6cce9f855788274df5de55dde4de7b71",
-)
-
-maven_jar(
-    name = "auto-value-gson-extension",
-    artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION,
-    sha1 = "0c4c01a3e10e5b10df2e5f5697efa4bb3f453ac1",
-)
-
-maven_jar(
-    name = "auto-value-gson-factory",
-    artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION,
-    sha1 = "9ed8d79144ee8d60cc94cc11f847b5ed8ee9f19c",
-)
-
-maven_jar(
-    name = "javapoet",
-    artifact = "com.squareup:javapoet:1.13.0",
-    sha1 = "d6562d385049f35eb50403fa86bb11cce76b866a",
-)
-
-maven_jar(
-    name = "autotransient",
-    artifact = "io.sweers.autotransient:autotransient:1.0.0",
-    sha1 = "38b1c630b8e76560221622289f37be40105abb3d",
-)
-
 declare_nongoogle_deps()
 
-maven_jar(
-    name = "mime-util",
-    artifact = "eu.medsea.mimeutil:mime-util:2.1.3",
-    attach_source = False,
-    sha1 = "0c9cfae15c74f62491d4f28def0dff1dabe52a47",
-)
-
-PROLOG_VERS = "1.4.4"
-
-PROLOG_REPO = GERRIT
-
-maven_jar(
-    name = "prolog-runtime",
-    artifact = "com.googlecode.prolog-cafe:prolog-runtime:" + PROLOG_VERS,
-    attach_source = False,
-    repository = PROLOG_REPO,
-    sha1 = "e9a364f4233481cce63239e8e68a6190c8f58acd",
-)
-
-maven_jar(
-    name = "prolog-compiler",
-    artifact = "com.googlecode.prolog-cafe:prolog-compiler:" + PROLOG_VERS,
-    attach_source = False,
-    repository = PROLOG_REPO,
-    sha1 = "570295026f6aa7b905e423d107cb2e081eecdc04",
-)
-
-maven_jar(
-    name = "prolog-io",
-    artifact = "com.googlecode.prolog-cafe:prolog-io:" + PROLOG_VERS,
-    attach_source = False,
-    repository = PROLOG_REPO,
-    sha1 = "1f25c4e27d22bdbc31481ee0c962a2a2853e4428",
-)
-
-maven_jar(
-    name = "cafeteria",
-    artifact = "com.googlecode.prolog-cafe:prolog-cafeteria:" + PROLOG_VERS,
-    attach_source = False,
-    repository = PROLOG_REPO,
-    sha1 = "0e6c2deeaf5054815a561cbd663566fd59b56c6c",
-)
-
-maven_jar(
-    name = "guava-retrying",
-    artifact = "com.github.rholder:guava-retrying:2.0.0",
-    sha1 = "974bc0a04a11cc4806f7c20a34703bd23c34e7f4",
-)
-
-maven_jar(
-    name = "jsr305",
-    artifact = "com.google.code.findbugs:jsr305:3.0.1",
-    sha1 = "f7be08ec23c21485b9b5a1cf1654c2ec8c58168d",
-)
-
-GITILES_VERS = "0.4-1"
-
-GITILES_REPO = GERRIT
-
-maven_jar(
-    name = "blame-cache",
-    artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
-    attach_source = False,
-    repository = GITILES_REPO,
-    sha1 = "0df80c6b8822147e1f116fd7804b8a0de544f402",
-)
-
-maven_jar(
-    name = "gitiles-servlet",
-    artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
-    repository = GITILES_REPO,
-    sha1 = "60870897d22b840e65623fd024eabd9cc9706ebe",
-)
-
-# prettify must match the version used in Gitiles
-maven_jar(
-    name = "prettify",
-    artifact = "com.github.twalcari:java-prettify:1.2.2",
-    sha1 = "b8ba1c1eb8b2e45cfd465d01218c6060e887572e",
-)
-
-maven_jar(
-    name = "html-types",
-    artifact = "com.google.common.html.types:types:1.0.8",
-    sha1 = "9e9cf7bc4b2a60efeb5f5581fe46d17c068e0777",
-)
-
-maven_jar(
-    name = "icu4j",
-    artifact = "com.ibm.icu:icu4j:57.1",
-    sha1 = "198ea005f41219f038f4291f0b0e9f3259730e92",
-)
-
-# When updating Bouncy Castle, also update it in bazlets.
-BC_VERS = "1.72"
-
-maven_jar(
-    name = "bcprov",
-    artifact = "org.bouncycastle:bcprov-jdk18on:" + BC_VERS,
-    sha1 = "d8dc62c28a3497d29c93fee3e71c00b27dff41b4",
-)
-
-maven_jar(
-    name = "bcpg",
-    artifact = "org.bouncycastle:bcpg-jdk18on:" + BC_VERS,
-    sha1 = "1a36a1740d07869161f6f0d01fae8d72dd1d8320",
-)
-
-maven_jar(
-    name = "bcpkix",
-    artifact = "org.bouncycastle:bcpkix-jdk18on:" + BC_VERS,
-    sha1 = "bb3fdb5162ccd5085e8d7e57fada4d8eaa571f5a",
-)
-
-maven_jar(
-    name = "bcutil",
-    artifact = "org.bouncycastle:bcutil-jdk18on:" + BC_VERS,
-    sha1 = "41f19a69ada3b06fa48781120d8bebe1ba955c77",
-)
-
-maven_jar(
-    name = "h2",
-    artifact = "com.h2database:h2:1.3.176",
-    sha1 = "fd369423346b2f1525c413e33f8cf95b09c92cbd",
-)
-
-HTTPCOMP_VERS = "4.5.2"
-
-maven_jar(
-    name = "fluent-hc",
-    artifact = "org.apache.httpcomponents:fluent-hc:" + HTTPCOMP_VERS,
-    sha1 = "7bfdfa49de6d720ad3c8cedb6a5238eec564dfed",
-)
-
-maven_jar(
-    name = "httpclient",
-    artifact = "org.apache.httpcomponents:httpclient:" + HTTPCOMP_VERS,
-    sha1 = "733db77aa8d9b2d68015189df76ab06304406e50",
-)
-
-maven_jar(
-    name = "httpcore",
-    artifact = "org.apache.httpcomponents:httpcore:4.4.4",
-    sha1 = "b31526a230871fbe285fbcbe2813f9c0839ae9b0",
-)
-
-# Test-only dependencies below.
-
-maven_jar(
-    name = "junit",
-    artifact = "junit:junit:4.12",
-    sha1 = "2973d150c0dc1fefe998f834810d68f278ea58ec",
-)
-
-maven_jar(
-    name = "diffutils",
-    artifact = "com.googlecode.java-diff-utils:diffutils:1.3.0",
-    sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
-)
-
-JETTY_VERS = "9.4.53.v20231009"
-
-maven_jar(
-    name = "jetty-servlet",
-    artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
-    sha1 = "6670d6a54cdcaedd8090e8cf420fd5dd7d08e859",
-)
-
-maven_jar(
-    name = "jetty-security",
-    artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
-    sha1 = "6fbc8ebe9046954dc2f51d4ba69c8f8344b05f7f",
-)
-
-maven_jar(
-    name = "jetty-server",
-    artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
-    sha1 = "8b0e761a0b359db59dae77c00b4213b0586cb994",
-)
-
-maven_jar(
-    name = "jetty-jmx",
-    artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
-    sha1 = "f0392f756b59f65ea7d6be41bf7a2f7b2c7c98d5",
-)
-
-maven_jar(
-    name = "jetty-http",
-    artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
-    sha1 = "87faf21eb322753f0527bcb88c43e67044786369",
-)
-
-maven_jar(
-    name = "jetty-io",
-    artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
-    sha1 = "70cf7649b27c964ad29bfddf58f3bfe0d30346cf",
-)
-
-maven_jar(
-    name = "jetty-util",
-    artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
-    sha1 = "f72bb4f687b4454052c6f06528ba9910714df947",
-)
-
-maven_jar(
-    name = "jetty-util-ajax",
-    artifact = "org.eclipse.jetty:jetty-util-ajax:" + JETTY_VERS,
-    sha1 = "4d20f6206eb7747293697c5f64c2dc5bf4bd54a4",
-    src_sha1 = "1aed8017c3c8a449323901639de6b4eb3b1f02ea",
-)
-
-maven_jar(
-    name = "asciidoctor",
-    artifact = "org.asciidoctor:asciidoctorj:1.5.7",
-    sha1 = "8e8c1d8fc6144405700dd8df3b177f2801ac5987",
-)
-
-maven_jar(
-    name = "javax-activation",
-    artifact = "javax.activation:activation:1.1.1",
-    sha1 = "485de3a253e23f645037828c07f1d7f1af40763a",
-)
-
-maven_jar(
-    name = "mockito",
-    artifact = "org.mockito:mockito-core:3.3.3",
-    sha1 = "4878395d4e63173f3825e17e5e0690e8054445f1",
-)
-
-BYTE_BUDDY_VERSION = "1.10.7"
-
-maven_jar(
-    name = "bytebuddy",
-    artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION,
-    sha1 = "1eefb7dd1b032b33c773ca0a17d5cc9e6b56ea1a",
-)
-
-maven_jar(
-    name = "bytebuddy-agent",
-    artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION,
-    sha1 = "c472fad33f617228601172682aa64f8b78508045",
-)
-
-maven_jar(
-    name = "objenesis",
-    artifact = "org.objenesis:objenesis:3.0.1",
-    sha1 = "11cfac598df9dc48bb9ed9357ed04212694b7808",
-)
-
 load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
 
 node_repositories(
diff --git a/antlr3/com/google/gerrit/index/query/Query.g b/antlr3/com/google/gerrit/index/query/Query.g
index 1bf20aa..0994d9b 100644
--- a/antlr3/com/google/gerrit/index/query/Query.g
+++ b/antlr3/com/google/gerrit/index/query/Query.g
@@ -152,18 +152,19 @@
   :  ( ' ' | '\r' | '\t' | '\n' ) { $channel=HIDDEN; }
   ;
 
+fragment LOWERCASE_AND_UNDERSCORE: ('a'..'z' | '_')+ ;
+
 FIELD_NAME
-  : ('a'..'z' | '_')+
+  : LOWERCASE_AND_UNDERSCORE ( '-' LOWERCASE_AND_UNDERSCORE )*
   ;
 
 EXACT_PHRASE
-  : '"' ( ~('"') )* '"' {
-      String s = $text;
-      setText(s.substring(1, s.length() - 1));
+@init { final StringBuilder buf = new StringBuilder(); }
+  : '"' ( ESCAPE[buf] | i = ~('\\'|'"') { buf.appendCodePoint(i); } )* '"' {
+      setText(buf.toString());
     }
-  | '{' ( ~('{'|'}') )* '}' {
-      String s = $text;
-      setText(s.substring(1, s.length() - 1));
+  | '{' ( ESCAPE[buf] | i = ~('\\'|'{'|'}') { buf.appendCodePoint(i); } )* '}' {
+      setText(buf.toString());
     }
   ;
 
@@ -197,3 +198,11 @@
      // | '~' permit
      )
   ;
+
+fragment ESCAPE[StringBuilder buf] :
+    '\\'
+    ( 't' { buf.append('\t'); }
+    | 'n' { buf.append('\n'); }
+    | 'r' { buf.append('\r'); }
+    | i = ~('t'|'n'|'r') { buf.appendCodePoint(i); }
+    );
diff --git a/contrib/convertkey/src/main/java/com/googlesource/gerrit/convertkey/ConvertKey.java b/contrib/convertkey/src/main/java/com/googlesource/gerrit/convertkey/ConvertKey.java
deleted file mode 100644
index 08a529c..0000000
--- a/contrib/convertkey/src/main/java/com/googlesource/gerrit/convertkey/ConvertKey.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// 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.
-
-package com.googlesource.gerrit.convertkey;
-
-import com.jcraft.jsch.HostKey;
-import com.jcraft.jsch.JSchException;
-import java.io.File;
-import java.io.IOException;
-import java.io.StringWriter;
-import java.security.GeneralSecurityException;
-import java.security.KeyPair;
-import org.apache.sshd.common.util.Buffer;
-import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
-import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
-
-public class ConvertKey {
-  public static void main(String[] args)
-      throws GeneralSecurityException, JSchException, IOException {
-    SimpleGeneratorHostKeyProvider p;
-
-    if (args.length != 1) {
-      System.err.println("Error: requires path to the SSH host key");
-      return;
-    } else {
-      File file = new File(args[0]);
-      if (!file.exists() || !file.isFile() || !file.canRead()) {
-        System.err.println("Error: ssh key should exist and be readable");
-        return;
-      }
-    }
-
-    p = new SimpleGeneratorHostKeyProvider();
-    // Gerrit's SSH "simple" keys are always RSA.
-    p.setPath(args[0]);
-    p.setAlgorithm("RSA");
-    Iterable<KeyPair> keys = p.loadKeys(); // forces the key to generate.
-    for (KeyPair k : keys) {
-      System.out.println("Public Key (" + k.getPublic().getAlgorithm() + "):");
-      // From Gerrit's SshDaemon class; use JSch to get the public
-      // key/type
-      final Buffer buf = new Buffer();
-      buf.putRawPublicKey(k.getPublic());
-      final byte[] keyBin = buf.getCompactData();
-      HostKey pub = new HostKey("localhost", keyBin);
-      System.out.println(pub.getType() + " " + pub.getKey());
-      System.out.println("Private Key:");
-      // Use Bouncy Castle to write the private key back in PEM format
-      // (PKCS#1)
-      // http://stackoverflow.com/questions/25129822/export-rsa-public-key-to-pem-string-using-java
-      StringWriter privout = new StringWriter();
-      JcaPEMWriter privWriter = new JcaPEMWriter(privout);
-      privWriter.writeObject(k.getPrivate());
-      privWriter.close();
-      System.out.println(privout);
-    }
-  }
-}
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..e26fc00 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)
   }
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..5d5f5d5 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,11 @@
   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)
+    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/Main.java b/java/Main.java
index 11d8234..09c8c76 100644
--- a/java/Main.java
+++ b/java/Main.java
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+@SuppressWarnings("DefaultPackage")
 public final class Main {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
   private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 3934a6a..903b709 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -26,6 +26,7 @@
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.TOPIC_CLOSURE;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -38,12 +39,15 @@
 
 import com.github.rholder.retry.BlockStrategy;
 import com.google.common.base.Strings;
+import com.google.common.base.Ticker;
 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.collect.Lists;
 import com.google.common.jimfs.Jimfs;
 import com.google.common.primitives.Chars;
+import com.google.common.testing.FakeTicker;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
@@ -78,6 +82,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -95,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;
@@ -123,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;
@@ -163,7 +162,6 @@
 import java.nio.file.FileSystems;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.security.KeyPair;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -290,6 +288,7 @@
   @Inject protected ChangeNotes.Factory notesFactory;
   @Inject protected BatchAbandon batchAbandon;
   @Inject protected TestSshKeys sshKeys;
+  @Inject protected TestTicker testTicker;
 
   protected EventRecorder eventRecorder;
   protected GerritServer server;
@@ -310,14 +309,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;
@@ -579,8 +575,7 @@
         && SshMode.useSsh()
         && (adminSshSession == null || userSshSession == null)) {
       // Create Ssh sessions
-      KeyPair adminKeyPair = sshKeys.getKeyPair(admin);
-      SshSessionFactory.initSsh(adminKeyPair);
+      SshSessionFactory.initSsh();
       Context ctx = newRequestContext(user);
       atrScope.set(ctx);
       userSshSession = ctx.getSession();
@@ -655,7 +650,10 @@
   }
 
   protected Project.NameKey createProjectOverAPI(
-      String nameSuffix, Project.NameKey parent, boolean createEmptyCommit, SubmitType submitType)
+      String nameSuffix,
+      @Nullable Project.NameKey parent,
+      boolean createEmptyCommit,
+      @Nullable SubmitType submitType)
       throws RestApiException {
     ProjectInput in = new ProjectInput();
     in.name = name(nameSuffix);
@@ -702,6 +700,8 @@
     }
     SystemReader.setInstance(oldSystemReader);
     oldSystemReader = null;
+    // Set useDefaultTicker in afterTest, so the next beforeTest will use the default ticker
+    testTicker.useDefaultTicker();
   }
 
   protected void closeSsh() {
@@ -1063,87 +1063,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();
   }
@@ -1194,9 +1113,37 @@
   }
 
   protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
-    List<ChangeInfo> actual = gApi.changes().id(chId).submittedTogether();
+    assertSubmittedTogether(chId, ImmutableSet.of(), expected);
+  }
+
+  protected void assertSubmittedTogetherWithTopicClosure(String chId, String... expected)
+      throws Exception {
+    assertSubmittedTogether(chId, ImmutableSet.of(TOPIC_CLOSURE), expected);
+  }
+
+  protected void assertSubmittedTogether(
+      String chId,
+      ImmutableSet<SubmittedTogetherOption> submittedTogetherOptions,
+      String... expected)
+      throws Exception {
+    // This does not include NON_VISIBILE_CHANGES
+    List<ChangeInfo> actual =
+        submittedTogetherOptions.isEmpty()
+            ? gApi.changes().id(chId).submittedTogether()
+            : gApi.changes()
+                .id(chId)
+                .submittedTogether(EnumSet.copyOf(submittedTogetherOptions))
+                .changes;
+
+    EnumSet<SubmittedTogetherOption> enumSetIncludingNonVisibleChanges =
+        submittedTogetherOptions.isEmpty()
+            ? EnumSet.of(NON_VISIBLE_CHANGES)
+            : EnumSet.copyOf(submittedTogetherOptions);
+    enumSetIncludingNonVisibleChanges.add(NON_VISIBLE_CHANGES);
+
+    // This includes NON_VISIBLE_CHANGES for comparison.
     SubmittedTogetherInfo info =
-        gApi.changes().id(chId).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+        gApi.changes().id(chId).submittedTogether(enumSetIncludingNonVisibleChanges);
 
     assertThat(info.nonVisibleChanges).isEqualTo(0);
     assertThat(Iterables.transform(actual, i1 -> i1.changeId))
@@ -1250,10 +1197,10 @@
     assertThat(replyTo.getString()).contains(email);
   }
 
-  protected Map<BranchNameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
-    try (BinaryResult result = gApi.changes().id(changeId).current().submitPreview()) {
-      return fetchFromBundles(result);
-    }
+  protected void assertMailNotReplyTo(Message message, String email) throws Exception {
+    assertThat(message.headers()).containsKey("Reply-To");
+    StringEmailHeader replyTo = (StringEmailHeader) message.headers().get("Reply-To");
+    assertThat(replyTo.getString()).doesNotContain(email);
   }
 
   /**
@@ -1608,6 +1555,13 @@
     }
   }
 
+  protected void clearSubmitRequirements(Project.NameKey project) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().clearSubmitRequirements();
+      u.save();
+    }
+  }
+
   protected void configLabel(String label, LabelFunction func) throws Exception {
     configLabel(label, func, ImmutableList.of());
   }
@@ -1763,4 +1717,32 @@
           moduleClass.getName());
     }
   }
+
+  /** {@link Ticker} implementation for mocking without restarting GerritServer */
+  public static class TestTicker extends Ticker {
+    Ticker actualTicker;
+
+    public TestTicker() {
+      useDefaultTicker();
+    }
+
+    /** Switches to system ticker */
+    public Ticker useDefaultTicker() {
+      this.actualTicker = Ticker.systemTicker();
+      return actualTicker;
+    }
+
+    /** Switches to {@link FakeTicker} */
+    public FakeTicker useFakeTicker() {
+      if (!(this.actualTicker instanceof FakeTicker)) {
+        this.actualTicker = new FakeTicker();
+      }
+      return (FakeTicker) actualTicker;
+    }
+
+    @Override
+    public long read() {
+      return actualTicker.read();
+    }
+  }
 }
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/AcceptanceTestRequestScope.java b/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
index 50536d8..c4bf20c 100644
--- a/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
+++ b/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
@@ -162,7 +162,7 @@
       new Scope() {
         @Override
         public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<T>() {
+          return new Provider<>() {
             @Override
             public T get() {
               return requireContext().get(key, creator);
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 12c6837..2cf279f 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -39,11 +39,9 @@
     "//lib:gson",
     "//lib:guava-retrying",
     "//lib:jgit",
-    "//lib:jgit-ssh-jsch",
     "//lib:jgit-ssh-apache",
-    "//lib:jsch",
     "//lib/commons:compress",
-    "//lib/commons:lang",
+    "//lib/commons:lang3",
     "//lib/flogger:api",
     "//lib/guice",
     "//lib/guice:guice-assistedinject",
@@ -125,6 +123,7 @@
     "//lib/truth",
     "//lib/truth:truth-java8-extension",
     "//lib/greenmail",
+    "//lib:guava-testlib",
 ] + TEST_DEPS
 
 java_library(
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 3844788..0acf3bc 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.config.DownloadScheme;
@@ -22,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;
@@ -49,6 +51,8 @@
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.git.validators.RefOperationValidationListener;
 import com.google.gerrit.server.logging.PerformanceLogger;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeHasOperandFactory;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeIsOperandFactory;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
@@ -71,6 +75,7 @@
   private final DynamicSet<PerformanceLogger> performanceLoggers;
   private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
   private final DynamicSet<SubmitRule> submitRules;
+  private final DynamicSet<SubmitRequirement> submitRequirements;
   private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
   private final DynamicSet<ChangeETagComputation> changeETagComputations;
   private final DynamicSet<ActionVisitor> actionVisitors;
@@ -78,6 +83,7 @@
   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;
@@ -98,6 +104,9 @@
   private final DynamicSet<ReviewerAddedListener> reviewerAddedListeners;
   private final DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners;
 
+  private final DynamicMap<ChangeHasOperandFactory> hasOperands;
+  private final DynamicMap<ChangeIsOperandFactory> isOperands;
+
   @Inject
   ExtensionRegistry(
       DynamicSet<AccountIndexedListener> accountIndexedListeners,
@@ -110,6 +119,7 @@
       DynamicSet<PerformanceLogger> performanceLoggers,
       DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
       DynamicSet<SubmitRule> submitRules,
+      DynamicSet<SubmitRequirement> submitRequirements,
       DynamicSet<ChangeMessageModifier> changeMessageModifiers,
       DynamicSet<ChangeETagComputation> changeETagComputations,
       DynamicSet<ActionVisitor> actionVisitors,
@@ -117,6 +127,7 @@
       DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
       DynamicSet<CommentAddedListener> commentAddedListeners,
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
+      DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
       DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks,
@@ -134,7 +145,9 @@
       DynamicSet<PluginPushOption> pluginPushOption,
       DynamicSet<OnPostReview> onPostReviews,
       DynamicSet<ReviewerAddedListener> reviewerAddedListeners,
-      DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners) {
+      DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners,
+      DynamicMap<ChangeHasOperandFactory> hasOperands,
+      DynamicMap<ChangeIsOperandFactory> isOperands) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
@@ -145,6 +158,7 @@
     this.performanceLoggers = performanceLoggers;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
     this.submitRules = submitRules;
+    this.submitRequirements = submitRequirements;
     this.changeMessageModifiers = changeMessageModifiers;
     this.changeETagComputations = changeETagComputations;
     this.actionVisitors = actionVisitors;
@@ -152,6 +166,7 @@
     this.refOperationValidationListeners = refOperationValidationListeners;
     this.commentAddedListeners = commentAddedListeners;
     this.refUpdatedListeners = refUpdatedListeners;
+    this.batchRefUpdateListeners = batchRefUpdateListeners;
     this.fileHistoryWebLinks = fileHistoryWebLinks;
     this.patchSetWebLinks = patchSetWebLinks;
     this.editWebLinks = editWebLinks;
@@ -170,6 +185,8 @@
     this.onPostReviews = onPostReviews;
     this.reviewerAddedListeners = reviewerAddedListeners;
     this.reviewerDeletedListeners = reviewerDeletedListeners;
+    this.hasOperands = hasOperands;
+    this.isOperands = isOperands;
   }
 
   public Registration newRegistration() {
@@ -220,6 +237,18 @@
       return add(submitRules, submitRule);
     }
 
+    public Registration add(SubmitRequirement submitRequirement) {
+      return add(submitRequirements, submitRequirement);
+    }
+
+    public Registration add(ChangeHasOperandFactory hasOperand, String exportName) {
+      return add(hasOperands, hasOperand, exportName);
+    }
+
+    public Registration add(ChangeIsOperandFactory isOperand, String exportName) {
+      return add(isOperands, isOperand, exportName);
+    }
+
     public Registration add(ChangeMessageModifier changeMessageModifier) {
       return add(changeMessageModifiers, changeMessageModifier);
     }
@@ -252,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);
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index fb7189a..009bebd 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -22,7 +22,9 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.base.Ticker;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest.TestTicker;
 import com.google.gerrit.acceptance.FakeGroupAuditService.FakeGroupAuditServiceModule;
 import com.google.gerrit.acceptance.ReindexGroupsAtStartup.ReindexGroupsAtStartupModule;
 import com.google.gerrit.acceptance.ReindexProjectsAtStartup.ReindexProjectsAtStartupModule;
@@ -59,6 +61,7 @@
 import com.google.gerrit.server.experiments.ConfigExperimentFeatures.ConfigExperimentFeaturesModule;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits.AsyncReceiveCommitsModule;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.index.AbstractIndexModule;
 import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.ssh.NoSshModule;
@@ -76,6 +79,7 @@
 import com.google.inject.Module;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
+import com.google.inject.multibindings.OptionalBinder;
 import java.lang.annotation.Annotation;
 import java.lang.annotation.Retention;
 import java.lang.reflect.Field;
@@ -429,6 +433,23 @@
                 .to(GitObjectVisibilityChecker.class);
           }
         });
+    daemon.addAdditionalSysModuleForTesting(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            super.configure();
+            // GerritServer isn't restarted between tests. TestTicker allows to replace actual
+            // Ticker in tests without restarting server and transparently for other code.
+            // Alternative option with Provider<Ticker> is less convinient, because it affects how
+            // gerrit code should be written - i.e. Ticker must not be stored in fields and must
+            // always be obtained from the provider.
+            TestTicker testTicker = new TestTicker();
+            OptionalBinder.newOptionalBinder(binder(), Ticker.class)
+                .setBinding()
+                .toInstance(testTicker);
+            bind(TestTicker.class).toInstance(testTicker);
+          }
+        });
     daemon.setEnableHttpd(desc.httpd());
     // Assure that SSHD is enabled if HTTPD is not required, otherwise the Gerrit server would not
     // even start.
@@ -438,7 +459,11 @@
 
     if (desc.memory()) {
       checkArgument(additionalArgs.length == 0, "cannot pass args to in-memory server");
-      return startInMemory(desc, site, baseConfig, daemon, inMemoryRepoManager);
+      AbstractIndexModule testIndexModule =
+          (testSysModule instanceof AbstractIndexModule)
+              ? (AbstractIndexModule) testSysModule
+              : null;
+      return startInMemory(desc, site, baseConfig, daemon, inMemoryRepoManager, testIndexModule);
     }
     return startOnDisk(desc, site, daemon, serverStarted, additionalArgs);
   }
@@ -448,7 +473,8 @@
       Path site,
       Config baseConfig,
       Daemon daemon,
-      @Nullable InMemoryRepositoryManager inMemoryRepoManager)
+      @Nullable InMemoryRepositoryManager inMemoryRepoManager,
+      @Nullable AbstractIndexModule testIndexModule)
       throws Exception {
     Config cfg = desc.buildConfig(baseConfig);
     mergeTestConfig(cfg);
@@ -464,24 +490,11 @@
         "accountPatchReviewDb", null, "url", JdbcAccountPatchReviewStore.TEST_IN_MEMORY_URL);
 
     String configuredIndexBackend = cfg.getString("index", null, "type");
-    IndexType indexType;
-    if (configuredIndexBackend != null) {
-      // Explicitly configured index backend from gerrit.config trumps any other ways to configure
-      // index backends so that Reindex tests can be explicit about the backend they want to test
-      // against.
-      indexType = new IndexType(configuredIndexBackend);
-    } else {
-      // Allow configuring the index backend based on sys/env variables so that integration tests
-      // can be run against different index backends.
-      indexType = IndexType.fromEnvironment().orElse(new IndexType("fake"));
-    }
-    if (indexType.isLucene()) {
-      daemon.setIndexModule(
-          LuceneIndexModule.singleVersionAllLatest(
-              0, ReplicaUtil.isReplica(baseConfig), AutoFlush.ENABLED));
-    } else {
-      daemon.setIndexModule(FakeIndexModule.latestVersion(false));
-    }
+    IndexType indexType =
+        (configuredIndexBackend != null)
+            ? new IndexType(configuredIndexBackend)
+            : IndexType.fromEnvironment().orElse(new IndexType("fake"));
+    daemon.setIndexModule(createIndexModule(indexType, baseConfig, testIndexModule));
 
     daemon.setEnableHttpd(desc.httpd());
     daemon.setInMemory(true);
@@ -501,6 +514,17 @@
     return new GerritServer(desc, null, createTestInjector(daemon), daemon, null);
   }
 
+  private static AbstractIndexModule createIndexModule(
+      IndexType indexType, Config baseConfig, @Nullable AbstractIndexModule testIndexModule) {
+    if (testIndexModule != null) {
+      return testIndexModule;
+    }
+    return indexType.isLucene()
+        ? LuceneIndexModule.singleVersionAllLatest(
+            0, ReplicaUtil.isReplica(baseConfig), AutoFlush.ENABLED)
+        : FakeIndexModule.latestVersion(false);
+  }
+
   private static GerritServer startOnDisk(
       Description desc,
       Path site,
diff --git a/java/com/google/gerrit/acceptance/GitClientVersion.java b/java/com/google/gerrit/acceptance/GitClientVersion.java
index 4c9a32d..2415781 100644
--- a/java/com/google/gerrit/acceptance/GitClientVersion.java
+++ b/java/com/google/gerrit/acceptance/GitClientVersion.java
@@ -16,6 +16,8 @@
 
 import static java.util.stream.Collectors.joining;
 
+import com.google.common.base.Splitter;
+import java.util.List;
 import java.util.stream.IntStream;
 
 /** Class to parse and represent version of git-core client */
@@ -38,11 +40,11 @@
    */
   public GitClientVersion(String version) {
     // "git version x.y.z", at Google "git version x.y.z.gXXXXXXXXXX-goog"
-    String parts[] = version.split(" ")[2].split("\\.");
-    int numParts = Math.min(parts.length, 3); // ignore Google-specific part of the version
+    List<String> parts = Splitter.on(".").splitToList(Splitter.on(" ").splitToList(version).get(2));
+    int numParts = Math.min(parts.size(), 3); // ignore Google-specific part of the version
     v = new int[numParts];
     for (int i = 0; i < numParts; i++) {
-      v[i] = Integer.valueOf(parts[i]);
+      v[i] = Integer.valueOf(parts.get(i));
     }
   }
 
diff --git a/java/com/google/gerrit/acceptance/InProcessProtocol.java b/java/com/google/gerrit/acceptance/InProcessProtocol.java
index 83c63f9..17ce595 100644
--- a/java/com/google/gerrit/acceptance/InProcessProtocol.java
+++ b/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -100,7 +100,7 @@
       new Scope() {
         @Override
         public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<T>() {
+          return new Provider<>() {
             @Override
             public T get() {
               Context ctx = current.get();
@@ -235,9 +235,9 @@
 
       PermissionBackend.ForProject perm = permissionBackend.currentUser().project(req.project);
       try {
-        perm.check(ProjectPermission.RUN_UPLOAD_PACK);
-      } catch (AuthException e) {
-        throw new ServiceNotAuthorizedException(e.getMessage(), e);
+        if (!perm.test(ProjectPermission.RUN_UPLOAD_PACK)) {
+          throw new ServiceNotAuthorizedException("upload pack not permitted");
+        }
       } catch (PermissionBackendException e) {
         throw new RuntimeException(e);
       }
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/SshSessionJsch.java b/java/com/google/gerrit/acceptance/SshSessionJsch.java
deleted file mode 100644
index fa4d1d1..0000000
--- a/java/com/google/gerrit/acceptance/SshSessionJsch.java
+++ /dev/null
@@ -1,174 +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.acceptance;
-
-import static java.nio.charset.StandardCharsets.US_ASCII;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.gerrit.acceptance.testsuite.account.TestAccount;
-import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
-import com.jcraft.jsch.ChannelExec;
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.JSchException;
-import com.jcraft.jsch.Session;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.io.StringWriter;
-import java.net.InetSocketAddress;
-import java.nio.charset.StandardCharsets;
-import java.security.GeneralSecurityException;
-import java.security.KeyPair;
-import java.security.KeyPairGenerator;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
-import java.util.Properties;
-import java.util.Scanner;
-import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
-import org.bouncycastle.openssl.jcajce.JcaPKCS8Generator;
-import org.bouncycastle.util.io.pem.PemObject;
-import org.eclipse.jgit.transport.SshSessionFactory;
-import org.eclipse.jgit.transport.ssh.jsch.JschConfigSessionFactory;
-import org.eclipse.jgit.transport.ssh.jsch.OpenSshConfig.Host;
-import org.eclipse.jgit.util.FS;
-
-public class SshSessionJsch extends SshSession {
-
-  private Session session;
-
-  public static void initClient(KeyPair keyPair) {
-    Properties config = new Properties();
-    config.put("StrictHostKeyChecking", "no");
-    JSch.setConfig(config);
-
-    // register a JschConfigSessionFactory that adds the private key as identity
-    // to the JSch instance of JGit so that SSH communication via JGit can
-    // succeed
-    SshSessionFactory.setInstance(
-        new JschConfigSessionFactory() {
-          @Override
-          protected void configure(Host hc, Session session) {
-            try {
-              JSch jsch = getJSch(hc, FS.DETECTED);
-              jsch.addIdentity(
-                  "KeyPair", privateKey(keyPair), TestSshKeys.publicKeyBlob(keyPair), null);
-            } catch (JSchException | GeneralSecurityException | IOException e) {
-              throw new RuntimeException(e);
-            }
-          }
-        });
-  }
-
-  public static KeyPairGenerator initKeyPairGenerator() throws NoSuchAlgorithmException {
-    KeyPairGenerator gen;
-    gen = KeyPairGenerator.getInstance("RSA");
-    gen.initialize(512, new SecureRandom());
-    return gen;
-  }
-
-  public SshSessionJsch(TestSshKeys sshKeys, InetSocketAddress addr, TestAccount account) {
-    super(sshKeys, addr, account);
-  }
-
-  @Override
-  public void open() throws Exception {
-    getJschSession();
-  }
-
-  @Override
-  public void close() {
-    if (session != null) {
-      session.disconnect();
-      session = null;
-    }
-  }
-
-  @SuppressWarnings("resource")
-  @Override
-  public String exec(String command) throws Exception {
-    ChannelExec channel = (ChannelExec) getJschSession().openChannel("exec");
-    try {
-      channel.setCommand(command);
-      InputStream in = channel.getInputStream();
-      InputStream err = channel.getErrStream();
-      channel.connect();
-
-      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
-      error = s.hasNext() ? s.next() : null;
-
-      s = new Scanner(in, UTF_8.name()).useDelimiter("\\A");
-      return s.hasNext() ? s.next() : "";
-    } finally {
-      channel.disconnect();
-    }
-  }
-
-  @SuppressWarnings("resource")
-  @Override
-  public int execAndReturnStatus(String command) throws Exception {
-    ChannelExec channel = (ChannelExec) getJschSession().openChannel("exec");
-    try {
-      channel.setCommand(command);
-      InputStream err = channel.getErrStream();
-      channel.connect();
-
-      Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
-      error = s.hasNext() ? s.next() : null;
-      return channel.getExitStatus();
-    } finally {
-      channel.disconnect();
-    }
-  }
-
-  @Override
-  public Reader execAndReturnReader(String command) throws Exception {
-    ChannelExec channel = (ChannelExec) getJschSession().openChannel("exec");
-    channel.setCommand(command);
-    channel.connect();
-
-    return new InputStreamReader(channel.getInputStream(), StandardCharsets.UTF_8) {
-      @Override
-      public void close() throws IOException {
-        super.close();
-        channel.disconnect();
-      }
-    };
-  }
-
-  private Session getJschSession() throws Exception {
-    if (session == null) {
-      KeyPair keyPair = sshKeys.getKeyPair(account);
-      JSch jsch = new JSch();
-      jsch.addIdentity("KeyPair", privateKey(keyPair), TestSshKeys.publicKeyBlob(keyPair), null);
-      String username = getUsername();
-      session = jsch.getSession(username, addr.getAddress().getHostAddress(), addr.getPort());
-      session.setConfig("StrictHostKeyChecking", "no");
-      session.connect();
-    }
-    return session;
-  }
-
-  private static byte[] privateKey(KeyPair keyPair) throws IOException {
-    // unencrypted form of PKCS#8 file
-    JcaPKCS8Generator gen1 = new JcaPKCS8Generator(keyPair.getPrivate(), null);
-    PemObject obj1 = gen1.generate();
-    StringWriter sw1 = new StringWriter();
-    try (JcaPEMWriter pw = new JcaPEMWriter(sw1)) {
-      pw.writeObject(obj1);
-    }
-    return sw1.toString().getBytes(US_ASCII.name());
-  }
-}
diff --git a/java/com/google/gerrit/acceptance/SshSessionMina.java b/java/com/google/gerrit/acceptance/SshSessionMina.java
index 3b0ba3b..89096e4 100644
--- a/java/com/google/gerrit/acceptance/SshSessionMina.java
+++ b/java/com/google/gerrit/acceptance/SshSessionMina.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.nio.file.Files.createTempDirectory;
 
 import com.google.common.io.CharSink;
 import com.google.common.io.Files;
@@ -140,7 +141,7 @@
                   + addr.getPort());
 
       // TODO(davido): Switch to memory only key resolving mode.
-      File userhome = Files.createTempDir();
+      File userhome = createTempDirectory("home-").toFile();
 
       FS fs = FS.DETECTED.setUserHome(userhome);
       File sshDir = new File(userhome, ".ssh");
@@ -168,6 +169,7 @@
                 MoreFiles.deleteRecursively(userhome.toPath(), ALLOW_INSECURE);
               } catch (IOException e) {
                 e.printStackTrace();
+                throw new RuntimeException("Failed to cleanup userhome", e);
               }
             });
       }
diff --git a/java/com/google/gerrit/acceptance/TestMetricMaker.java b/java/com/google/gerrit/acceptance/TestMetricMaker.java
index 2620f99..881f389 100644
--- a/java/com/google/gerrit/acceptance/TestMetricMaker.java
+++ b/java/com/google/gerrit/acceptance/TestMetricMaker.java
@@ -14,21 +14,25 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.Timer1;
 import com.google.inject.Singleton;
-import java.util.HashMap;
-import java.util.Map;
-import org.apache.commons.lang.mutable.MutableLong;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.lang3.mutable.MutableLong;
 
 /**
  * {@link com.google.gerrit.metrics.MetricMaker} to be bound in tests.
  *
- * <p>Records how often {@link Counter0} metrics are invoked. Metrics of other types are not
- * recorded.
+ * <p>Records how often {@link Counter0} and {@link Timer1} metrics are invoked. Metrics for other
+ * types are not recorded.
  *
- * <p>Allows test to check how much a {@link Counter0} metrics is increased by an operation.
+ * <p>Allows test to check how much a {@link Counter0} and {@link Timer1} metric is increased by an
+ * operation.
  *
  * <p>Example:
  *
@@ -49,26 +53,50 @@
  */
 @Singleton
 public class TestMetricMaker extends DisabledMetricMaker {
-  private final Map<String, MutableLong> counts = new HashMap<>();
+  private final ConcurrentHashMap<String, MutableLong> counts = new ConcurrentHashMap<>();
+  private final ConcurrentHashMap<String, MutableLong> timers = new ConcurrentHashMap<>();
 
   public long getCount(String counter0Name) {
-    return get(counter0Name).longValue();
+    return getCounterValue(counter0Name).longValue();
+  }
+
+  public long getTimer(String timerName) {
+    return getTimerValue(timerName).longValue();
   }
 
   public void reset() {
     counts.clear();
+    timers.clear();
   }
 
-  private MutableLong get(String counter0Name) {
+  private MutableLong getCounterValue(String counter0Name) {
     return counts.computeIfAbsent(counter0Name, name -> new MutableLong(0));
   }
 
+  private MutableLong getTimerValue(String timerName) {
+    return counts.computeIfAbsent(timerName, name -> new MutableLong(0));
+  }
+
   @Override
   public Counter0 newCounter(String name, Description desc) {
     return new Counter0() {
       @Override
       public void incrementBy(long value) {
-        get(name).add(value);
+        getCounterValue(name).add(value);
+      }
+
+      @Override
+      public void remove() {}
+    };
+  }
+
+  @Override
+  @UsedAt(UsedAt.Project.PLUGIN_PULL_REPLICATION)
+  public <F1> Timer1<F1> newTimer(String name, Description desc, Field<F1> field1) {
+    return new Timer1<>(name, field1) {
+      @Override
+      protected void doRecord(F1 field1, long value, TimeUnit unit) {
+        getTimerValue(name).add(value);
       }
 
       @Override
diff --git a/java/com/google/gerrit/acceptance/TestOnStoreSubmitRequirementResultModifier.java b/java/com/google/gerrit/acceptance/TestOnStoreSubmitRequirementResultModifier.java
new file mode 100644
index 0000000..0138ffa
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/TestOnStoreSubmitRequirementResultModifier.java
@@ -0,0 +1,86 @@
+// 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;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.ChangeContext;
+import java.util.Optional;
+
+/** Implementation of {@link OnStoreSubmitRequirementResultModifier} that is used in tests. */
+public class TestOnStoreSubmitRequirementResultModifier
+    implements OnStoreSubmitRequirementResultModifier {
+
+  private ModificationStrategy modificationStrategy = ModificationStrategy.KEEP;
+
+  private boolean hide = false;
+
+  /**
+   * The strategy, used by this modifier to transform {@link SubmitRequirementResult} on {@link
+   * OnStoreSubmitRequirementResultModifier#modifyResultOnStore} invocations.
+   */
+  public enum ModificationStrategy {
+    KEEP,
+    FAIL,
+    PASS,
+    OVERRIDE
+  }
+
+  public void setModificationStrategy(ModificationStrategy modificationStrategy) {
+    this.modificationStrategy = modificationStrategy;
+  }
+
+  public void hide(boolean hide) {
+    this.hide = hide;
+  }
+
+  @Override
+  public SubmitRequirementResult modifyResultOnStore(
+      SubmitRequirement submitRequirement,
+      SubmitRequirementResult result,
+      ChangeData cd,
+      ChangeContext ctx) {
+    if (modificationStrategy.equals(ModificationStrategy.KEEP)) {
+      return result;
+    }
+    SubmitRequirementResult.Builder srResultBuilder = result.toBuilder().hidden(Optional.of(hide));
+    if (modificationStrategy.equals(ModificationStrategy.OVERRIDE)) {
+      return srResultBuilder
+          .overrideExpressionResult(
+              Optional.of(
+                  SubmitRequirementExpressionResult.create(
+                      submitRequirement.submittabilityExpression(),
+                      SubmitRequirementExpressionResult.Status.PASS,
+                      ImmutableList.of(),
+                      ImmutableList.of(
+                          submitRequirement.submittabilityExpression().expressionString()))))
+          .build();
+    }
+    return srResultBuilder
+        .submittabilityExpressionResult(
+            SubmitRequirementExpressionResult.create(
+                submitRequirement.submittabilityExpression(),
+                modificationStrategy.equals(ModificationStrategy.FAIL)
+                    ? SubmitRequirementExpressionResult.Status.FAIL
+                    : SubmitRequirementExpressionResult.Status.PASS,
+                ImmutableList.of(),
+                ImmutableList.of(submitRequirement.submittabilityExpression().expressionString())))
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/rest/PluginResource.java b/java/com/google/gerrit/acceptance/rest/PluginResource.java
index 745d4fa..56710b9 100644
--- a/java/com/google/gerrit/acceptance/rest/PluginResource.java
+++ b/java/com/google/gerrit/acceptance/rest/PluginResource.java
@@ -20,6 +20,5 @@
 
 public class PluginResource extends ConfigResource {
 
-  static final TypeLiteral<RestView<PluginResource>> PLUGIN_KIND =
-      new TypeLiteral<RestView<PluginResource>>() {};
+  static final TypeLiteral<RestView<PluginResource>> PLUGIN_KIND = new TypeLiteral<>() {};
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index 3b15b57..d05269f 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -47,9 +47,8 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -138,10 +137,9 @@
     try (Repository repository = repositoryManager.openRepository(project);
         ObjectInserter objectInserter = repository.newObjectInserter();
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
       IdentifiedUser changeOwner = getChangeOwner(changeCreation);
-      PersonIdent authorAndCommitter =
-          changeOwner.newCommitterIdent(now, serverIdent.getTimeZone());
+      PersonIdent authorAndCommitter = changeOwner.newCommitterIdent(now, serverIdent.getZoneId());
       ObjectId commitId =
           createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
 
@@ -431,7 +429,7 @@
       try (Repository repository = repositoryManager.openRepository(project);
           ObjectInserter objectInserter = repository.newObjectInserter();
           RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-        Timestamp now = TimeUtil.nowTs();
+        Instant now = TimeUtil.now();
         ObjectId newPatchsetCommit =
             createPatchsetCommit(
                 repository, revWalk, objectInserter, changeNotes, patchsetCreation, now);
@@ -457,7 +455,7 @@
         ObjectInserter objectInserter,
         ChangeNotes changeNotes,
         TestPatchsetCreation patchsetCreation,
-        Timestamp now)
+        Instant now)
         throws IOException, BadRequestException {
       ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
       RevCommit oldPatchsetCommit = repository.parseCommit(oldPatchsetCommitId);
@@ -494,10 +492,10 @@
       return Optional.ofNullable(oldPatchsetCommit.getAuthorIdent()).orElse(serverIdent);
     }
 
-    private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Timestamp now) {
+    private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Instant now) {
       PersonIdent oldPatchsetCommitter =
           Optional.ofNullable(oldPatchsetCommit.getCommitterIdent()).orElse(serverIdent);
-      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhen())) {
+      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhenAsInstant())) {
         /* We need to ensure that the resulting commit SHA-1 is different from the old patchset.
          * In real situations, this automatically happens as two patchsets won't have exactly the
          * same commit timestamp even when the tree and commit message are the same. In tests,
@@ -505,13 +503,13 @@
          * We could of course require that tests must use TestTimeUtil#setClockStep but
          * that would be an unnecessary nuisance for test writers. Hence, go with a simple solution
          * here and simply add a second. */
-        now = Timestamp.from(now.toInstant().plusSeconds(1));
+        now = now.plusSeconds(1);
       }
       return new PersonIdent(oldPatchsetCommitter, now);
     }
 
-    private long asSeconds(Date date) {
-      return date.getTime() / 1000;
+    private long asSeconds(Instant date) {
+      return date.getEpochSecond();
     }
 
     private ImmutableList<ObjectId> getParents(
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/PerPatchsetOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
index eda6c7e..9b393ef 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -38,7 +38,7 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -106,7 +106,7 @@
     try (Repository repository = repositoryManager.openRepository(project);
         ObjectInserter objectInserter = repository.newObjectInserter();
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
 
       IdentifiedUser author = getAuthor(commentCreation);
       CommentAdditionOp commentAdditionOp = new CommentAdditionOp(commentCreation);
@@ -165,8 +165,7 @@
       short side = commentCreation.side().orElse(CommentSide.PATCHSET_COMMIT).getNumericSide();
       Boolean unresolved = commentCreation.unresolved().orElse(null);
       String parentUuid = commentCreation.parentUuid().orElse(null);
-      Timestamp createdOn =
-          commentCreation.createdOn().map(Timestamp::from).orElse(context.getWhen());
+      Instant createdOn = commentCreation.createdOn().orElse(context.getWhen());
       HumanComment newComment =
           commentsUtil.newHumanComment(
               context.getNotes(),
@@ -202,7 +201,7 @@
     try (Repository repository = repositoryManager.openRepository(project);
         ObjectInserter objectInserter = repository.newObjectInserter();
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
 
       IdentifiedUser author = getAuthor(robotCommentCreation);
       RobotCommentAdditionOp robotCommentAdditionOp =
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
index c885353..0b21e2c 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 @AutoValue
@@ -40,7 +40,7 @@
 
   public abstract boolean visibleToAll();
 
-  public abstract Timestamp createdOn();
+  public abstract Instant createdOn();
 
   public abstract ImmutableSet<Account.Id> members();
 
@@ -67,7 +67,7 @@
 
     public abstract Builder visibleToAll(boolean visibleToAll);
 
-    public abstract Builder createdOn(Timestamp createdOn);
+    public abstract Builder createdOn(Instant createdOn);
 
     public abstract Builder members(ImmutableSet<Account.Id> members);
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/BUILD b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
index f9e2fb5..850a133 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/BUILD
+++ b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
@@ -17,7 +17,7 @@
         "//lib:jgit-junit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index 18da4b3..deeb843 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -43,7 +43,7 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
-import org.apache.commons.lang.RandomStringUtils;
+import org.apache.commons.lang3.RandomStringUtils;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java b/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java
index d5dd28a..3442b6e 100644
--- a/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java
+++ b/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.acceptance.testsuite.request;
 
-import static com.google.gerrit.server.config.SshClientImplementation.getFromEnvironment;
-
 import com.google.gerrit.acceptance.SshSession;
-import com.google.gerrit.acceptance.SshSessionJsch;
 import com.google.gerrit.acceptance.SshSessionMina;
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
@@ -28,25 +25,16 @@
 public class SshSessionFactory {
   public static SshSession createSession(
       TestSshKeys testSshKeys, InetSocketAddress sshAddress, TestAccount testAccount) {
-    return getFromEnvironment().isMina()
-        ? new SshSessionMina(testSshKeys, sshAddress, testAccount)
-        : new SshSessionJsch(testSshKeys, sshAddress, testAccount);
+    return new SshSessionMina(testSshKeys, sshAddress, testAccount);
   }
 
-  public static void initSsh(KeyPair keyPair) {
-    if (getFromEnvironment().isMina()) {
-      SshSessionMina.initClient();
-    } else {
-      SshSessionJsch.initClient(keyPair);
-    }
+  public static void initSsh() {
+    SshSessionMina.initClient();
   }
 
   private SshSessionFactory() {}
 
   public static KeyPair genSshKey() throws GeneralSecurityException {
-    return (getFromEnvironment().isMina()
-            ? SshSessionMina.initKeyPairGenerator()
-            : SshSessionJsch.initKeyPairGenerator())
-        .generateKeyPair();
+    return SshSessionMina.initKeyPairGenerator().generateKeyPair();
   }
 }
diff --git a/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
index 2947efd..c3870f4 100644
--- a/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
@@ -125,7 +125,7 @@
 
     String groupDn = uuid.get().substring(LDAP_UUID.length());
     CurrentUser user = userProvider.get();
-    if (!(user.isIdentifiedUser()) || !membershipsOf(user.asIdentifiedUser()).contains(uuid)) {
+    if (!user.isIdentifiedUser() || !membershipsOf(user.asIdentifiedUser()).contains(uuid)) {
       try {
         if (!existsCache.get(groupDn)) {
           return null;
diff --git a/java/com/google/gerrit/auth/ldap/LdapRealm.java b/java/com/google/gerrit/auth/ldap/LdapRealm.java
index 9a9f309..7699799 100644
--- a/java/com/google/gerrit/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/auth/ldap/LdapRealm.java
@@ -199,7 +199,7 @@
       String configOption, String suppliedValue, boolean disabledByBackend) {
     if (disabledByBackend && !Strings.isNullOrEmpty(suppliedValue)) {
       String msg = String.format("LDAP backend doesn't support: ldap.%s", configOption);
-      logger.atSevere().log(msg);
+      logger.atSevere().log("%s", msg);
       throw new IllegalArgumentException(msg);
     }
   }
diff --git a/java/com/google/gerrit/common/IoUtil.java b/java/com/google/gerrit/common/IoUtil.java
index 37f6c2c..09a8993 100644
--- a/java/com/google/gerrit/common/IoUtil.java
+++ b/java/com/google/gerrit/common/IoUtil.java
@@ -32,16 +32,27 @@
 public final class IoUtil {
   public static void copyWithThread(InputStream src, OutputStream dst) {
     new Thread("IoUtil-Copy") {
+      // We cannot propagate the exception since this code is running in a background thread.
+      // Printing the stacktrace is the best we can do. Hence ignoring the exception after printing
+      // the stacktrace is OK and it's fine to suppress the warning for the CatchAndPrintStackTrace
+      // bug pattern here.
+      @SuppressWarnings("CatchAndPrintStackTrace")
       @Override
       public void run() {
         try {
+          copyIo();
+        } catch (IOException e) {
+          e.printStackTrace();
+        }
+      }
+
+      private void copyIo() throws IOException {
+        try {
           final byte[] buf = new byte[256];
           int n;
           while (0 < (n = src.read(buf))) {
             dst.write(buf, 0, n);
           }
-        } catch (IOException e) {
-          e.printStackTrace();
         } finally {
           try {
             src.close();
diff --git a/java/com/google/gerrit/common/RuntimeVersion.java b/java/com/google/gerrit/common/RuntimeVersion.java
new file mode 100644
index 0000000..9ab1b96
--- /dev/null
+++ b/java/com/google/gerrit/common/RuntimeVersion.java
@@ -0,0 +1,33 @@
+// 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.common;
+
+/** JDK version string utilities. */
+public final class RuntimeVersion {
+
+  private static final int FEATURE = Runtime.version().feature();
+
+  /** Returns true if the current runtime is JDK 17 or newer. */
+  public static boolean isAtLeast17() {
+    return FEATURE >= 17;
+  }
+
+  /** Returns true if the current runtime is JDK 18 or newer. */
+  public static boolean isAtLeast18() {
+    return FEATURE >= 18;
+  }
+
+  private RuntimeVersion() {}
+}
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 3e103c8..353b70d 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -42,6 +42,7 @@
     PLUGIN_SERVICEUSER,
     PLUGIN_HIGH_AVAILABILITY,
     PLUGIN_MULTI_SITE,
+    PLUGIN_PULL_REPLICATION,
     PLUGIN_WEBSESSION_FLATFILE,
     PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
   }
diff --git a/java/com/google/gerrit/common/Version.java b/java/com/google/gerrit/common/Version.java
index 6197be5..bfca4d0 100644
--- a/java/com/google/gerrit/common/Version.java
+++ b/java/com/google/gerrit/common/Version.java
@@ -54,7 +54,7 @@
         return vs;
       }
     } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage());
+      logger.atSevere().withCause(e).log("%s", e.getMessage());
       return "(unknown version)";
     }
   }
diff --git a/java/com/google/gerrit/common/data/FilenameComparator.java b/java/com/google/gerrit/common/data/FilenameComparator.java
index ebf423c..0b188df 100644
--- a/java/com/google/gerrit/common/data/FilenameComparator.java
+++ b/java/com/google/gerrit/common/data/FilenameComparator.java
@@ -24,7 +24,7 @@
   public static final FilenameComparator INSTANCE = new FilenameComparator();
 
   private static final Set<String> cppHeaderSuffixes =
-      new HashSet<>(Arrays.asList(".h", ".hxx", ".hpp"));
+      new HashSet<>(Arrays.asList(".h", ".hh", ".hxx", ".hpp"));
 
   private FilenameComparator() {}
 
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index cd3b27a..303e79f 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -22,7 +22,7 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 /**
@@ -123,7 +123,7 @@
   public abstract Id id();
 
   /** Date and time the user registered with the review server. */
-  public abstract Timestamp registeredOn();
+  public abstract Instant registeredOn();
 
   /** Full name of the user ("Given-name Surname" style). */
   @Nullable
@@ -157,7 +157,7 @@
    * @param newId unique id, see {@link com.google.gerrit.server.notedb.Sequences#nextAccountId()}.
    * @param registeredOn when the account was registered.
    */
-  public static Account.Builder builder(Account.Id newId, Timestamp registeredOn) {
+  public static Account.Builder builder(Account.Id newId, Instant registeredOn) {
     return new AutoValue_Account.Builder()
         .setInactive(false)
         .setId(newId)
@@ -196,9 +196,9 @@
    * <p>Example output:
    *
    * <ul>
-   *   <li>{@code A U. Thor &lt;author@example.com&gt;}: full populated
+   *   <li>{@code A U. Thor <author@example.com>}: full populated
    *   <li>{@code A U. Thor (12)}: missing email address
-   *   <li>{@code Anonymous Coward &lt;author@example.com&gt;}: missing name
+   *   <li>{@code Anonymous Coward <author@example.com>}: missing name
    *   <li>{@code Anonymous Coward (12)}: missing name and email address
    * </ul>
    */
@@ -230,9 +230,9 @@
 
     abstract Builder setId(Id id);
 
-    public abstract Timestamp registeredOn();
+    public abstract Instant registeredOn();
 
-    abstract Builder setRegisteredOn(Timestamp registeredOn);
+    abstract Builder setRegisteredOn(Instant registeredOn);
 
     @Nullable
     public abstract String fullName();
diff --git a/java/com/google/gerrit/entities/AccountGroupByIdAudit.java b/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
index 17ddf51..0ef51e5 100644
--- a/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
+++ b/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 /** Inclusion of an {@link AccountGroup} in another {@link AccountGroup}. */
@@ -33,13 +33,13 @@
 
     public abstract Builder addedBy(Account.Id addedBy);
 
-    public abstract Builder addedOn(Timestamp addedOn);
+    public abstract Builder addedOn(Instant addedOn);
 
     abstract Builder removedBy(Account.Id removedBy);
 
-    abstract Builder removedOn(Timestamp removedOn);
+    abstract Builder removedOn(Instant removedOn);
 
-    public Builder removed(Account.Id removedBy, Timestamp removedOn) {
+    public Builder removed(Account.Id removedBy, Instant removedOn) {
       return removedBy(removedBy).removedOn(removedOn);
     }
 
@@ -52,11 +52,11 @@
 
   public abstract Account.Id addedBy();
 
-  public abstract Timestamp addedOn();
+  public abstract Instant addedOn();
 
   public abstract Optional<Account.Id> removedBy();
 
-  public abstract Optional<Timestamp> removedOn();
+  public abstract Optional<Instant> removedOn();
 
   public abstract Builder toBuilder();
 
diff --git a/java/com/google/gerrit/entities/AccountGroupMemberAudit.java b/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
index 4d191b8..913956e 100644
--- a/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
+++ b/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 /** Membership of an {@link Account} in an {@link AccountGroup}. */
@@ -35,15 +35,15 @@
 
     abstract Account.Id addedBy();
 
-    public abstract Builder addedOn(Timestamp addedOn);
+    public abstract Builder addedOn(Instant addedOn);
 
-    abstract Timestamp addedOn();
+    abstract Instant addedOn();
 
     abstract Builder removedBy(Account.Id removedBy);
 
-    abstract Builder removedOn(Timestamp removedOn);
+    abstract Builder removedOn(Instant removedOn);
 
-    public Builder removed(Account.Id removedBy, Timestamp removedOn) {
+    public Builder removed(Account.Id removedBy, Instant removedOn) {
       return removedBy(removedBy).removedOn(removedOn);
     }
 
@@ -60,11 +60,11 @@
 
   public abstract Account.Id addedBy();
 
-  public abstract Timestamp addedOn();
+  public abstract Instant addedOn();
 
   public abstract Optional<Account.Id> removedBy();
 
-  public abstract Optional<Timestamp> removedOn();
+  public abstract Optional<Instant> removedOn();
 
   public abstract Builder toBuilder();
 
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index 76e35db..be4a1cf 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -227,7 +227,7 @@
       try {
         parsedProjectLevelConfigsBuilder().put(configFileName, ImmutableConfig.parse(config));
       } catch (ConfigInvalidException e) {
-        logger.atInfo().withCause(e).log("Config for " + configFileName + " not parsable");
+        logger.atInfo().withCause(e).log("Config for %s not parsable", configFileName);
       }
       return this;
     }
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index d1826bc..66e1a96 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.entities;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
 
 import com.google.auto.value.AutoValue;
@@ -24,7 +23,7 @@
 import com.google.gson.Gson;
 import com.google.gson.TypeAdapter;
 import com.google.gson.annotations.SerializedName;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Optional;
 
@@ -109,18 +108,6 @@
     /**
      * Parse a Change.Id out of a string representation.
      *
-     * @deprecated use {@link #tryParse(String)} instead.
-     */
-    @Deprecated
-    public static Id parse(String str) {
-      Integer id = Ints.tryParse(str);
-      checkArgument(id != null, "invalid change ID: %s", str);
-      return Change.id(id);
-    }
-
-    /**
-     * Parse a Change.Id out of a string representation.
-     *
      * @param str the string to parse
      * @return Optional containing the Change.Id, or {@code Optional.empty()} if str does not
      *     represent a valid Change.Id.
@@ -438,37 +425,37 @@
   }
 
   /** Locally assigned unique identifier of the change */
-  protected Id changeId;
+  private Id changeId;
 
   /** Globally assigned unique identifier of the change */
-  protected Key changeKey;
+  private Key changeKey;
 
   /** When this change was first introduced into the database. */
-  protected Timestamp createdOn;
+  private Instant createdOn;
 
   /**
    * When was a meaningful modification last made to this record's data
    *
    * <p>Note, this update timestamp includes its children.
    */
-  protected Timestamp lastUpdatedOn;
+  private Instant lastUpdatedOn;
 
-  protected Account.Id owner;
+  private Account.Id owner;
 
   /** The branch (and project) this change merges into. */
-  protected BranchNameKey dest;
+  private BranchNameKey dest;
 
   /** Current state code; see {@link Status}. */
-  protected char status;
+  private char status;
 
   /** The current patch set. */
-  protected int currentPatchSetId;
+  private int currentPatchSetId;
 
   /** Subject from the current patch set. */
-  protected String subject;
+  private String subject;
 
   /** Topic name assigned by the user, if any. */
-  @Nullable protected String topic;
+  @Nullable private String topic;
 
   /**
    * First line of first patch set's commit message.
@@ -476,40 +463,36 @@
    * <p>Unlike {@link #subject}, this string does not change if future patch sets change the first
    * line.
    */
-  @Nullable protected String originalSubject;
+  @Nullable private String originalSubject;
 
   /**
    * Unique id for the changes submitted together assigned during merging. Only set if the status is
    * MERGED.
    */
-  @Nullable protected String submissionId;
+  @Nullable private String submissionId;
 
   /** Allows assigning a change to a user. */
-  @Nullable protected Account.Id assignee;
+  @Nullable private Account.Id assignee;
 
   /** Whether the change is private. */
-  protected boolean isPrivate;
+  private boolean isPrivate;
 
   /** Whether the change is work in progress. */
-  protected boolean workInProgress;
+  private boolean workInProgress;
 
   /** Whether the change has started review. */
-  protected boolean reviewStarted;
+  private boolean reviewStarted;
 
   /** References a change that this change reverts. */
-  @Nullable protected Id revertOf;
+  @Nullable private Id revertOf;
 
   /** References the source change and patchset that this change was cherry-picked from. */
-  @Nullable protected PatchSet.Id cherryPickOf;
+  @Nullable private PatchSet.Id cherryPickOf;
 
-  protected Change() {}
+  Change() {}
 
   public Change(
-      Change.Key newKey,
-      Change.Id newId,
-      Account.Id ownedBy,
-      BranchNameKey forBranch,
-      Timestamp ts) {
+      Change.Key newKey, Change.Id newId, Account.Id ownedBy, BranchNameKey forBranch, Instant ts) {
     changeKey = newKey;
     changeId = newId;
     createdOn = ts;
@@ -567,19 +550,19 @@
     assignee = a;
   }
 
-  public Timestamp getCreatedOn() {
+  public Instant getCreatedOn() {
     return createdOn;
   }
 
-  public void setCreatedOn(Timestamp ts) {
+  public void setCreatedOn(Instant ts) {
     createdOn = ts;
   }
 
-  public Timestamp getLastUpdatedOn() {
+  public Instant getLastUpdatedOn() {
     return lastUpdatedOn;
   }
 
-  public void setLastUpdatedOn(Timestamp now) {
+  public void setLastUpdatedOn(Instant now) {
     lastUpdatedOn = now;
   }
 
diff --git a/java/com/google/gerrit/entities/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
index cb56c31..609b54c 100644
--- a/java/com/google/gerrit/entities/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -16,7 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 /**
@@ -40,40 +40,40 @@
     public abstract String uuid();
   }
 
-  protected Key key;
+  private Key key;
 
   /** Who wrote this comment; null if it was written by the Gerrit system. */
-  @Nullable protected Account.Id author;
+  @Nullable private Account.Id author;
 
   /** When this comment was drafted. */
-  protected Timestamp writtenOn;
+  private Instant writtenOn;
 
   /**
    * The text left by the user or Gerrit system in template form, that is free of Gerrit User
    * Identifiable Information and can be persisted in data storage.
    */
-  @Nullable protected String message;
+  @Nullable private String message;
 
   /** Which patchset (if any) was this message generated from? */
-  @Nullable protected PatchSet.Id patchset;
+  @Nullable private PatchSet.Id patchset;
 
   /** Tag associated with change message */
-  @Nullable protected String tag;
+  @Nullable private String tag;
 
   /** Real user that added this message on behalf of the user recorded in {@link #author}. */
-  @Nullable protected Account.Id realAuthor;
+  @Nullable private Account.Id realAuthor;
 
-  protected ChangeMessage() {}
+  private ChangeMessage() {}
 
   public static ChangeMessage create(
-      final ChangeMessage.Key k, @Nullable Account.Id a, Timestamp wo, @Nullable PatchSet.Id psid) {
+      final ChangeMessage.Key k, @Nullable Account.Id a, Instant wo, @Nullable PatchSet.Id psid) {
     return create(k, a, wo, psid, /*messageTemplate=*/ null, /*realAuthor=*/ null, /*tag=*/ null);
   }
 
   public static ChangeMessage create(
       final ChangeMessage.Key k,
       @Nullable Account.Id a,
-      Timestamp wo,
+      Instant wo,
       @Nullable PatchSet.Id psid,
       @Nullable String messageTemplate,
       @Nullable Account.Id realAuthor,
@@ -103,7 +103,7 @@
     return realAuthor != null ? realAuthor : getAuthor();
   }
 
-  public Timestamp getWrittenOn() {
+  public Instant getWrittenOn() {
     return writtenOn;
   }
 
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/Comment.java b/java/com/google/gerrit/entities/Comment.java
index 92bcaf6..65a1559 100644
--- a/java/com/google/gerrit/entities/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -18,6 +18,7 @@
 import com.google.common.base.MoreObjects.ToStringHelper;
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Comparator;
 import java.util.Objects;
 import org.eclipse.jgit.lib.AnyObjectId;
@@ -215,7 +216,10 @@
 
   public Identity author;
   protected Identity realAuthor;
+
+  // TODO(issue-15525): Migrate this field from Timestamp to Instant
   public Timestamp writtenOn;
+
   public short side;
   public String message;
   public String parentUuid;
@@ -233,13 +237,7 @@
   public String serverId;
 
   public Comment(Comment c) {
-    this(
-        new Key(c.key),
-        c.author.getId(),
-        new Timestamp(c.writtenOn.getTime()),
-        c.side,
-        c.message,
-        c.serverId);
+    this(new Key(c.key), c.author.getId(), c.writtenOn.toInstant(), c.side, c.message, c.serverId);
     this.lineNbr = c.lineNbr;
     this.realAuthor = c.realAuthor;
     this.parentUuid = c.parentUuid;
@@ -249,21 +247,20 @@
   }
 
   public Comment(
-      Key key,
-      Account.Id author,
-      Timestamp writtenOn,
-      short side,
-      String message,
-      String serverId) {
+      Key key, Account.Id author, Instant writtenOn, short side, String message, String serverId) {
     this.key = key;
     this.author = new Comment.Identity(author);
     this.realAuthor = this.author;
-    this.writtenOn = writtenOn;
+    this.writtenOn = Timestamp.from(writtenOn);
     this.side = side;
     this.message = message;
     this.serverId = serverId;
   }
 
+  public void setWrittenOn(Instant writtenOn) {
+    this.writtenOn = Timestamp.from(writtenOn);
+  }
+
   public void setLineNbrAndRange(
       Integer lineNbr, com.google.gerrit.extensions.client.Comment.Range range) {
     this.lineNbr = lineNbr != null ? lineNbr : range != null ? range.endLine : 0;
diff --git a/java/com/google/gerrit/entities/EmailHeader.java b/java/com/google/gerrit/entities/EmailHeader.java
index d2be65e..e43b6a3 100644
--- a/java/com/google/gerrit/entities/EmailHeader.java
+++ b/java/com/google/gerrit/entities/EmailHeader.java
@@ -19,7 +19,9 @@
 import com.google.common.base.MoreObjects;
 import java.io.IOException;
 import java.io.Writer;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -127,13 +129,13 @@
   }
 
   public static class Date extends EmailHeader {
-    private final java.util.Date value;
+    private final Instant value;
 
-    public Date(java.util.Date v) {
+    public Date(Instant v) {
       value = v;
     }
 
-    public java.util.Date getDate() {
+    public Instant getDate() {
       return value;
     }
 
@@ -144,10 +146,12 @@
 
     @Override
     public void write(Writer w) throws IOException {
-      final SimpleDateFormat fmt;
-      // Mon, 1 Jun 2009 10:49:44 -0700
-      fmt = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z", Locale.US);
-      w.write(fmt.format(value));
+      // Mon, 1 Jun 2009 10:49:44 +0000
+      w.write(
+          DateTimeFormatter.ofPattern("EEE, d MMM yyyy HH:mm:ss Z")
+              .withLocale(Locale.US)
+              .withZone(ZoneId.of("UTC"))
+              .format(value));
     }
 
     @Override
@@ -157,7 +161,7 @@
 
     @Override
     public boolean equals(Object o) {
-      return (o instanceof Date) && value.getTime() == ((Date) o).value.getTime();
+      return (o instanceof Date) && Objects.equals(value, ((Date) o).value);
     }
 
     @Override
diff --git a/java/com/google/gerrit/entities/GroupDescription.java b/java/com/google/gerrit/entities/GroupDescription.java
index 7054bed..666e8f6 100644
--- a/java/com/google/gerrit/entities/GroupDescription.java
+++ b/java/com/google/gerrit/entities/GroupDescription.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.gerrit.common.Nullable;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Set;
 
 /** Group methods exposed by the GroupBackend. */
@@ -55,7 +55,7 @@
 
     boolean isVisibleToAll();
 
-    Timestamp getCreatedOn();
+    Instant getCreatedOn();
 
     Set<Account.Id> getMembers();
 
diff --git a/java/com/google/gerrit/entities/HumanComment.java b/java/com/google/gerrit/entities/HumanComment.java
index 50bee8d..d287fa0 100644
--- a/java/com/google/gerrit/entities/HumanComment.java
+++ b/java/com/google/gerrit/entities/HumanComment.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.entities;
 
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 /**
@@ -33,7 +33,7 @@
   public HumanComment(
       Key key,
       Account.Id author,
-      Timestamp writtenOn,
+      Instant writtenOn,
       short side,
       String message,
       String serverId,
diff --git a/java/com/google/gerrit/entities/InternalGroup.java b/java/com/google/gerrit/entities/InternalGroup.java
index ebfa36a..43c3af3 100644
--- a/java/com/google/gerrit/entities/InternalGroup.java
+++ b/java/com/google/gerrit/entities/InternalGroup.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import java.io.Serializable;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.ObjectId;
 
 @AutoValue
@@ -42,7 +42,7 @@
 
   public abstract AccountGroup.UUID getGroupUUID();
 
-  public abstract Timestamp getCreatedOn();
+  public abstract Instant getCreatedOn();
 
   public abstract ImmutableSet<Account.Id> getMembers();
 
@@ -71,7 +71,7 @@
 
     public abstract Builder setGroupUUID(AccountGroup.UUID groupUuid);
 
-    public abstract Builder setCreatedOn(Timestamp createdOn);
+    public abstract Builder setCreatedOn(Instant createdOn);
 
     public abstract Builder setMembers(ImmutableSet<Account.Id> members);
 
diff --git a/java/com/google/gerrit/entities/KeyUtil.java b/java/com/google/gerrit/entities/KeyUtil.java
index 40fb757..be28689 100644
--- a/java/com/google/gerrit/entities/KeyUtil.java
+++ b/java/com/google/gerrit/entities/KeyUtil.java
@@ -48,12 +48,12 @@
     for (char i = 'a'; i <= 'f'; i++) hexb[i] = (byte) ((i - 'a') + 10);
   }
 
-  public static String encode(final String e) {
+  public static String encode(final String key) {
     final byte[] b;
     try {
-      b = e.getBytes("UTF-8");
-    } catch (UnsupportedEncodingException e1) {
-      throw new RuntimeException("No UTF-8 support", e1);
+      b = key.getBytes("UTF-8");
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException("No UTF-8 support", e);
     }
 
     final StringBuilder r = new StringBuilder(b.length);
@@ -71,20 +71,20 @@
     return r.toString();
   }
 
-  public static String decode(final String e) {
-    if (e.indexOf('%') < 0) {
-      return e.replace('+', ' ');
+  public static String decode(final String key) {
+    if (key.indexOf('%') < 0) {
+      return key.replace('+', ' ');
     }
 
-    final byte[] b = new byte[e.length()];
+    final byte[] b = new byte[key.length()];
     int bPtr = 0;
     try {
-      for (int i = 0; i < e.length(); ) {
-        final char c = e.charAt(i);
-        if (c == '%' && i + 2 < e.length()) {
-          final int v = (hexb[e.charAt(i + 1)] << 4) | hexb[e.charAt(i + 2)];
+      for (int i = 0; i < key.length(); ) {
+        final char c = key.charAt(i);
+        if (c == '%' && i + 2 < key.length()) {
+          final int v = (hexb[key.charAt(i + 1)] << 4) | hexb[key.charAt(i + 2)];
           if (v < 0) {
-            throw new IllegalArgumentException(e.substring(i, i + 3));
+            throw new IllegalArgumentException(key.substring(i, i + 3));
           }
           b[bPtr++] = (byte) v;
           i += 3;
@@ -97,12 +97,12 @@
         }
       }
     } catch (ArrayIndexOutOfBoundsException err) {
-      throw new IllegalArgumentException("Bad encoding" + e, err);
+      throw new IllegalArgumentException("Bad encoding" + key, err);
     }
     try {
       return new String(b, 0, bPtr, "UTF-8");
-    } catch (UnsupportedEncodingException e1) {
-      throw new RuntimeException("No UTF-8 support", e1);
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException("No UTF-8 support", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index d254752..e36224e 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -95,6 +95,8 @@
 
   public abstract String getName();
 
+  public abstract Optional<String> getDescription();
+
   public abstract LabelFunction getFunction();
 
   public abstract boolean isCopyAnyScore();
@@ -141,8 +143,9 @@
   }
 
   public static LabelType.Builder builder(String name, List<LabelValue> valueList) {
-    return (new AutoValue_LabelType.Builder())
+    return new AutoValue_LabelType.Builder()
         .setName(name)
+        .setDescription(Optional.empty())
         .setValues(valueList)
         .setDefaultValue((short) 0)
         .setFunction(LabelFunction.MAX_WITH_BLOCK)
@@ -226,6 +229,13 @@
   public abstract static class Builder {
     public abstract Builder setName(String name);
 
+    public abstract Builder setDescription(Optional<String> description);
+
+    /**
+     * @deprecated in favour of using submit requirements, except if it’s needed to set the value to
+     *     PatchSetLock
+     */
+    @Deprecated
     public abstract Builder setFunction(LabelFunction function);
 
     public abstract Builder setCanOverride(boolean canOverride);
diff --git a/java/com/google/gerrit/entities/LabelTypes.java b/java/com/google/gerrit/entities/LabelTypes.java
index 55a9976..a2f2e0b 100644
--- a/java/com/google/gerrit/entities/LabelTypes.java
+++ b/java/com/google/gerrit/entities/LabelTypes.java
@@ -69,7 +69,7 @@
 
   public Comparator<String> nameComparator() {
     final Map<String, Integer> positions = positions();
-    return new Comparator<String>() {
+    return new Comparator<>() {
       @Override
       public int compare(String left, String right) {
         int lp = position(left);
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index acbf697d..6c52368 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -23,7 +23,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Ints;
 import com.google.errorprone.annotations.InlineMe;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
@@ -163,7 +163,7 @@
 
     public abstract Builder uploader(Account.Id uploader);
 
-    public abstract Builder createdOn(Timestamp createdOn);
+    public abstract Builder createdOn(Instant createdOn);
 
     public abstract Builder groups(Iterable<String> groups);
 
@@ -210,7 +210,7 @@
    * Gerrit, and the old data erroneously did not include a {@code createdOn}, then this method will
    * return a timestamp of 0.
    */
-  public abstract Timestamp createdOn();
+  public abstract Instant createdOn();
 
   /**
    * Opaque group identifier, usually assigned during creation.
diff --git a/java/com/google/gerrit/entities/PatchSetApproval.java b/java/com/google/gerrit/entities/PatchSetApproval.java
index f853f77..608cf0d 100644
--- a/java/com/google/gerrit/entities/PatchSetApproval.java
+++ b/java/com/google/gerrit/entities/PatchSetApproval.java
@@ -16,8 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.primitives.Shorts;
-import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 import java.util.Optional;
 
 /** An approval (or negative approval) on a patch set. */
@@ -40,6 +39,36 @@
     }
   }
 
+  /**
+   * Globally unique identifier.
+   *
+   * <p>The identifier is unique to each granted approval, i.e. approvals, re-added within same
+   * {@link Change} or even {@link PatchSet} have different {@link UUID}.
+   */
+  @AutoValue
+  public abstract static class UUID implements Comparable<UUID> {
+
+    abstract String uuid();
+
+    public String get() {
+      return uuid();
+    }
+
+    @Override
+    public final int compareTo(UUID o) {
+      return uuid().compareTo(o.uuid());
+    }
+
+    @Override
+    public final String toString() {
+      return get();
+    }
+  }
+
+  public static UUID uuid(String n) {
+    return new AutoValue_PatchSetApproval_UUID(n);
+  }
+
   public static Builder builder() {
     return new AutoValue_PatchSetApproval.Builder().postSubmit(false).copied(false);
   }
@@ -50,17 +79,24 @@
 
     public abstract Key key();
 
+    /**
+     * {@link UUID} of {@link PatchSetApproval}.
+     *
+     * <p>Optional, since it might be missing for approvals, granted (persisted in NoteDB), before
+     * {@link UUID} was introduced and does not apply to removals ( represented as approval with
+     * {@link #value}, set to '0').
+     */
+    public abstract Builder uuid(Optional<UUID> uuid);
+
+    public abstract Builder uuid(UUID uuid);
+
     public abstract Builder value(short value);
 
     public Builder value(int value) {
       return value(Shorts.checkedCast(value));
     }
 
-    public abstract Builder granted(Timestamp granted);
-
-    public Builder granted(Date granted) {
-      return granted(new Timestamp(granted.getTime()));
-    }
+    public abstract Builder granted(Instant granted);
 
     public abstract Builder tag(String tag);
 
@@ -86,6 +122,8 @@
 
   public abstract Key key();
 
+  public abstract Optional<UUID> uuid();
+
   /**
    * Value assigned by the user.
    *
@@ -104,7 +142,7 @@
    */
   public abstract short value();
 
-  public abstract Timestamp granted();
+  public abstract Instant granted();
 
   public abstract Optional<String> tag();
 
@@ -117,8 +155,24 @@
 
   public abstract Builder toBuilder();
 
+  /**
+   * Makes a copy of {@link PatchSetApproval} that applies to {@code psId}.
+   *
+   * <p>The returned {@link PatchSetApproval} has the same {@link UUID} as the original {@link
+   * PatchSetApproval}, which is generated when it is originally granted.
+   *
+   * <p>This is needed since we want to keep the link between the original {@link PatchSetApproval}
+   * and the {@link #copied} one.
+   *
+   * @param psId {@link PatchSet.Id} of {@link PatchSet} that the copy should be applied to.
+   * @return {@link #copied} {@link PatchSetApproval} that applies to {@code psId}.
+   */
   public PatchSetApproval copyWithPatchSet(PatchSet.Id psId) {
-    return toBuilder().key(key(psId, key().accountId(), key().labelId())).copied(true).build();
+    return toBuilder()
+        .key(key(psId, key().accountId(), key().labelId()))
+        .uuid(uuid())
+        .copied(true)
+        .build();
   }
 
   public PatchSet.Id patchSetId() {
diff --git a/java/com/google/gerrit/entities/PatchSetInfo.java b/java/com/google/gerrit/entities/PatchSetInfo.java
index e3c6613..5770a7e 100644
--- a/java/com/google/gerrit/entities/PatchSetInfo.java
+++ b/java/com/google/gerrit/entities/PatchSetInfo.java
@@ -31,33 +31,33 @@
       this.shortMessage = requireNonNull(shortMessage);
     }
 
-    protected ParentInfo() {}
+    ParentInfo() {}
   }
 
-  protected PatchSet.Id key;
+  private PatchSet.Id key;
 
   /** First line of {@link #message}. */
-  protected String subject;
+  private String subject;
 
   /** The complete description of the change the patch set introduces. */
-  protected String message;
+  private String message;
 
   /** Identity of who wrote the patch set. May differ from {@link #committer}. */
-  protected UserIdentity author;
+  private UserIdentity author;
 
   /** Identity of who committed the patch set to the VCS. */
-  protected UserIdentity committer;
+  private UserIdentity committer;
 
   /** List of parents of the patch set. */
-  protected List<ParentInfo> parents;
+  private List<ParentInfo> parents;
 
   /** ID of commit. */
-  protected ObjectId commitId;
+  private ObjectId commitId;
 
   /** Optional user-supplied description for the patch set. */
-  protected String description;
+  private String description;
 
-  protected PatchSetInfo() {}
+  PatchSetInfo() {}
 
   public PatchSetInfo(PatchSet.Id k) {
     key = k;
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index 2263aba..b9c1b3c 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.entities;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.UsedAt;
+import java.util.List;
 
 /** Constants and utilities for Gerrit-specific ref names. */
 public class RefNames {
@@ -126,6 +128,8 @@
           REFS_STARRED_CHANGES,
           REFS_REJECT_COMMITS);
 
+  private static final Splitter SPLITTER = Splitter.on("/");
+
   public static String fullName(String ref) {
     return (ref.startsWith(REFS) || ref.equals(HEAD)) ? ref : REFS_HEADS + ref;
   }
@@ -344,16 +348,16 @@
       return null;
     }
 
-    String[] parts = name.split("/");
-    int n = parts.length;
+    List<String> parts = SPLITTER.splitToList(name);
+    int n = parts.size();
     if (n < 2) {
       return null;
     }
 
     // Last 2 digits.
     int le;
-    for (le = 0; le < parts[0].length(); le++) {
-      if (!Character.isDigit(parts[0].charAt(le))) {
+    for (le = 0; le < parts.get(0).length(); le++) {
+      if (!Character.isDigit(parts.get(0).charAt(le))) {
         return null;
       }
     }
@@ -363,8 +367,8 @@
 
     // Full ID.
     int ie;
-    for (ie = 0; ie < parts[1].length(); ie++) {
-      if (!Character.isDigit(parts[1].charAt(ie))) {
+    for (ie = 0; ie < parts.get(1).length(); ie++) {
+      if (!Character.isDigit(parts.get(1).charAt(ie))) {
         if (ie == 0) {
           return null;
         }
@@ -372,8 +376,8 @@
       }
     }
 
-    int shard = Integer.parseInt(parts[0]);
-    int id = Integer.parseInt(parts[1].substring(0, ie));
+    int shard = Integer.parseInt(parts.get(0));
+    int id = Integer.parseInt(parts.get(1).substring(0, ie));
 
     if (id % 100 != shard) {
       return null;
@@ -387,20 +391,20 @@
       return null;
     }
 
-    String[] parts = name.split("/");
-    int n = parts.length;
+    List<String> parts = SPLITTER.splitToList(name);
+    int n = parts.size();
     if (n != 2) {
       return null;
     }
 
     // First 2 chars.
-    if (parts[0].length() != 2) {
+    if (parts.get(0).length() != 2) {
       return null;
     }
 
     // Full UUID.
-    String uuid = parts[1];
-    if (!uuid.startsWith(parts[0])) {
+    String uuid = parts.get(1);
+    if (!uuid.startsWith(parts.get(0))) {
       return null;
     }
 
@@ -421,16 +425,16 @@
       return null;
     }
 
-    String[] parts = name.split("/");
-    int n = parts.length;
+    List<String> parts = SPLITTER.splitToList(name);
+    int n = parts.size();
     if (n < 2) {
       return null;
     }
 
     // Last 2 digits.
     int le;
-    for (le = 0; le < parts[0].length(); le++) {
-      if (!Character.isDigit(parts[0].charAt(le))) {
+    for (le = 0; le < parts.get(0).length(); le++) {
+      if (!Character.isDigit(parts.get(0).charAt(le))) {
         return null;
       }
     }
@@ -440,8 +444,8 @@
 
     // Full ID.
     int ie;
-    for (ie = 0; ie < parts[1].length(); ie++) {
-      if (!Character.isDigit(parts[1].charAt(ie))) {
+    for (ie = 0; ie < parts.get(1).length(); ie++) {
+      if (!Character.isDigit(parts.get(1).charAt(ie))) {
         if (ie == 0) {
           return null;
         }
@@ -449,8 +453,8 @@
       }
     }
 
-    int shard = Integer.parseInt(parts[0]);
-    int id = Integer.parseInt(parts[1].substring(0, ie));
+    int shard = Integer.parseInt(parts.get(0));
+    int id = Integer.parseInt(parts.get(1).substring(0, ie));
 
     if (id % 100 != shard) {
       return null;
@@ -489,7 +493,7 @@
     return Integer.parseInt(rest.substring(0, ie));
   }
 
-  static Integer parseRefSuffix(String name) {
+  public static Integer parseRefSuffix(String name) {
     if (name == null) {
       return null;
     }
diff --git a/java/com/google/gerrit/entities/RobotComment.java b/java/com/google/gerrit/entities/RobotComment.java
index e2e4114..1d46d3b 100644
--- a/java/com/google/gerrit/entities/RobotComment.java
+++ b/java/com/google/gerrit/entities/RobotComment.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.entities;
 
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -29,7 +29,7 @@
   public RobotComment(
       Key key,
       Account.Id author,
-      Timestamp writtenOn,
+      Instant writtenOn,
       short side,
       String message,
       String serverId,
diff --git a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
index f298782..dc5abbc 100644
--- a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
+++ b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
@@ -58,7 +58,7 @@
    * on it's own.
    */
   public static StoredCommentLinkInfo disabled(String name) {
-    return builder(name).setOverrideOnly(true).build();
+    return builder(name).setOverrideOnly(true).setEnabled(false).build();
   }
 
   /** Creates and returns a new {@link StoredCommentLinkInfo.Builder} instance. */
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
index 13e0b53..523b993 100644
--- a/java/com/google/gerrit/entities/SubmitRequirement.java
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -1,25 +1,37 @@
-//  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;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gson.Gson;
 import com.google.gson.TypeAdapter;
 import java.util.Optional;
 
-/** Entity describing a requirement that should be met for a change to become submittable. */
+/**
+ * Entity describing a requirement that should be met for a change to become submittable.
+ *
+ * <p>There are two ways to contribute {@link SubmitRequirement}:
+ *
+ * <ul>
+ *   <li>Set per-project in project.config (see {@link
+ *       com.google.gerrit.server.project.ProjectState#getSubmitRequirements()}
+ *   <li>Bind a global {@link SubmitRequirement} that will be evaluated for all projects.
+ * </ul>
+ */
+@ExtensionPoint
 @AutoValue
 public abstract class SubmitRequirement {
   /** Requirement name. */
@@ -32,7 +44,7 @@
    * Expression of the condition that makes the requirement applicable. The expression should be
    * evaluated for a specific {@link Change} and if it returns false, the requirement becomes
    * irrelevant for the change (i.e. {@link #submittabilityExpression()} and {@link
-   * #overrideExpression()} become irrelevant).
+   * #overrideExpression()} are not evaluated).
    *
    * <p>An empty {@link Optional} indicates that the requirement is applicable for any change.
    */
@@ -56,7 +68,12 @@
 
   /**
    * Boolean value indicating if the {@link SubmitRequirement} definition can be overridden in child
-   * projects. Default is false.
+   * projects.
+   *
+   * <p>For globally bound {@link SubmitRequirement}, indicates if can be overridden by {@link
+   * SubmitRequirement} in project.config.
+   *
+   * <p>Default is false.
    */
   public abstract boolean allowOverrideInChildProjects();
 
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpression.java b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
index 2af1379..abac3e6 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpression.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
@@ -47,4 +47,12 @@
   public static TypeAdapter<SubmitRequirementExpression> typeAdapter(Gson gson) {
     return new AutoValue_SubmitRequirementExpression.GsonTypeAdapter(gson);
   }
+
+  /**
+   * Returns a "submit requirement" expression that requires the maximum vote on the Code-Review
+   * label.
+   */
+  public static SubmitRequirementExpression maxCodeReview() {
+    return SubmitRequirementExpression.create(String.format("label:Code-Review=MAX"));
+  }
 }
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index 900b2e2..aff0994 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -39,17 +39,17 @@
   /**
    * List leaf predicates that are fulfilled, for example the expression
    *
-   * <p><i>label:code-review=+2 and branch:refs/heads/master</i>
+   * <p><i>label:Code-Review=+2 and branch:refs/heads/master</i>
    *
    * <p>has two leaf predicates:
    *
    * <ul>
-   *   <li>label:code-review=+2
+   *   <li>label:Code-Review=+2
    *   <li>branch:refs/heads/master
    * </ul>
    *
    * This method will return the leaf predicates that were fulfilled, for example if only the first
-   * predicate was fulfilled, the returned list will be equal to ["label:code-review=+2"].
+   * predicate was fulfilled, the returned list will be equal to ["label:Code-Review=+2"].
    */
   public abstract ImmutableList<String> passingAtoms();
 
@@ -72,8 +72,17 @@
       Status status,
       ImmutableList<String> passingAtoms,
       ImmutableList<String> failingAtoms) {
+    return create(expression, status, passingAtoms, failingAtoms, Optional.empty());
+  }
+
+  public static SubmitRequirementExpressionResult create(
+      SubmitRequirementExpression expression,
+      Status status,
+      ImmutableList<String> passingAtoms,
+      ImmutableList<String> failingAtoms,
+      Optional<String> errorMessage) {
     return new AutoValue_SubmitRequirementExpressionResult(
-        expression, status, Optional.empty(), passingAtoms, failingAtoms);
+        expression, status, errorMessage, passingAtoms, failingAtoms);
   }
 
   public static SubmitRequirementExpressionResult error(
diff --git a/java/com/google/gerrit/entities/SubmitRequirementResult.java b/java/com/google/gerrit/entities/SubmitRequirementResult.java
index 13625c1..d9bb162 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementResult.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.auto.value.extension.memoized.Memoized;
+import com.google.gerrit.common.Nullable;
 import com.google.gson.Gson;
 import com.google.gson.TypeAdapter;
 import java.util.Optional;
@@ -31,11 +32,18 @@
   public abstract Optional<SubmitRequirementExpressionResult> applicabilityExpressionResult();
 
   /**
-   * Result of evaluating a {@link SubmitRequirement#submittabilityExpression()} ()} on a change.
+   * Result of evaluating a {@link SubmitRequirement#submittabilityExpression()} on a change.
+   *
+   * <p>Empty if submit requirement does not apply.
    */
-  public abstract SubmitRequirementExpressionResult submittabilityExpressionResult();
+  public abstract Optional<SubmitRequirementExpressionResult> submittabilityExpressionResult();
 
-  /** Result of evaluating a {@link SubmitRequirement#overrideExpression()} ()} on a change. */
+  /**
+   * Result of evaluating a {@link SubmitRequirement#overrideExpression()} on a change.
+   *
+   * <p>Empty if submit requirement does not apply, or if the submit requirement did not define an
+   * override expression.
+   */
   public abstract Optional<SubmitRequirementExpressionResult> overrideExpressionResult();
 
   /** SHA-1 of the patchset commit ID for which the submit requirement was evaluated. */
@@ -53,9 +61,56 @@
     return legacy().orElse(false);
   }
 
+  /**
+   * Boolean indicating if the "submit requirement" was bypassed during submission, e.g. by
+   * performing a push with the %submit option.
+   */
+  public abstract Optional<Boolean> forced();
+
+  /**
+   * Whether this result should be filtered out when returned from REST API.
+   *
+   * <p>This can be used by {@link
+   * com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier}. It can override the
+   * {@code SubmitRequirementResult} status and might want to hide the SR from the API as if it was
+   * non-applicable (non-applicable SRs are currently hidden on UI).
+   */
+  public abstract Optional<Boolean> hidden();
+
+  public boolean isHidden() {
+    return hidden().orElse(false);
+  }
+
+  public Optional<String> errorMessage() {
+    if (!status().equals(Status.ERROR)) {
+      return Optional.empty();
+    }
+    if (applicabilityExpressionResult().isPresent()
+        && applicabilityExpressionResult().get().errorMessage().isPresent()) {
+      return Optional.of(
+          "Applicability expression result has an error: "
+              + applicabilityExpressionResult().get().errorMessage().get());
+    }
+    if (submittabilityExpressionResult().isPresent()
+        && submittabilityExpressionResult().get().errorMessage().isPresent()) {
+      return Optional.of(
+          "Submittability expression result has an error: "
+              + submittabilityExpressionResult().get().errorMessage().get());
+    }
+    if (overrideExpressionResult().isPresent()
+        && overrideExpressionResult().get().errorMessage().isPresent()) {
+      return Optional.of(
+          "Override expression result has an error: "
+              + overrideExpressionResult().get().errorMessage().get());
+    }
+    return Optional.of("No error logged.");
+  }
+
   @Memoized
   public Status status() {
-    if (assertError(submittabilityExpressionResult())
+    if (forced().orElse(false)) {
+      return Status.FORCED;
+    } else if (assertError(submittabilityExpressionResult())
         || assertError(applicabilityExpressionResult())
         || assertError(overrideExpressionResult())) {
       return Status.ERROR;
@@ -74,13 +129,18 @@
   @Memoized
   public boolean fulfilled() {
     Status s = status();
-    return s == Status.SATISFIED || s == Status.OVERRIDDEN || s == Status.NOT_APPLICABLE;
+    return s == Status.SATISFIED
+        || s == Status.OVERRIDDEN
+        || s == Status.NOT_APPLICABLE
+        || s == Status.FORCED;
   }
 
   public static Builder builder() {
     return new AutoValue_SubmitRequirementResult.Builder();
   }
 
+  public abstract Builder toBuilder();
+
   public static TypeAdapter<SubmitRequirementResult> typeAdapter(Gson gson) {
     return new AutoValue_SubmitRequirementResult.GsonTypeAdapter(gson);
   }
@@ -108,10 +168,16 @@
     NOT_APPLICABLE,
 
     /**
-     * Any of the applicability, blocking or override expressions contain invalid syntax and are not
-     * parsable.
+     * Any of the applicability, submittability or override expressions contain invalid syntax and
+     * are not parsable.
      */
-    ERROR
+    ERROR,
+
+    /**
+     * The "submit requirement" was bypassed during submission, e.g. by pushing for review with the
+     * %submit option.
+     */
+    FORCED
   }
 
   @AutoValue.Builder
@@ -121,7 +187,11 @@
     public abstract Builder applicabilityExpressionResult(
         Optional<SubmitRequirementExpressionResult> value);
 
-    public abstract Builder submittabilityExpressionResult(SubmitRequirementExpressionResult value);
+    public abstract Builder submittabilityExpressionResult(
+        Optional<SubmitRequirementExpressionResult> value);
+
+    public abstract Builder submittabilityExpressionResult(
+        @Nullable SubmitRequirementExpressionResult value);
 
     public abstract Builder overrideExpressionResult(
         Optional<SubmitRequirementExpressionResult> value);
@@ -130,36 +200,32 @@
 
     public abstract Builder legacy(Optional<Boolean> value);
 
+    public abstract Builder forced(Optional<Boolean> value);
+
+    public abstract Builder hidden(Optional<Boolean> value);
+
     public abstract SubmitRequirementResult build();
   }
 
-  private boolean assertPass(Optional<SubmitRequirementExpressionResult> expressionResult) {
+  public static boolean assertPass(Optional<SubmitRequirementExpressionResult> expressionResult) {
     return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
   }
 
-  private boolean assertPass(SubmitRequirementExpressionResult expressionResult) {
-    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
-  }
-
-  private boolean assertFail(Optional<SubmitRequirementExpressionResult> expressionResult) {
+  public static boolean assertFail(Optional<SubmitRequirementExpressionResult> expressionResult) {
     return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.FAIL);
   }
 
-  private boolean assertError(Optional<SubmitRequirementExpressionResult> expressionResult) {
+  public static boolean assertError(Optional<SubmitRequirementExpressionResult> expressionResult) {
     return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
   }
 
-  private boolean assertError(SubmitRequirementExpressionResult expressionResult) {
-    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
-  }
-
-  private boolean assertStatus(
+  private static boolean assertStatus(
       SubmitRequirementExpressionResult expressionResult,
       SubmitRequirementExpressionResult.Status status) {
     return expressionResult.status() == status;
   }
 
-  private boolean assertStatus(
+  private static boolean assertStatus(
       Optional<SubmitRequirementExpressionResult> expressionResult,
       SubmitRequirementExpressionResult.Status status) {
     return expressionResult.isPresent() && assertStatus(expressionResult.get(), status);
diff --git a/java/com/google/gerrit/entities/SubmoduleSubscription.java b/java/com/google/gerrit/entities/SubmoduleSubscription.java
index 5ea1b1e..db26eb3 100644
--- a/java/com/google/gerrit/entities/SubmoduleSubscription.java
+++ b/java/com/google/gerrit/entities/SubmoduleSubscription.java
@@ -25,11 +25,11 @@
  * <p>A subscriber operates a submodule in defined path.
  */
 public final class SubmoduleSubscription {
-  protected BranchNameKey superProject;
+  private BranchNameKey superProject;
 
-  protected String submodulePath;
+  private String submodulePath;
 
-  protected BranchNameKey submodule;
+  private BranchNameKey submodule;
 
   public SubmoduleSubscription(BranchNameKey superProject, BranchNameKey submodule, String path) {
     this.superProject = superProject;
diff --git a/java/com/google/gerrit/entities/UserIdentity.java b/java/com/google/gerrit/entities/UserIdentity.java
index e07d21a..8334157 100644
--- a/java/com/google/gerrit/entities/UserIdentity.java
+++ b/java/com/google/gerrit/entities/UserIdentity.java
@@ -14,26 +14,26 @@
 
 package com.google.gerrit.entities;
 
-import java.sql.Timestamp;
+import java.time.Instant;
 
 public final class UserIdentity {
   /** Full name of the user. */
-  protected String name;
+  private String name;
 
   /** Email address (or user@host style string anyway). */
-  protected String email;
+  private String email;
 
   /** Username of the user. */
-  protected String username;
+  private String username;
 
   /** Time (in UTC) when the identity was constructed. */
-  protected Timestamp when;
+  private Instant when;
 
   /** Offset from UTC */
-  protected int tz;
+  private int tz;
 
   /** If the user has a Gerrit account, their account identity. */
-  protected Account.Id accountId;
+  private Account.Id accountId;
 
   public String getName() {
     return name;
@@ -55,11 +55,11 @@
     return username;
   }
 
-  public Timestamp getDate() {
+  public Instant getDate() {
     return when;
   }
 
-  public void setDate(Timestamp d) {
+  public void setDate(Instant d) {
     when = d;
   }
 
diff --git a/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
index eb2a381..edf921e 100644
--- a/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 @Immutable
@@ -44,9 +44,9 @@
     if (author != null) {
       builder.setAuthorId(accountIdConverter.toProto(author));
     }
-    Timestamp writtenOn = changeMessage.getWrittenOn();
+    Instant writtenOn = changeMessage.getWrittenOn();
     if (writtenOn != null) {
-      builder.setWrittenOn(writtenOn.getTime());
+      builder.setWrittenOn(writtenOn.toEpochMilli());
     }
     // Build proto with template representation of the message. Templates are parsed when message is
     // extracted from cache.
@@ -78,7 +78,7 @@
         proto.hasKey() ? changeMessageKeyConverter.fromProto(proto.getKey()) : null;
     Account.Id author =
         proto.hasAuthorId() ? accountIdConverter.fromProto(proto.getAuthorId()) : null;
-    Timestamp writtenOn = proto.hasWrittenOn() ? new Timestamp(proto.getWrittenOn()) : null;
+    Instant writtenOn = proto.hasWrittenOn() ? Instant.ofEpochMilli(proto.getWrittenOn()) : null;
     PatchSet.Id patchSetId =
         proto.hasPatchset() ? patchSetIdConverter.fromProto(proto.getPatchset()) : null;
     // Only template representation of the message is stored in entity. Templates should be replaced
diff --git a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
index 689b4aa..4903364 100644
--- a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 @Immutable
 public enum ChangeProtoConverter implements ProtoConverter<Entities.Change, Change> {
@@ -44,8 +44,8 @@
         Entities.Change.newBuilder()
             .setChangeId(changeIdConverter.toProto(change.getId()))
             .setChangeKey(changeKeyConverter.toProto(change.getKey()))
-            .setCreatedOn(change.getCreatedOn().getTime())
-            .setLastUpdatedOn(change.getLastUpdatedOn().getTime())
+            .setCreatedOn(change.getCreatedOn().toEpochMilli())
+            .setLastUpdatedOn(change.getLastUpdatedOn().toEpochMilli())
             .setOwnerAccountId(accountIdConverter.toProto(change.getOwner()))
             .setDest(branchNameConverter.toProto(change.getDest()))
             .setStatus(change.getStatus().getCode())
@@ -96,9 +96,9 @@
     BranchNameKey destination =
         proto.hasDest() ? branchNameConverter.fromProto(proto.getDest()) : null;
     Change change =
-        new Change(key, changeId, owner, destination, new Timestamp(proto.getCreatedOn()));
+        new Change(key, changeId, owner, destination, Instant.ofEpochMilli(proto.getCreatedOn()));
     if (proto.hasLastUpdatedOn()) {
-      change.setLastUpdatedOn(new Timestamp(proto.getLastUpdatedOn()));
+      change.setLastUpdatedOn(Instant.ofEpochMilli(proto.getLastUpdatedOn()));
     }
     Change.Status status = Change.Status.forCode((char) proto.getStatus());
     if (status != null) {
diff --git a/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
index 9e77025..e8ef346 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 @Immutable
@@ -38,10 +38,11 @@
         Entities.PatchSetApproval.newBuilder()
             .setKey(patchSetApprovalKeyProtoConverter.toProto(patchSetApproval.key()))
             .setValue(patchSetApproval.value())
-            .setGranted(patchSetApproval.granted().getTime())
+            .setGranted(patchSetApproval.granted().toEpochMilli())
             .setPostSubmit(patchSetApproval.postSubmit())
             .setCopied(patchSetApproval.copied());
 
+    patchSetApproval.uuid().ifPresent(uuid -> builder.setUuid(uuid.get()));
     patchSetApproval.tag().ifPresent(builder::setTag);
     Account.Id realAccountId = patchSetApproval.realAccountId();
     // PatchSetApproval#getRealAccountId automatically delegates to PatchSetApproval#getAccountId if
@@ -61,9 +62,12 @@
         PatchSetApproval.builder()
             .key(patchSetApprovalKeyProtoConverter.fromProto(proto.getKey()))
             .value(proto.getValue())
-            .granted(new Timestamp(proto.getGranted()))
+            .granted(Instant.ofEpochMilli(proto.getGranted()))
             .postSubmit(proto.getPostSubmit())
             .copied(proto.getCopied());
+    if (proto.hasUuid()) {
+      builder.uuid(PatchSetApproval.uuid(proto.getUuid()));
+    }
     if (proto.hasTag()) {
       builder.tag(proto.getTag());
     }
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
index 13a6e71..210972d 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -42,7 +42,7 @@
             .setId(patchSetIdConverter.toProto(patchSet.id()))
             .setCommitId(objectIdConverter.toProto(patchSet.commitId()))
             .setUploaderAccountId(accountIdConverter.toProto(patchSet.uploader()))
-            .setCreatedOn(patchSet.createdOn().getTime());
+            .setCreatedOn(patchSet.createdOn().toEpochMilli());
     List<String> groups = patchSet.groups();
     if (!groups.isEmpty()) {
       builder.setGroups(PatchSet.joinGroups(groups));
@@ -84,7 +84,8 @@
             proto.hasUploaderAccountId()
                 ? accountIdConverter.fromProto(proto.getUploaderAccountId())
                 : Account.id(0))
-        .createdOn(proto.hasCreatedOn() ? new Timestamp(proto.getCreatedOn()) : new Timestamp(0));
+        .createdOn(
+            proto.hasCreatedOn() ? Instant.ofEpochMilli(proto.getCreatedOn()) : Instant.EPOCH);
 
     return builder.build();
   }
diff --git a/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java b/java/com/google/gerrit/exceptions/MergeUpdateException.java
similarity index 64%
rename from java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
rename to java/com/google/gerrit/exceptions/MergeUpdateException.java
index 452192c..b60ca57 100644
--- a/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
+++ b/java/com/google/gerrit/exceptions/MergeUpdateException.java
@@ -14,10 +14,15 @@
 
 package com.google.gerrit.exceptions;
 
-public class InternalServerWithUserMessageException extends RuntimeException {
+/**
+ * An exception used for changes that fail to merge. This exception has a user visible message
+ * unlike other {@link RuntimeException}s, because this is our way to improve the UX when
+ * submission/merges fail.
+ */
+public class MergeUpdateException extends RuntimeException {
   private static final long serialVersionUID = 1L;
 
-  public InternalServerWithUserMessageException(String msg, Throwable cause) {
-    super(msg, cause);
+  public MergeUpdateException(String userVisibleMessage, Throwable cause) {
+    super(userVisibleMessage, cause);
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index fb03bc5..232b2b5 100644
--- a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -31,4 +31,5 @@
   public boolean allowConflicts;
   public String topic;
   public boolean allowEmpty;
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
index ee10a1d..8432c8f 100644
--- a/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
@@ -25,4 +25,10 @@
   public NotifyHandling notify = NotifyHandling.ALL;
 
   public Map<RecipientType, NotifyInfo> notifyDetails;
+
+  /**
+   * Users in the attention set will not be added/removed from this endpoint call. Normally, users
+   * are added to the attention set upon deletion of their vote by other users.
+   */
+  public boolean ignoreAutomaticAttentionSetRules;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index 10559a3..e9b05cc 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import java.util.Map;
+
 public class RebaseInput {
   public String base;
 
@@ -24,4 +26,6 @@
    * to indicate the conflicts.
    */
   public boolean allowConflicts;
+
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 1307516..b659cca 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -51,12 +51,6 @@
 
   ChangeInfo submit(SubmitInput in) throws RestApiException;
 
-  default BinaryResult submitPreview() throws RestApiException {
-    return submitPreview("zip");
-  }
-
-  BinaryResult submitPreview(String format) throws RestApiException;
-
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
 
   ChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException;
@@ -369,11 +363,6 @@
     }
 
     @Override
-    public BinaryResult submitPreview(String format) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
index e2cab4d..68a4e88 100644
--- a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
+++ b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
@@ -16,5 +16,6 @@
 
 /** Output options available for submitted_together requests. */
 public enum SubmittedTogetherOption {
-  NON_VISIBLE_CHANGES;
+  NON_VISIBLE_CHANGES,
+  TOPIC_CLOSURE;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/BranchInput.java b/java/com/google/gerrit/extensions/api/projects/BranchInput.java
index aaf69d9..aeefcd1 100644
--- a/java/com/google/gerrit/extensions/api/projects/BranchInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/BranchInput.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.extensions.api.projects;
 
 import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
 
 public class BranchInput {
   @DefaultInput public String revision;
   public String ref;
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/TagInfo.java b/java/com/google/gerrit/extensions/api/projects/TagInfo.java
index a6269fe..61ea518 100644
--- a/java/com/google/gerrit/extensions/api/projects/TagInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/TagInfo.java
@@ -14,16 +14,22 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 
 public class TagInfo extends RefInfo {
   public String object;
   public String message;
   public GitPerson tagger;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp created;
+
   public List<WebLinkInfo> webLinks;
 
   public TagInfo(
@@ -39,8 +45,22 @@
     this.created = created;
   }
 
+  @SuppressWarnings("JdkObsolete")
+  public TagInfo(
+      String ref,
+      String revision,
+      Boolean canDelete,
+      List<WebLinkInfo> webLinks,
+      @Nullable Instant created) {
+    this.ref = ref;
+    this.revision = revision;
+    this.canDelete = canDelete;
+    this.webLinks = webLinks;
+    this.created = created != null ? Timestamp.from(created) : null;
+  }
+
   public TagInfo(String ref, String revision, Boolean canDelete, List<WebLinkInfo> webLinks) {
-    this(ref, revision, canDelete, webLinks, null);
+    this(ref, revision, canDelete, webLinks, (Instant) null);
   }
 
   public TagInfo(
@@ -66,8 +86,24 @@
       String message,
       GitPerson tagger,
       Boolean canDelete,
+      List<WebLinkInfo> webLinks,
+      Instant created) {
+    this(ref, revision, canDelete, webLinks, created);
+    this.object = object;
+    this.message = message;
+    this.tagger = tagger;
+    this.webLinks = webLinks;
+  }
+
+  public TagInfo(
+      String ref,
+      String revision,
+      String object,
+      String message,
+      GitPerson tagger,
+      Boolean canDelete,
       List<WebLinkInfo> webLinks) {
-    this(ref, revision, object, message, tagger, canDelete, webLinks, null);
+    this(ref, revision, object, message, tagger, canDelete, webLinks, (Instant) null);
     this.object = object;
     this.message = message;
     this.tagger = tagger;
diff --git a/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
index 5cd13db..b8843d3 100644
--- a/java/com/google/gerrit/extensions/client/Comment.java
+++ b/java/com/google/gerrit/extensions/client/Comment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.client;
 
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Comparator;
 import java.util.Objects;
 
@@ -35,7 +36,11 @@
 
   public Range range;
   public String inReplyTo;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp updated;
+
   public String message;
 
   /**
@@ -44,6 +49,20 @@
    */
   public String commitId;
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getUpdated() {
+    return updated.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setUpdated(Instant when) {
+    updated = Timestamp.from(when);
+  }
+
   public static class Range implements Comparable<Range> {
     private static final Comparator<Range> RANGE_COMPARATOR =
         Comparator.<Range>comparingInt(range -> range.startLine)
@@ -110,6 +129,11 @@
     return 1;
   }
 
+  // This is a value class that allows adding attributes by subclassing.
+  // Doing this is discouraged and using composition rather than inheritance to add fields to value
+  // types is preferred. However this class is part of the extension API, hence we cannot change it
+  // without breaking the API. Hence suppress the EqualsGetClass warning here.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object o) {
     if (this == o) {
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index b26f435..6acf3f4 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -22,9 +22,6 @@
   /** Default number of items to display per page. */
   public static final int DEFAULT_PAGESIZE = 25;
 
-  /** Valid choices for the page size. */
-  public static final int[] PAGESIZE_CHOICES = {10, 25, 50, 100};
-
   /** Preferred method to download a change. */
   public enum DownloadCommand {
     PULL,
diff --git a/java/com/google/gerrit/extensions/common/AccountDetailInfo.java b/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
index a2aeab2..a76a7f9 100644
--- a/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.time.Instant;
 
 /**
  * Representation of a (detailed) account in the REST API.
@@ -27,9 +28,18 @@
  */
 public class AccountDetailInfo extends AccountInfo {
   /** The timestamp of when the account was registered. */
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp registeredOn;
 
   public AccountDetailInfo(Integer id) {
     super(id);
   }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setRegisteredOn(Instant registeredOn) {
+    this.registeredOn = Timestamp.from(registeredOn);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java b/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
index e3e0fc8..b51e195 100644
--- a/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
@@ -57,10 +57,10 @@
   public boolean equals(Object o) {
     if (o instanceof AccountExternalIdInfo) {
       AccountExternalIdInfo a = (AccountExternalIdInfo) o;
-      return (Objects.equals(a.identity, identity))
-          && (Objects.equals(a.emailAddress, emailAddress))
-          && (Objects.equals(a.trusted, trusted))
-          && (Objects.equals(a.canDelete, canDelete));
+      return Objects.equals(a.identity, identity)
+          && Objects.equals(a.emailAddress, emailAddress)
+          && Objects.equals(a.trusted, trusted)
+          && Objects.equals(a.canDelete, canDelete);
     }
     return false;
   }
diff --git a/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index bf72e83..4519add 100644
--- a/java/com/google/gerrit/extensions/common/ApprovalInfo.java
+++ b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 /**
@@ -43,6 +44,8 @@
   public Integer value;
 
   /** The time and date describing when the approval was made. */
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp date;
 
   /** Whether this vote was made after the change was submitted. */
@@ -62,10 +65,10 @@
 
   public ApprovalInfo(
       Integer id,
-      Integer value,
+      @Nullable Integer value,
       @Nullable VotingRangeInfo permittedVotingRange,
       @Nullable String tag,
-      Timestamp date) {
+      @Nullable Timestamp date) {
     super(id);
     this.value = value;
     this.permittedVotingRange = permittedVotingRange;
@@ -73,6 +76,28 @@
     this.tag = tag;
   }
 
+  public ApprovalInfo(
+      Integer id,
+      @Nullable Integer value,
+      @Nullable VotingRangeInfo permittedVotingRange,
+      @Nullable String tag,
+      @Nullable Instant date) {
+    super(id);
+    this.value = value;
+    this.permittedVotingRange = permittedVotingRange;
+    this.tag = tag;
+    if (date != null) {
+      setDate(date);
+    }
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setDate(Instant date) {
+    this.date = Timestamp.from(date);
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ApprovalInfo) {
@@ -88,6 +113,11 @@
   }
 
   @Override
+  public String toString() {
+    return super.toString() + ", value=" + this.value;
+  }
+
+  @Override
   public int hashCode() {
     return Objects.hash(super.hashCode(), tag, value, date, postSubmit, permittedVotingRange);
   }
diff --git a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
index d34ba6d..81dbc88 100644
--- a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
+++ b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 /**
@@ -28,8 +29,12 @@
 public class AttentionSetInfo {
   /** The user included in the attention set. */
   public AccountInfo account;
+
   /** The timestamp of the last update. */
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp lastUpdate;
+
   /** The human readable reason why the user was added. */
   public String reason;
 
@@ -51,6 +56,17 @@
     this.reasonAccount = reasonAccount;
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public AttentionSetInfo(
+      AccountInfo account, Instant lastUpdate, String reason, @Nullable AccountInfo reasonAccount) {
+    this.account = account;
+    this.lastUpdate = Timestamp.from(lastUpdate);
+    this.reason = reason;
+    this.reasonAccount = reasonAccount;
+  }
+
   protected AttentionSetInfo() {}
 
   @Override
diff --git a/java/com/google/gerrit/extensions/common/BlameInfo.java b/java/com/google/gerrit/extensions/common/BlameInfo.java
index df3f373..6ee677e 100644
--- a/java/com/google/gerrit/extensions/common/BlameInfo.java
+++ b/java/com/google/gerrit/extensions/common/BlameInfo.java
@@ -28,7 +28,7 @@
     if (this == o) {
       return true;
     }
-    if (o == null || getClass() != o.getClass()) {
+    if (o == null || !(o instanceof BlameInfo)) {
       return false;
     }
     BlameInfo blameInfo = (BlameInfo) o;
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index fc09b49..3bcd150 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -24,4 +24,5 @@
   public String mergeabilityComputationBehavior;
   public Boolean enableAttentionSet;
   public Boolean enableAssignee;
+  public Boolean conflictsPredicateEnabled;
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 2bb3dd7..40ae2ec 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -46,14 +47,20 @@
    */
   public Map<Integer, AttentionSetInfo> attentionSet;
 
+  public Map<Integer, AttentionSetInfo> removedFromAttentionSet;
+
   public AccountInfo assignee;
   public Collection<String> hashtags;
   public String changeId;
   public String subject;
   public ChangeStatus status;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp created;
   public Timestamp updated;
   public Timestamp submitted;
+
   public AccountInfo submitter;
   public Boolean starred;
   public Collection<String> stars;
@@ -124,4 +131,47 @@
   public ChangeInfo(Map<String, RevisionInfo> revisions) {
     this.revisions = ImmutableMap.copyOf(revisions);
   }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getCreated() {
+    return created.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setCreated(Instant when) {
+    created = Timestamp.from(when);
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getUpdated() {
+    return updated.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setUpdated(Instant when) {
+    updated = Timestamp.from(when);
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getSubmitted() {
+    return submitted.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setSubmitted(Instant when, AccountInfo who) {
+    submitted = Timestamp.from(when);
+    submitter = who;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index c1cb1627..51fe57c 100644
--- a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.common.collect.Iterables;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collection;
 import java.util.Objects;
 
@@ -24,7 +26,11 @@
   public String tag;
   public AccountInfo author;
   public AccountInfo realAuthor;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp date;
+
   public String message;
   public Collection<AccountInfo> accountsInMessage;
   public Integer _revisionNumber;
@@ -35,6 +41,13 @@
     this.message = message;
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setDate(Instant when) {
+    date = Timestamp.from(when);
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ChangeMessageInfo) {
@@ -45,7 +58,10 @@
           && Objects.equals(realAuthor, cmi.realAuthor)
           && Objects.equals(date, cmi.date)
           && Objects.equals(message, cmi.message)
-          && Objects.equals(accountsInMessage, cmi.accountsInMessage)
+          && ((accountsInMessage == null && cmi.accountsInMessage == null)
+              || (accountsInMessage != null
+                  && cmi.accountsInMessage != null
+                  && Iterables.elementsEqual(accountsInMessage, cmi.accountsInMessage)))
           && Objects.equals(_revisionNumber, cmi._revisionNumber);
     }
     return false;
diff --git a/java/com/google/gerrit/extensions/common/GitPerson.java b/java/com/google/gerrit/extensions/common/GitPerson.java
index 8ed919e..df3e488 100644
--- a/java/com/google/gerrit/extensions/common/GitPerson.java
+++ b/java/com/google/gerrit/extensions/common/GitPerson.java
@@ -15,14 +15,26 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 public class GitPerson {
   public String name;
   public String email;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp date;
+
   public int tz;
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setDate(Instant when) {
+    date = Timestamp.from(when);
+  }
+
   @Override
   public boolean equals(Object o) {
     if (!(o instanceof GitPerson)) {
diff --git a/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java b/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
index 711337a..9a13713 100644
--- a/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
+++ b/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 public abstract class GroupAuditEventInfo {
@@ -27,25 +29,62 @@
 
   public Type type;
   public AccountInfo user;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp date;
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   public static UserMemberAuditEventInfo createAddUserEvent(
       AccountInfo user, Timestamp date, AccountInfo member) {
-    return new UserMemberAuditEventInfo(Type.ADD_USER, user, Optional.of(date), member);
+    return new UserMemberAuditEventInfo(Type.ADD_USER, user, date.toInstant(), member);
+  }
+
+  public static UserMemberAuditEventInfo createAddUserEvent(
+      AccountInfo user, Instant date, AccountInfo member) {
+    return new UserMemberAuditEventInfo(Type.ADD_USER, user, date, member);
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public static UserMemberAuditEventInfo createRemoveUserEvent(
+      AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
+    return new UserMemberAuditEventInfo(
+        Type.REMOVE_USER, user, date.map(Timestamp::toInstant).orElse(null), member);
   }
 
   public static UserMemberAuditEventInfo createRemoveUserEvent(
-      AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
+      AccountInfo user, @Nullable Instant date, AccountInfo member) {
     return new UserMemberAuditEventInfo(Type.REMOVE_USER, user, date, member);
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   public static GroupMemberAuditEventInfo createAddGroupEvent(
       AccountInfo user, Timestamp date, GroupInfo member) {
-    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, Optional.of(date), member);
+    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, date.toInstant(), member);
+  }
+
+  public static GroupMemberAuditEventInfo createAddGroupEvent(
+      AccountInfo user, Instant date, GroupInfo member) {
+    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, date, member);
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public static GroupMemberAuditEventInfo createRemoveGroupEvent(
+      AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
+    return new GroupMemberAuditEventInfo(
+        Type.REMOVE_GROUP, user, date.map(Timestamp::toInstant).orElse(null), member);
   }
 
   public static GroupMemberAuditEventInfo createRemoveGroupEvent(
-      AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
+      AccountInfo user, @Nullable Instant date, GroupInfo member) {
     return new GroupMemberAuditEventInfo(Type.REMOVE_GROUP, user, date, member);
   }
 
@@ -55,11 +94,20 @@
     this.date = date.orElse(null);
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  protected GroupAuditEventInfo(Type type, AccountInfo user, @Nullable Instant date) {
+    this.type = type;
+    this.user = user;
+    this.date = date != null ? Timestamp.from(date) : null;
+  }
+
   public static class UserMemberAuditEventInfo extends GroupAuditEventInfo {
     public AccountInfo member;
 
     private UserMemberAuditEventInfo(
-        Type type, AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
+        Type type, AccountInfo user, @Nullable Instant date, AccountInfo member) {
       super(type, user, date);
       this.member = member;
     }
@@ -69,7 +117,7 @@
     public GroupInfo member;
 
     private GroupMemberAuditEventInfo(
-        Type type, AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
+        Type type, AccountInfo user, @Nullable Instant date, GroupInfo member) {
       super(type, user, date);
       this.member = member;
     }
diff --git a/java/com/google/gerrit/extensions/common/GroupInfo.java b/java/com/google/gerrit/extensions/common/GroupInfo.java
index b21475c..edbaa01 100644
--- a/java/com/google/gerrit/extensions/common/GroupInfo.java
+++ b/java/com/google/gerrit/extensions/common/GroupInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 
 public class GroupInfo extends GroupBaseInfo {
@@ -26,10 +27,28 @@
   public Integer groupId;
   public String owner;
   public String ownerId;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp createdOn;
+
   public Boolean _moreGroups;
 
   // These fields are only supplied for internal groups, and only if requested.
   public List<AccountInfo> members;
   public List<GroupInfo> includes;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getCreatedOn() {
+    return createdOn.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setCreatedOn(Instant when) {
+    createdOn = Timestamp.from(when);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
index 6f733d6..d3baecd 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -19,6 +19,7 @@
 
 public class LabelDefinitionInfo {
   public String name;
+  public String description;
   public String projectName;
   public String function;
   public Map<String, String> values;
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
index 38b76c1..1d580bb 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -19,6 +19,7 @@
 
 public class LabelDefinitionInput extends InputWithCommitMessage {
   public String name;
+  public String description;
   public String function;
   public Map<String, String> values;
   public Short defaultValue;
diff --git a/java/com/google/gerrit/extensions/common/LabelInfo.java b/java/com/google/gerrit/extensions/common/LabelInfo.java
index 44bcdaf..cdd3b18 100644
--- a/java/com/google/gerrit/extensions/common/LabelInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelInfo.java
@@ -27,6 +27,7 @@
 
   public Map<String, String> values;
 
+  public String description;
   public Short value;
   public Short defaultValue;
   public Boolean optional;
diff --git a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
index 37e1ceb..36682f6 100644
--- a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
+++ b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
@@ -16,14 +16,31 @@
 
 import com.google.gerrit.extensions.client.ReviewerState;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 public class ReviewerUpdateInfo {
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp updated;
+
   public AccountInfo updatedBy;
   public AccountInfo reviewer;
   public ReviewerState state;
 
+  public ReviewerUpdateInfo() {}
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public ReviewerUpdateInfo(
+      Instant updated, AccountInfo updatedBy, AccountInfo reviewer, ReviewerState state) {
+    this.updated = Timestamp.from(updated);
+    this.updatedBy = updatedBy;
+    this.reviewer = reviewer;
+    this.state = state;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ReviewerUpdateInfo) {
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index f710ab7..7c52c8c 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.client.ChangeKind;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Map;
 import java.util.Objects;
 
@@ -25,7 +26,11 @@
   public transient boolean isCurrent;
   public ChangeKind kind;
   public int _number;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp created;
+
   public AccountInfo uploader;
   public String ref;
   public Map<String, FetchInfo> fetch;
@@ -51,6 +56,13 @@
     this.uploader = uploader;
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setCreated(Instant date) {
+    this.created = Timestamp.from(date);
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof RevisionInfo) {
diff --git a/java/com/google/gerrit/extensions/common/ServerInfo.java b/java/com/google/gerrit/extensions/common/ServerInfo.java
index bc7fcfd..ce65240 100644
--- a/java/com/google/gerrit/extensions/common/ServerInfo.java
+++ b/java/com/google/gerrit/extensions/common/ServerInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.List;
+
 /** API response containing values from {@code gerrit.config} as nested objects. */
 public class ServerInfo {
   public AccountsInfo accounts;
@@ -28,4 +30,5 @@
   public UserConfigInfo user;
   public ReceiveInfo receive;
   public String defaultTheme;
+  public List<String> submitRequirementDashboardColumns;
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java b/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
index e591963..d957733 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 /** API response containing a {@link com.google.gerrit.entities.SubmitRecord} entity. */
 public class SubmitRecordInfo {
@@ -38,6 +39,25 @@
     public String label;
     public Label.Status status;
     public AccountInfo appliedBy;
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof Label)) {
+        return false;
+      }
+      Label that = (Label) o;
+      return Objects.equals(label, that.label)
+          && status == that.status
+          && Objects.equals(appliedBy, that.appliedBy);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(label, status, appliedBy);
+    }
   }
 
   public String ruleName;
@@ -45,4 +65,25 @@
   public List<Label> labels;
   public List<LegacySubmitRequirementInfo> requirements;
   public String errorMessage;
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof SubmitRecordInfo)) {
+      return false;
+    }
+    SubmitRecordInfo that = (SubmitRecordInfo) o;
+    return Objects.equals(ruleName, that.ruleName)
+        && status == that.status
+        && Objects.equals(labels, that.labels)
+        && Objects.equals(requirements, that.requirements)
+        && Objects.equals(errorMessage, that.errorMessage);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(ruleName, status, labels, requirements, errorMessage);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
index 4d1fce2..b4731f2 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 /** Result of evaluating a single submit requirement expression. */
 public class SubmitRequirementExpressionInfo {
@@ -36,4 +37,31 @@
    * has two atoms: ["branch:refs/heads/foo", "project:bar"].
    */
   public List<String> failingAtoms;
+
+  /**
+   * Optional error message. Contains an explanation of why the submit requirement expression failed
+   * during its evaluation.
+   */
+  public String errorMessage;
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof SubmitRequirementExpressionInfo)) {
+      return false;
+    }
+    SubmitRequirementExpressionInfo that = (SubmitRequirementExpressionInfo) o;
+    return fulfilled == that.fulfilled
+        && Objects.equals(expression, that.expression)
+        && Objects.equals(passingAtoms, that.passingAtoms)
+        && Objects.equals(failingAtoms, that.failingAtoms)
+        && Objects.equals(errorMessage, that.errorMessage);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(expression, fulfilled, passingAtoms, failingAtoms, errorMessage);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
index 3d50f13..cf0d53c 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.common.Nullable;
+import java.util.Objects;
+
 /** Result of evaluating a submit requirement on a change. */
 public class SubmitRequirementResultInfo {
   public enum Status {
@@ -41,7 +44,13 @@
      * Any of the applicability, submittability or override expressions contain invalid syntax and
      * are not parsable.
      */
-    ERROR
+    ERROR,
+
+    /**
+     * The "submit requirement" was bypassed during submission, e.g. by pushing for review with the
+     * %submit option.
+     */
+    FORCED
   }
 
   /** Submit requirement name. */
@@ -57,11 +66,41 @@
   public boolean isLegacy;
 
   /** Result of evaluating the applicability expression. */
-  public SubmitRequirementExpressionInfo applicabilityExpressionResult;
+  @Nullable public SubmitRequirementExpressionInfo applicabilityExpressionResult;
 
   /** Result of evaluating the submittability expression. */
-  public SubmitRequirementExpressionInfo submittabilityExpressionResult;
+  @Nullable public SubmitRequirementExpressionInfo submittabilityExpressionResult;
 
   /** Result of evaluating the override expression. */
-  public SubmitRequirementExpressionInfo overrideExpressionResult;
+  @Nullable public SubmitRequirementExpressionInfo overrideExpressionResult;
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof SubmitRequirementResultInfo)) {
+      return false;
+    }
+    SubmitRequirementResultInfo that = (SubmitRequirementResultInfo) o;
+    return isLegacy == that.isLegacy
+        && Objects.equals(name, that.name)
+        && Objects.equals(description, that.description)
+        && status == that.status
+        && Objects.equals(applicabilityExpressionResult, that.applicabilityExpressionResult)
+        && Objects.equals(submittabilityExpressionResult, that.submittabilityExpressionResult)
+        && Objects.equals(overrideExpressionResult, that.overrideExpressionResult);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        name,
+        description,
+        status,
+        isLegacy,
+        applicabilityExpressionResult,
+        submittabilityExpressionResult,
+        overrideExpressionResult);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
index d827d5d..f75ec66 100644
--- a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
@@ -24,7 +24,6 @@
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.GitPerson;
 import java.sql.Timestamp;
-import java.util.Date;
 import org.eclipse.jgit.lib.PersonIdent;
 
 public class GitPersonSubject extends Subject {
@@ -71,11 +70,16 @@
     tz().isEqualTo(other.tz);
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   public void matches(PersonIdent ident) {
     isNotNull();
     name().isEqualTo(ident.getName());
     email().isEqualTo(ident.getEmailAddress());
-    check("roundedDate()").that(new Date(gitPerson.date.getTime())).isEqualTo(ident.getWhen());
+    check("roundedDate()")
+        .that(gitPerson.date.getTime())
+        .isEqualTo(ident.getWhenAsInstant().toEpochMilli());
     tz().isEqualTo(ident.getTimeZoneOffset());
   }
 }
diff --git a/java/com/google/gerrit/extensions/events/ChangeEvent.java b/java/com/google/gerrit/extensions/events/ChangeEvent.java
index def75b7..6542d8e 100644
--- a/java/com/google/gerrit/extensions/events/ChangeEvent.java
+++ b/java/com/google/gerrit/extensions/events/ChangeEvent.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Interface to be extended by Events with a Change. */
 public interface ChangeEvent extends GerritEvent {
@@ -29,5 +29,5 @@
 
   AccountInfo getWho();
 
-  Timestamp getWhen();
+  Instant getWhen();
 }
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/registration/DynamicMap.java b/java/com/google/gerrit/extensions/registration/DynamicMap.java
index 48b1279..6fd2c03 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicMap.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicMap.java
@@ -24,8 +24,8 @@
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.NavigableMap;
+import java.util.NavigableSet;
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.concurrent.ConcurrentHashMap;
@@ -116,14 +116,14 @@
   /**
    * Get the names of all running plugins supplying this type.
    *
-   * @return sorted set of active plugins that supply at least one item.
+   * @return navigatable set of active plugins that supply at least one item.
    */
-  public SortedSet<String> plugins() {
-    SortedSet<String> r = new TreeSet<>();
+  public NavigableSet<String> plugins() {
+    NavigableSet<String> r = new TreeSet<>();
     for (NamePair p : items.keySet()) {
       r.add(p.pluginName);
     }
-    return Collections.unmodifiableSortedSet(r);
+    return Collections.unmodifiableNavigableSet(r);
   }
 
   /**
@@ -132,21 +132,21 @@
    * @param pluginName name of the plugin.
    * @return items exported by a plugin, keyed by the export name.
    */
-  public SortedMap<String, Provider<T>> byPlugin(String pluginName) {
-    SortedMap<String, Provider<T>> r = new TreeMap<>();
+  public NavigableMap<String, Provider<T>> byPlugin(String pluginName) {
+    NavigableMap<String, Provider<T>> r = new TreeMap<>();
     for (Map.Entry<NamePair, Provider<T>> e : items.entrySet()) {
       if (e.getKey().pluginName.equals(pluginName)) {
         r.put(e.getKey().exportName, e.getValue());
       }
     }
-    return Collections.unmodifiableSortedMap(r);
+    return Collections.unmodifiableNavigableMap(r);
   }
 
   /** Iterate through all entries in an undefined order. */
   @Override
   public Iterator<Extension<T>> iterator() {
     final Iterator<Map.Entry<NamePair, Provider<T>>> i = items.entrySet().iterator();
-    return new Iterator<Extension<T>>() {
+    return new Iterator<>() {
       @Override
       public boolean hasNext() {
         return i.hasNext();
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSet.java b/java/com/google/gerrit/extensions/registration/DynamicSet.java
index b2e871e..a0b2c6a 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -153,7 +153,7 @@
   @Override
   public Iterator<T> iterator() {
     Iterator<Extension<T>> entryIterator = entries().iterator();
-    return new Iterator<T>() {
+    return new Iterator<>() {
       @Override
       public boolean hasNext() {
         return entryIterator.hasNext();
@@ -170,7 +170,7 @@
   public Iterable<Extension<T>> entries() {
     final Iterator<AtomicReference<Extension<T>>> itr = items.iterator();
     return () ->
-        new Iterator<Extension<T>>() {
+        new Iterator<>() {
           private Extension<T> next;
 
           @Override
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java b/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
index 832933b..d8999e3 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.common.collect.ImmutableList;
 import com.google.inject.Binding;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicReference;
 
@@ -38,11 +38,12 @@
     return new DynamicSet<>(find(injector, type));
   }
 
-  private static <T> List<AtomicReference<Extension<T>>> find(Injector src, TypeLiteral<T> type) {
+  private static <T> ImmutableList<AtomicReference<Extension<T>>> find(
+      Injector src, TypeLiteral<T> type) {
     List<Binding<T>> bindings = src.findBindingsByType(type);
     int cnt = bindings != null ? bindings.size() : 0;
     if (cnt == 0) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
     List<AtomicReference<Extension<T>>> r = new ArrayList<>(cnt);
     for (Binding<T> b : bindings) {
@@ -50,6 +51,6 @@
         r.add(new AtomicReference<>(new Extension<>(PluginName.GERRIT, b.getProvider())));
       }
     }
-    return r;
+    return ImmutableList.copyOf(r);
   }
 }
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
index fd31fcd..5b528cb 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.inject.Binding;
 import com.google.inject.Inject;
@@ -80,10 +81,10 @@
     return Collections.unmodifiableMap(m);
   }
 
-  public static List<RegistrationHandle> attachItems(
+  public static ImmutableList<RegistrationHandle> attachItems(
       Injector src, String pluginName, Map<TypeLiteral<?>, DynamicItem<?>> items) {
     if (src == null || items == null || items.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     List<RegistrationHandle> handles = new ArrayList<>(4);
@@ -103,13 +104,13 @@
       remove(handles);
       throw e;
     }
-    return handles;
+    return ImmutableList.copyOf(handles);
   }
 
-  public static List<RegistrationHandle> attachSets(
+  public static ImmutableList<RegistrationHandle> attachSets(
       Injector src, String pluginName, Map<TypeLiteral<?>, DynamicSet<?>> sets) {
     if (src == null || sets == null || sets.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     List<RegistrationHandle> handles = new ArrayList<>(4);
@@ -131,13 +132,13 @@
       remove(handles);
       throw e;
     }
-    return handles;
+    return ImmutableList.copyOf(handles);
   }
 
-  public static List<RegistrationHandle> attachMaps(
+  public static ImmutableList<RegistrationHandle> attachMaps(
       Injector src, String pluginName, Map<TypeLiteral<?>, DynamicMap<?>> maps) {
     if (src == null || maps == null || maps.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     List<RegistrationHandle> handles = new ArrayList<>(4);
@@ -160,7 +161,7 @@
       remove(handles);
       throw e;
     }
-    return handles;
+    return ImmutableList.copyOf(handles);
   }
 
   public static LifecycleListener registerInParentInjectors() {
diff --git a/java/com/google/gerrit/extensions/validators/CommentValidationContext.java b/java/com/google/gerrit/extensions/validators/CommentValidationContext.java
index db08058..2c9d8f2 100644
--- a/java/com/google/gerrit/extensions/validators/CommentValidationContext.java
+++ b/java/com/google/gerrit/extensions/validators/CommentValidationContext.java
@@ -34,7 +34,10 @@
   /** Returns the project the comment is being added to. */
   public abstract String getProject();
 
-  public static CommentValidationContext create(int changeId, String project) {
-    return new AutoValue_CommentValidationContext(changeId, project);
+  /** Returns the ref name the comment is being added to. */
+  public abstract String getRefName();
+
+  public static CommentValidationContext create(int changeId, String project, String refName) {
+    return new AutoValue_CommentValidationContext(changeId, project, refName);
   }
 }
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/PublicKeyChecker.java b/java/com/google/gerrit/gpg/PublicKeyChecker.java
index 27530e7..0a96212 100644
--- a/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -32,9 +32,9 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -62,7 +62,7 @@
   private PublicKeyStore store;
   private Map<Long, Fingerprint> trusted;
   private int maxTrustDepth;
-  private Date effectiveTime = new Date();
+  private Instant effectiveTime = Instant.now();
 
   /**
    * Enable web-of-trust checks.
@@ -111,12 +111,12 @@
    * @param effectiveTime effective time.
    * @return a reference to this object.
    */
-  public PublicKeyChecker setEffectiveTime(Date effectiveTime) {
+  public PublicKeyChecker setEffectiveTime(Instant effectiveTime) {
     this.effectiveTime = effectiveTime;
     return this;
   }
 
-  protected Date getEffectiveTime() {
+  protected Instant getEffectiveTime() {
     return effectiveTime;
   }
 
@@ -183,13 +183,13 @@
     return CheckResult.create(status, problems);
   }
 
-  private CheckResult checkBasic(PGPPublicKey key, Date now) {
+  private CheckResult checkBasic(PGPPublicKey key, Instant now) {
     List<String> problems = new ArrayList<>(2);
     gatherRevocationProblems(key, now, problems);
 
     long validMs = key.getValidSeconds() * 1000;
     if (validMs != 0) {
-      long msSinceCreation = now.getTime() - key.getCreationTime().getTime();
+      long msSinceCreation = now.toEpochMilli() - getCreationTime(key).toEpochMilli();
       if (msSinceCreation > validMs) {
         problems.add("Key is expired");
       }
@@ -197,7 +197,7 @@
     return CheckResult.create(problems);
   }
 
-  private void gatherRevocationProblems(PGPPublicKey key, Date now, List<String> problems) {
+  private void gatherRevocationProblems(PGPPublicKey key, Instant now, List<String> problems) {
     try {
       List<PGPSignature> revocations = new ArrayList<>();
       Map<Long, RevocationKey> revokers = new HashMap<>();
@@ -216,7 +216,7 @@
   }
 
   private static boolean isRevocationValid(
-      PGPSignature revocation, RevocationReason reason, Date now) {
+      PGPSignature revocation, RevocationReason reason, Instant now) {
     // RFC4880 states:
     // "If a key has been revoked because of a compromise, all signatures
     // created by that key are suspect. However, if it was merely superseded or
@@ -226,11 +226,14 @@
     // consider the revocation reason and timestamp when checking whether a
     // signature (data or certification) is valid.
     return reason.getRevocationReason() == KEY_COMPROMISED
-        || revocation.getCreationTime().before(now);
+        || PushCertificateChecker.getCreationTime(revocation).isBefore(now);
   }
 
   private PGPSignature scanRevocations(
-      PGPPublicKey key, Date now, List<PGPSignature> revocations, Map<Long, RevocationKey> revokers)
+      PGPPublicKey key,
+      Instant now,
+      List<PGPSignature> revocations,
+      Map<Long, RevocationKey> revokers)
       throws PGPException {
     @SuppressWarnings("unchecked")
     Iterator<PGPSignature> allSigs = key.getSignatures();
@@ -305,7 +308,7 @@
       if (rk.getAlgorithm() != revoker.getAlgorithm()) {
         continue;
       }
-      if (!checkBasic(rk, revocation.getCreationTime()).isOk()) {
+      if (!checkBasic(rk, PushCertificateChecker.getCreationTime(revocation)).isOk()) {
         // Revoker's key was expired or revoked at time of revocation, so the
         // revocation is invalid.
         continue;
@@ -469,4 +472,9 @@
     }
     return null;
   }
+
+  @SuppressWarnings("JdkObsolete")
+  private static Instant getCreationTime(PGPPublicKey key) {
+    return key.getCreationTime().toInstant();
+  }
 }
diff --git a/java/com/google/gerrit/gpg/PushCertificateChecker.java b/java/com/google/gerrit/gpg/PushCertificateChecker.java
index 36a4af7..17ca5a4 100644
--- a/java/com/google/gerrit/gpg/PushCertificateChecker.java
+++ b/java/com/google/gerrit/gpg/PushCertificateChecker.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import org.bouncycastle.bcpg.ArmoredInputStream;
@@ -106,7 +107,7 @@
       }
     } catch (PGPException | IOException e) {
       String msg = "Internal error checking push certificate";
-      logger.atSevere().withCause(e).log(msg);
+      logger.atSevere().withCause(e).log("%s", msg);
       results.add(CheckResult.bad(msg));
     }
 
@@ -205,7 +206,7 @@
           null, CheckResult.bad("Signature by " + keyIdToString(sig.getKeyID()) + " is not valid"));
     }
     CheckResult result =
-        publicKeyChecker.setStore(store).setEffectiveTime(sig.getCreationTime()).check(signer);
+        publicKeyChecker.setStore(store).setEffectiveTime(getCreationTime(sig)).check(signer);
     if (!result.getProblems().isEmpty()) {
       StringBuilder err =
           new StringBuilder("Invalid public key ")
@@ -216,4 +217,9 @@
     }
     return new Result(signer, result);
   }
+
+  @SuppressWarnings("JdkObsolete")
+  public static Instant getCreationTime(PGPSignature signature) {
+    return signature.getCreationTime().toInstant();
+  }
 }
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index e0c921d..bcc8631 100644
--- a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -95,7 +95,7 @@
 
       CommitBuilder cb = new CommitBuilder();
       PersonIdent committer = serverIdent.get();
-      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
+      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer));
       cb.setCommitter(committer);
       cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
 
diff --git a/java/com/google/gerrit/gpg/server/GpgKey.java b/java/com/google/gerrit/gpg/server/GpgKey.java
index aa6b6f4..fbe97ad 100644
--- a/java/com/google/gerrit/gpg/server/GpgKey.java
+++ b/java/com/google/gerrit/gpg/server/GpgKey.java
@@ -21,8 +21,7 @@
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
 
 public class GpgKey extends AccountResource {
-  public static final TypeLiteral<RestView<GpgKey>> GPG_KEY_KIND =
-      new TypeLiteral<RestView<GpgKey>>() {};
+  public static final TypeLiteral<RestView<GpgKey>> GPG_KEY_KIND = new TypeLiteral<>() {};
 
   private final PGPPublicKeyRing keyRing;
 
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index d46b344..3341806 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -164,7 +164,7 @@
     }
   }
 
-  private Map<ExternalId, Fingerprint> readKeysToRemove(
+  private ImmutableMap<ExternalId, Fingerprint> readKeysToRemove(
       GpgKeysInput input, Collection<ExternalId> existingExtIds) {
     if (input.delete == null || input.delete.isEmpty()) {
       return ImmutableMap.of();
@@ -179,10 +179,11 @@
         // Skip removal.
       }
     }
-    return fingerprints;
+    return ImmutableMap.copyOf(fingerprints);
   }
 
-  private List<PGPPublicKeyRing> readKeysToAdd(GpgKeysInput input, Collection<Fingerprint> toRemove)
+  private ImmutableList<PGPPublicKeyRing> readKeysToAdd(
+      GpgKeysInput input, Collection<Fingerprint> toRemove)
       throws BadRequestException, IOException {
     if (input.add == null || input.add.isEmpty()) {
       return ImmutableList.of();
@@ -206,7 +207,7 @@
         throw new BadRequestException("Failed to parse GPG keys", e);
       }
     }
-    return keyRings;
+    return ImmutableList.copyOf(keyRings);
   }
 
   private void storeKeys(
@@ -249,7 +250,7 @@
       }
       CommitBuilder cb = new CommitBuilder();
       PersonIdent committer = serverIdent.get();
-      cb.setAuthor(user.newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
+      cb.setAuthor(user.newCommitterIdent(committer));
       cb.setCommitter(committer);
 
       RefUpdate.Result saveResult = store.save(cb);
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index ea7c609..1284829 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -37,7 +37,7 @@
         "//lib:soy",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/httpd/CanonicalWebUrl.java b/java/com/google/gerrit/httpd/CanonicalWebUrl.java
index 437ddf3..3b04884 100644
--- a/java/com/google/gerrit/httpd/CanonicalWebUrl.java
+++ b/java/com/google/gerrit/httpd/CanonicalWebUrl.java
@@ -37,6 +37,7 @@
     return url != null ? url : computeFromRequest(req);
   }
 
+  @SuppressWarnings("JdkObsolete")
   static String computeFromRequest(HttpServletRequest req) {
     StringBuffer url = req.getRequestURL();
     try {
diff --git a/java/com/google/gerrit/httpd/CookieBase64.java b/java/com/google/gerrit/httpd/CookieBase64.java
index 52cfde7..376ae1d 100644
--- a/java/com/google/gerrit/httpd/CookieBase64.java
+++ b/java/com/google/gerrit/httpd/CookieBase64.java
@@ -75,7 +75,7 @@
         out.append(enc[(inBuff >>> 18)]);
         out.append(enc[(inBuff >>> 12) & 0x3f]);
         out.append(enc[(inBuff >>> 6) & 0x3f]);
-        out.append(enc[(inBuff) & 0x3f]);
+        out.append(enc[inBuff & 0x3f]);
         break;
 
       case 2:
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index 5ed0629..189d081 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -499,6 +499,7 @@
         }
       } catch (Throwable e) {
         logger.atSevere().withCause(e).log(
+            "%s",
             MessageFormat.format(
                 HttpServerText.get().internalErrorDuringUploadPack,
                 ServletUtils.getRepository(req)));
@@ -526,7 +527,7 @@
         throws ServiceNotAuthorizedException {
       final ProjectState state = (ProjectState) req.getAttribute(ATT_STATE);
 
-      if (!(userProvider.get().isIdentifiedUser())) {
+      if (!userProvider.get().isIdentifiedUser()) {
         // Anonymous users are not permitted to push.
         throw new ServiceNotAuthorizedException();
       }
@@ -633,7 +634,7 @@
         return;
       }
 
-      if (!(userProvider.get().isIdentifiedUser())) {
+      if (!userProvider.get().isIdentifiedUser()) {
         chain.doFilter(request, responseWrapper);
         return;
       }
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/ProjectBasicAuthFilter.java b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index a421139..b0c7615 100644
--- a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -186,7 +186,7 @@
       if (passwordVerifier.checkPassword(who.externalIds(), username, password)) {
         return succeedAuthentication(who, null);
       }
-      logger.atWarning().withCause(e).log(authenticationFailedMsg(username, req));
+      logger.atWarning().withCause(e).log("%s", authenticationFailedMsg(username, req));
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     } catch (AuthenticationFailedException e) {
@@ -200,7 +200,7 @@
       rsp.sendError(SC_SERVICE_UNAVAILABLE);
       return false;
     } catch (AccountException e) {
-      logger.atWarning().withCause(e).log(authenticationFailedMsg(username, req));
+      logger.atWarning().withCause(e).log("%s", authenticationFailedMsg(username, req));
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
@@ -214,8 +214,8 @@
   private boolean failAuthentication(Response rsp, String username, HttpServletRequest req)
       throws IOException {
     logger.atWarning().log(
-        authenticationFailedMsg(username, req)
-            + ": password does not match the one stored in Gerrit");
+        "%s: password does not match the one stored in Gerrit",
+        authenticationFailedMsg(username, req));
     rsp.sendError(SC_UNAUTHORIZED);
     return false;
   }
diff --git a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
index fa53053..de6ae50 100644
--- a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -158,8 +158,8 @@
         accountCache.getByUsername(authInfo.username).filter(a -> a.account().isActive());
     if (!who.isPresent()) {
       logger.atWarning().log(
-          authenticationFailedMsg(authInfo.username, req)
-              + ": account inactive or not provisioned in Gerrit");
+          "%s: account inactive or not provisioned in Gerrit",
+          authenticationFailedMsg(authInfo.username, req));
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
@@ -180,7 +180,7 @@
       ws.setAccessPathOk(AccessPath.REST_API, true);
       return true;
     } catch (AccountException e) {
-      logger.atWarning().withCause(e).log(authenticationFailedMsg(authInfo.username, req));
+      logger.atWarning().withCause(e).log("%s", authenticationFailedMsg(authInfo.username, req));
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
diff --git a/java/com/google/gerrit/httpd/RequireSslFilter.java b/java/com/google/gerrit/httpd/RequireSslFilter.java
index a4a87e2..ca3c3d8 100644
--- a/java/com/google/gerrit/httpd/RequireSslFilter.java
+++ b/java/com/google/gerrit/httpd/RequireSslFilter.java
@@ -78,10 +78,7 @@
       //
       final String url;
       if (isLocalHost(req)) {
-        final StringBuffer b = req.getRequestURL();
-        b.replace(0, b.indexOf(":"), "https");
-        url = b.toString();
-
+        url = getLocalHostUrl(req);
       } else {
         url = urlProvider.get() + req.getServletPath();
       }
@@ -90,6 +87,13 @@
     }
   }
 
+  @SuppressWarnings("JdkObsolete")
+  private static String getLocalHostUrl(HttpServletRequest req) {
+    StringBuffer b = req.getRequestURL();
+    b.replace(0, b.indexOf(":"), "https");
+    return b.toString();
+  }
+
   private static boolean isSecure(HttpServletRequest req) {
     return "https".equals(req.getScheme()) || req.isSecure();
   }
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index acb3282..5a5de0a 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -145,8 +145,10 @@
 
   String getRemoteDisplayname(HttpServletRequest req) {
     if (displaynameHeader != null) {
-      String raw = req.getHeader(displaynameHeader);
-      return emptyToNull(new String(raw.getBytes(ISO_8859_1), UTF_8));
+      String raw = emptyToNull(req.getHeader(displaynameHeader));
+      if (raw != null) {
+        return new String(raw.getBytes(ISO_8859_1), UTF_8);
+      }
     }
     return null;
   }
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
index 820c7a2..59a7379 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.auth.container;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.server.account.AccountException;
@@ -36,8 +35,6 @@
 
 @Singleton
 class HttpsClientSslCertAuthFilter implements Filter {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private static final Pattern REGEX_USERID = Pattern.compile("CN=([^,]*)");
 
   private final DynamicItem<WebSession> webSession;
@@ -79,9 +76,7 @@
     try {
       arsp = accountManager.authenticate(areq);
     } catch (AccountException e) {
-      String err = "Unable to authenticate user \"" + userName + "\"";
-      logger.atSevere().withCause(e).log(err);
-      throw new ServletException(err, e);
+      throw new ServletException("Unable to authenticate user \"" + userName + "\"", e);
     }
     webSession.get().login(arsp, true);
     chain.doFilter(req, rsp);
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
index 2642a543..935762f 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
@@ -31,9 +31,9 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Map;
+import java.util.NavigableMap;
+import java.util.NavigableSet;
 import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -175,12 +175,12 @@
   }
 
   private void pickSSOServiceProvider() throws ServletException {
-    SortedSet<String> plugins = oauthServiceProviders.plugins();
+    NavigableSet<String> plugins = oauthServiceProviders.plugins();
     if (plugins.isEmpty()) {
       throw new ServletException("OAuth service provider wasn't installed");
     }
     if (plugins.size() == 1) {
-      SortedMap<String, Provider<OAuthServiceProvider>> services =
+      NavigableMap<String, Provider<OAuthServiceProvider>> services =
           oauthServiceProviders.byPlugin(Iterables.getOnlyElement(plugins));
       if (services.size() == 1) {
         ssoProvider = Iterables.getOnlyElement(services.values()).get();
diff --git a/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java b/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
index 2e8585d..3d9c819 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
@@ -21,8 +21,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.NavigableMap;
+import java.util.NavigableSet;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -79,9 +79,9 @@
   }
 
   private void pickSSOServiceProvider() {
-    SortedSet<String> plugins = oauthServiceProviders.plugins();
+    NavigableSet<String> plugins = oauthServiceProviders.plugins();
     if (plugins.size() == 1) {
-      SortedMap<String, Provider<OAuthServiceProvider>> services =
+      NavigableMap<String, Provider<OAuthServiceProvider>> services =
           oauthServiceProviders.byPlugin(Iterables.getOnlyElement(plugins));
       if (services.size() == 1) {
         ssoProvider = Iterables.getOnlyElement(services.values()).get();
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 4c18afd..4c31253 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -75,7 +75,7 @@
 import java.net.URISyntaxException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.Enumeration;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -137,7 +137,7 @@
     getProjectRoot(allProjects);
 
     final String url = gitwebConfig.getUrl();
-    if ((url != null) && (!url.equals("gitweb"))) {
+    if (url != null && !url.equals("gitweb")) {
       URI uri = null;
       try {
         uri = new URI(url);
@@ -573,9 +573,7 @@
     env.set("SERVER_PROTOCOL", req.getProtocol());
     env.set("SERVER_SOFTWARE", getServletContext().getServerInfo());
 
-    final Enumeration<String> hdrs = enumerateHeaderNames(req);
-    while (hdrs.hasMoreElements()) {
-      final String name = hdrs.nextElement();
+    for (String name : getHeaderNames(req)) {
       final String value = req.getHeader(name);
       env.set("HTTP_" + name.toUpperCase().replace('-', '_'), value);
     }
@@ -720,7 +718,7 @@
                         .collect(Collectors.joining("\n"))
                         .trim();
                 if (!err.isEmpty()) {
-                  logger.atSevere().log(err);
+                  logger.atSevere().log("%s", err);
                 }
               } catch (IOException e) {
                 logger.atSevere().withCause(e).log("Unexpected error copying stderr from CGI");
@@ -730,10 +728,6 @@
         .start();
   }
 
-  private static Enumeration<String> enumerateHeaderNames(HttpServletRequest req) {
-    return req.getHeaderNames();
-  }
-
   private void readCgiHeaders(HttpServletResponse res, InputStream in) throws IOException {
     String line;
     while (!(line = readLine(in)).isEmpty()) {
@@ -774,6 +768,11 @@
     return buf.toString().trim();
   }
 
+  @SuppressWarnings("JdkObsolete")
+  private static Iterable<String> getHeaderNames(HttpServletRequest req) {
+    return Collections.list(req.getHeaderNames());
+  }
+
   /** private utility class that manages the Environment passed to exec. */
   private static class EnvList {
     private Map<String, String> envMap;
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 6488f2e..dcabac0 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -345,7 +345,7 @@
         });
     modules.add(new DefaultUrlFormatterModule());
 
-    SshSessionFactoryInitializer.init(config);
+    SshSessionFactoryInitializer.init();
     modules.add(SshKeyCacheImpl.module());
     modules.add(
         new AbstractModule() {
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index 97752a0..a03aa36 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -68,7 +68,6 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
@@ -86,7 +85,7 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.util.IO;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -447,8 +446,7 @@
           return false;
         };
 
-    List<PluginEntry> entries =
-        Collections.list(scanner.entries()).stream().filter(filter).collect(toList());
+    List<PluginEntry> entries = scanner.entries().filter(filter).collect(toList());
     for (PluginEntry entry : entries) {
       String name = entry.getName().substring(prefix.length());
       if (name.startsWith("cmd-")) {
@@ -520,7 +518,7 @@
     macros.put("URL", url);
 
     Matcher m = Pattern.compile("(\\\\)?@([A-Z_]+)@").matcher(md);
-    StringBuffer sb = new StringBuffer();
+    StringBuilder sb = new StringBuilder();
     while (m.find()) {
       String key = m.group(2);
       String val = macros.get(key);
diff --git a/java/com/google/gerrit/httpd/plugins/PluginServletContext.java b/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
index 5a8fa31..5e875d7 100644
--- a/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
+++ b/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
@@ -61,11 +61,11 @@
       try {
         handler = API.class.getDeclaredMethod(method.getName(), method.getParameterTypes());
       } catch (NoSuchMethodException e) {
-        String msg =
-            String.format(
-                "%s does not implement %s", PluginServletContext.class, method.toGenericString());
-        logger.atSevere().withCause(e).log(msg);
-        throw new NoSuchMethodError(msg);
+        throw new NoSuchMethodError(
+                String.format(
+                    "%s does not implement %s",
+                    PluginServletContext.class, method.toGenericString()))
+            .initCause(e);
       }
       return handler.invoke(this, args);
     }
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 445a73a..ce22ae8 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -97,7 +97,8 @@
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
         break;
       case DIFF:
-        data.put("defaultDiffDetailHex", ListOption.toHex(IndexPreloadingUtil.DIFF_OPTIONS));
+        data.put(
+            "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index 3bdcb1a..fa9a820 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -89,7 +89,10 @@
           .map(query -> query.replaceAll("\\$\\{user}", "self"))
           .collect(toImmutableList());
   public static final ImmutableSet<ListChangesOption> DASHBOARD_OPTIONS =
-      ImmutableSet.of(ListChangesOption.LABELS, ListChangesOption.DETAILED_ACCOUNTS);
+      ImmutableSet.of(
+          ListChangesOption.LABELS,
+          ListChangesOption.DETAILED_ACCOUNTS,
+          ListChangesOption.SUBMIT_REQUIREMENTS);
 
   public static final ImmutableSet<ListChangesOption> CHANGE_DETAIL_OPTIONS =
       ImmutableSet.of(
@@ -101,13 +104,8 @@
           ListChangesOption.MESSAGES,
           ListChangesOption.SUBMITTABLE,
           ListChangesOption.WEB_LINKS,
-          ListChangesOption.SKIP_DIFFSTAT);
-
-  public static final ImmutableSet<ListChangesOption> DIFF_OPTIONS =
-      ImmutableSet.of(
-          ListChangesOption.ALL_COMMITS,
-          ListChangesOption.ALL_REVISIONS,
-          ListChangesOption.SKIP_DIFFSTAT);
+          ListChangesOption.SKIP_DIFFSTAT,
+          ListChangesOption.SUBMIT_REQUIREMENTS);
 
   public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
     if (requestedURL == null) {
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index 3f2c202..fcb821e 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -38,6 +38,8 @@
 
 public class IndexServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
+  private static final String POLY_GERRIT_INDEX_HTML_SOY =
+      "com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy";
 
   @Nullable private final String canonicalUrl;
   @Nullable private final String cdnPath;
@@ -60,7 +62,7 @@
     this.experimentFeatures = experimentFeatures;
     this.soySauce =
         SoyFileSet.builder()
-            .add(Resources.getResource("com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy"))
+            .add(Resources.getResource(POLY_GERRIT_INDEX_HTML_SOY), POLY_GERRIT_INDEX_HTML_SOY)
             .build()
             .compileTemplates();
     this.urlOrdainer =
@@ -74,7 +76,6 @@
     SoySauce.Renderer renderer;
     try {
       Map<String, String[]> parameterMap = req.getParameterMap();
-      String requestUrl = req.getRequestURL() == null ? null : req.getRequestURL().toString();
       // TODO(hiesel): Remove URL ordainer as parameter once Soy is consistent
       ImmutableMap<String, Object> templateData =
           IndexHtmlUtil.templateData(
@@ -85,7 +86,7 @@
               faviconPath,
               parameterMap,
               urlOrdainer,
-              requestUrl);
+              getRequestUrl(req));
       renderer = soySauce.renderTemplate("com.google.gerrit.httpd.raw.Index").setData(templateData);
     } catch (URISyntaxException | RestApiException e) {
       throw new IOException(e);
@@ -98,4 +99,13 @@
       w.write(renderer.renderHtml().get().toString().getBytes(UTF_8));
     }
   }
+
+  @SuppressWarnings("JdkObsolete")
+  @Nullable
+  private static String getRequestUrl(HttpServletRequest req) {
+    if (req.getRequestURL() == null) {
+      return null;
+    }
+    return req.getRequestURL().toString();
+  }
 }
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index aa32169..8e8a9d2 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -79,6 +79,7 @@
           "/dashboard/*",
           "/groups/self",
           "/settings/*",
+          "/topic/*",
           "/Documentation/q/*");
 
   /**
@@ -93,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 cfb5458..543e794 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -1714,7 +1714,7 @@
   private static List<IdString> splitPath(HttpServletRequest req) {
     String path = RequestUtil.getEncodedPathInfo(req);
     if (Strings.isNullOrEmpty(path)) {
-      return Collections.emptyList();
+      return new ArrayList<>();
     }
     List<IdString> out = new ArrayList<>();
     for (String p : Splitter.on('/').split(path)) {
diff --git a/java/com/google/gerrit/index/query/AndPredicate.java b/java/com/google/gerrit/index/query/AndPredicate.java
index 098bccb..23ae312 100644
--- a/java/com/google/gerrit/index/query/AndPredicate.java
+++ b/java/com/google/gerrit/index/query/AndPredicate.java
@@ -109,6 +109,8 @@
     return getChild(0).hashCode() * 31 + getChild(1).hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/index/query/DataSource.java b/java/com/google/gerrit/index/query/DataSource.java
index eb04099..3b83478 100644
--- a/java/com/google/gerrit/index/query/DataSource.java
+++ b/java/com/google/gerrit/index/query/DataSource.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.index.query;
 
 public interface DataSource<T> extends HasCardinality {
-  /** 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/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index 18d7fbc..b65fb96 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -35,7 +35,7 @@
    * complexity was reduced to the bare minimum at the cost of small discrepancies to the Unicode
    * spec.
    */
-  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-:\\/_\n"));
+  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-:\\/_=\n"));
 
   private final FieldDef<I, ?> def;
 
diff --git a/java/com/google/gerrit/index/query/IndexedQuery.java b/java/com/google/gerrit/index/query/IndexedQuery.java
index 4bc8043..cb98c06 100644
--- a/java/com/google/gerrit/index/query/IndexedQuery.java
+++ b/java/com/google/gerrit/index/query/IndexedQuery.java
@@ -114,6 +114,8 @@
     return pred.hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null || getClass() != other.getClass()) {
diff --git a/java/com/google/gerrit/index/query/IntPredicate.java b/java/com/google/gerrit/index/query/IntPredicate.java
index 16e59e7..a98e0b1 100644
--- a/java/com/google/gerrit/index/query/IntPredicate.java
+++ b/java/com/google/gerrit/index/query/IntPredicate.java
@@ -37,6 +37,8 @@
     return getOperator().hashCode() * 31 + intValue;
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/index/query/NotPredicate.java b/java/com/google/gerrit/index/query/NotPredicate.java
index 14cb740..fa8e01b 100644
--- a/java/com/google/gerrit/index/query/NotPredicate.java
+++ b/java/com/google/gerrit/index/query/NotPredicate.java
@@ -21,10 +21,10 @@
 import java.util.List;
 
 /** Negates the result of another predicate. */
-public class NotPredicate<T> extends Predicate<T> implements Matchable<T> {
+public final class NotPredicate<T> extends Predicate<T> implements Matchable<T> {
   private final Predicate<T> that;
 
-  protected NotPredicate(Predicate<T> that) {
+  NotPredicate(Predicate<T> that) {
     if (that instanceof NotPredicate) {
       throw new IllegalArgumentException("Double negation unsupported");
     }
@@ -87,7 +87,7 @@
     if (other == null) {
       return false;
     }
-    return getClass() == other.getClass()
+    return other instanceof NotPredicate
         && getChildren().equals(((Predicate<?>) other).getChildren());
   }
 
diff --git a/java/com/google/gerrit/index/query/OperatorPredicate.java b/java/com/google/gerrit/index/query/OperatorPredicate.java
index 368ee24..ea7717f 100644
--- a/java/com/google/gerrit/index/query/OperatorPredicate.java
+++ b/java/com/google/gerrit/index/query/OperatorPredicate.java
@@ -47,6 +47,8 @@
     return getOperator().hashCode() * 31 + getValue().hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/index/query/OrPredicate.java b/java/com/google/gerrit/index/query/OrPredicate.java
index 9bc3769..1c31af3 100644
--- a/java/com/google/gerrit/index/query/OrPredicate.java
+++ b/java/com/google/gerrit/index/query/OrPredicate.java
@@ -105,6 +105,8 @@
     return getChild(0).hashCode() * 31 + getChild(1).hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/index/query/Predicate.java b/java/com/google/gerrit/index/query/Predicate.java
index 9dc7689..e251b00 100644
--- a/java/com/google/gerrit/index/query/Predicate.java
+++ b/java/com/google/gerrit/index/query/Predicate.java
@@ -18,10 +18,10 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.collect.Iterables;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Queue;
 
@@ -152,7 +152,7 @@
   /** Returns a list of this predicate and all its descendants. */
   public List<Predicate<T>> getFlattenedPredicateList() {
     List<Predicate<T>> result = new ArrayList<>();
-    Queue<Predicate<T>> queue = new LinkedList<>();
+    Queue<Predicate<T>> queue = new ArrayDeque<>();
     queue.add(this);
     while (!queue.isEmpty()) {
       Predicate<T> current = queue.poll();
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/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index b7584c6..7a0f92c 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -272,7 +272,7 @@
                 // NOTE: This is consistent to the behaviour before the introduction of pagination.`
                 Ints.saturatedCast((long) limit + 1),
                 getRequestedFields());
-        logger.atFine().log("Query options: " + opts);
+        logger.atFine().log("Query options: %s", opts);
         Predicate<T> pred = rewriter.rewrite(q, opts);
         if (enforceVisibility) {
           pred = enforceVisibility(pred);
diff --git a/java/com/google/gerrit/index/query/TimestampRangePredicate.java b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
index 42f8aa8..29d6f22 100644
--- a/java/com/google/gerrit/index/query/TimestampRangePredicate.java
+++ b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
@@ -17,13 +17,13 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.json.JavaSqlTimestampHelper;
 import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 
 // TODO: Migrate this to IntegerRangePredicate
 public abstract class TimestampRangePredicate<I> extends IndexPredicate<I> {
-  protected static Timestamp parse(String value) throws QueryParseException {
+  protected static Instant parse(String value) throws QueryParseException {
     try {
-      return JavaSqlTimestampHelper.parseTimestamp(value);
+      return JavaSqlTimestampHelper.parseTimestamp(value).toInstant();
     } catch (IllegalArgumentException e) {
       // parseTimestamp's errors are specific and helpful, so preserve them.
       throw new QueryParseException(e.getMessage(), e);
@@ -38,7 +38,7 @@
     return (Timestamp) this.getField().get(object);
   }
 
-  public abstract Date getMinTimestamp();
+  public abstract Instant getMinTimestamp();
 
-  public abstract Date getMaxTimestamp();
+  public abstract Instant getMaxTimestamp();
 }
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index 9101ae5..692d524 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
@@ -48,7 +49,8 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
@@ -74,6 +76,7 @@
   private final String indexName;
   private final Map<K, D> indexedDocuments;
   private int queryCount;
+  private List<Integer> resultsSizes;
 
   AbstractFakeIndex(Schema<V> schema, SitePaths sitePaths, String indexName) {
     this.schema = schema;
@@ -81,6 +84,7 @@
     this.indexName = indexName;
     this.indexedDocuments = new HashMap<>();
     this.queryCount = 0;
+    this.resultsSizes = new ArrayList<Integer>();
   }
 
   @Override
@@ -118,6 +122,16 @@
     return queryCount;
   }
 
+  @VisibleForTesting
+  public void resetQueryCount() {
+    queryCount = 0;
+  }
+
+  @VisibleForTesting
+  public List<Integer> getResultsSizes() {
+    return resultsSizes;
+  }
+
   @Override
   public DataSource<V> getSource(Predicate<V> p, QueryOptions opts) {
     List<V> results;
@@ -145,8 +159,9 @@
                 .collect(toImmutableList());
       }
       queryCount++;
+      resultsSizes.add(results.size());
     }
-    return new DataSource<V>() {
+    return new DataSource<>() {
       @Override
       public int getCardinality() {
         return results.size();
@@ -225,7 +240,8 @@
     private final boolean skipMergable;
 
     @Inject
-    FakeChangeIndex(
+    @VisibleForTesting
+    protected FakeChangeIndex(
         SitePaths sitePaths,
         ChangeData.Factory changeDataFactory,
         @Assisted Schema<ChangeData> schema,
@@ -245,7 +261,7 @@
       Comparator<ChangeData> lastUpdated =
           Comparator.comparing(cd -> cd.change().getLastUpdatedOn());
       Comparator<ChangeData> merged =
-          Comparator.comparing(cd -> cd.getMergedOn().orElse(new Timestamp(0)));
+          Comparator.comparing(cd -> cd.getMergedOn().orElse(Instant.EPOCH));
       Comparator<ChangeData> id = Comparator.comparing(cd -> cd.getId().get());
       return lastUpdated.thenComparing(merged).thenComparing(id).reversed();
     }
diff --git a/java/com/google/gerrit/json/BUILD b/java/com/google/gerrit/json/BUILD
index d9cec45..7b2fe2f 100644
--- a/java/com/google/gerrit/json/BUILD
+++ b/java/com/google/gerrit/json/BUILD
@@ -5,6 +5,10 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/entities",
         "//lib:gson",
+        "//lib:guava",
+        "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/json/JavaSqlTimestampHelper.java b/java/com/google/gerrit/json/JavaSqlTimestampHelper.java
index b59cbd0d..35429f1 100644
--- a/java/com/google/gerrit/json/JavaSqlTimestampHelper.java
+++ b/java/com/google/gerrit/json/JavaSqlTimestampHelper.java
@@ -14,11 +14,19 @@
 
 package com.google.gerrit.json;
 
+import com.google.common.base.Splitter;
 import java.sql.Timestamp;
-import java.util.Date;
+import java.util.Calendar;
+import java.util.List;
+import java.util.TimeZone;
 
 /** Utility to parse Timestamp from a string. */
 public class JavaSqlTimestampHelper {
+
+  private static final Splitter TIMESTAMP_SPLITTER = Splitter.on(" ");
+  private static final Splitter DATE_SPLITTER = Splitter.on("-");
+  private static final Splitter TIME_SPLITTER = Splitter.on(":");
+
   /**
    * Parse a string into a timestamp.
    *
@@ -31,22 +39,22 @@
    * @return resulting timestamp.
    */
   public static Timestamp parseTimestamp(String s) {
-    String[] components = s.split(" ");
-    if (components.length < 1 || components.length > 3) {
+    List<String> components = TIMESTAMP_SPLITTER.splitToList(s);
+    if (components.size() < 1 || components.size() > 3) {
       throw new IllegalArgumentException("Expected date and optional time: " + s);
     }
-    String date = components[0];
-    String time = components.length >= 2 ? components[1] : null;
-    int off = components.length == 3 ? parseTimeZone(components[2]) : 0;
-    String[] dSplit = date.split("-");
-    if (dSplit.length != 3) {
+    String date = components.get(0);
+    String time = components.size() >= 2 ? components.get(1) : null;
+    int off = components.size() == 3 ? parseTimeZone(components.get(2)) : 0;
+    List<String> dSplit = DATE_SPLITTER.splitToList(date);
+    if (dSplit.size() != 3) {
       throw new IllegalArgumentException("Invalid date format: " + date);
     }
     int yy, mm, dd;
     try {
-      yy = Integer.parseInt(dSplit[0]) - 1900;
-      mm = Integer.parseInt(dSplit[1]) - 1;
-      dd = Integer.parseInt(dSplit[2]);
+      yy = Integer.parseInt(dSplit.get(0));
+      mm = Integer.parseInt(dSplit.get(1)) - 1;
+      dd = Integer.parseInt(dSplit.get(2));
     } catch (NumberFormatException e) {
       throw new IllegalArgumentException("Invalid date format: " + date, e);
     }
@@ -64,13 +72,13 @@
           t = time;
           f = 0;
         }
-        String[] tSplit = t.split(":");
-        if (tSplit.length != 3) {
+        List<String> tSplit = TIME_SPLITTER.splitToList(t);
+        if (tSplit.size() != 3) {
           throw new IllegalArgumentException("Invalid time format: " + time);
         }
-        hh = Integer.parseInt(tSplit[0]);
-        mi = Integer.parseInt(tSplit[1]);
-        ss = Integer.parseInt(tSplit[2]);
+        hh = Integer.parseInt(tSplit.get(0));
+        mi = Integer.parseInt(tSplit.get(1));
+        ss = Integer.parseInt(tSplit.get(2));
         ns = (int) Math.round(f * 1e9);
       } catch (NumberFormatException e) {
         throw new IllegalArgumentException("Invalid time format: " + time, e);
@@ -81,8 +89,9 @@
       ss = 0;
       ns = 0;
     }
-    @SuppressWarnings("deprecation")
-    Timestamp result = new Timestamp(Date.UTC(yy, mm, dd, hh, mi, ss) - off);
+    Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+    calendar.set(yy, mm, dd, hh, mi, ss);
+    Timestamp result = new Timestamp(calendar.toInstant().toEpochMilli() - off);
     result.setNanos(ns);
     return result;
   }
diff --git a/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java b/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
new file mode 100644
index 0000000..d35b8fb
--- /dev/null
+++ b/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
@@ -0,0 +1,150 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.json;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
+import com.google.gson.TypeAdapter;
+import com.google.gson.TypeAdapterFactory;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
+import com.google.inject.TypeLiteral;
+import java.io.IOException;
+import java.util.Optional;
+
+/**
+ * A {@code TypeAdapterFactory} for Optional {@code SubmitRequirementExpressionResult}.
+ *
+ * <p>{@link SubmitRequirementResult#submittabilityExpressionResult} was previously serialized as a
+ * mandatory field, but was later on migrated to an optional field. The server needs to handle
+ * deserializing of both formats.
+ */
+public class OptionalSubmitRequirementExpressionResultAdapterFactory implements TypeAdapterFactory {
+
+  private static final TypeToken<?> OPTIONAL_SR_EXPRESSION_RESULT_TOKEN =
+      TypeToken.get(new TypeLiteral<Optional<SubmitRequirementExpressionResult>>() {}.getType());
+
+  private static final TypeToken<?> SR_EXPRESSION_RESULT_TOKEN =
+      TypeToken.get(SubmitRequirementExpressionResult.class);
+
+  @SuppressWarnings({"unchecked"})
+  @Override
+  public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
+    if (typeToken.equals(OPTIONAL_SR_EXPRESSION_RESULT_TOKEN)) {
+      return (TypeAdapter<T>)
+          new OptionalSubmitRequirementExpressionResultTypeAdapter(
+              SubmitRequirementExpressionResult.typeAdapter(gson));
+    } else if (typeToken.equals(SR_EXPRESSION_RESULT_TOKEN)) {
+      return (TypeAdapter<T>)
+          new SubmitRequirementExpressionResultTypeAdapter(
+              SubmitRequirementExpressionResult.typeAdapter(gson));
+    }
+    return null;
+  }
+
+  /**
+   * Reads json representation of either {@code Optional<SubmitRequirementExpressionResult>} or
+   * {@code SubmitRequirementExpressionResult}, converting it to {@code Nullable} {@code
+   * SubmitRequirementExpressionResult}.
+   */
+  @Nullable
+  private static SubmitRequirementExpressionResult readOptionalOrMandatory(
+      TypeAdapter<SubmitRequirementExpressionResult> submitRequirementExpressionResultAdapter,
+      JsonReader in) {
+    JsonElement parsed = JsonParser.parseReader(in);
+    if (parsed == null) {
+      return null;
+    }
+    // If it does not have 'value' field, then it was serialized as
+    // SubmitRequirementExpressionResult directly
+    if (parsed.getAsJsonObject().has("value")) {
+      parsed = parsed.getAsJsonObject().get("value");
+    }
+    if (parsed == null || parsed.isJsonNull() || parsed.getAsJsonObject().entrySet().isEmpty()) {
+      return null;
+    }
+    return submitRequirementExpressionResultAdapter.fromJsonTree(parsed);
+  }
+
+  /**
+   * A {@code TypeAdapter} that provides backward compatibility for reading previously non-optional
+   * {@code SubmitRequirementExpressionResult} field.
+   */
+  private static class OptionalSubmitRequirementExpressionResultTypeAdapter
+      extends TypeAdapter<Optional<SubmitRequirementExpressionResult>> {
+
+    private final TypeAdapter<SubmitRequirementExpressionResult>
+        submitRequirementExpressionResultAdapter;
+
+    public OptionalSubmitRequirementExpressionResultTypeAdapter(
+        TypeAdapter<SubmitRequirementExpressionResult> submitRequirementResultAdapter) {
+      this.submitRequirementExpressionResultAdapter = submitRequirementResultAdapter;
+    }
+
+    @Override
+    public Optional<SubmitRequirementExpressionResult> read(JsonReader in) throws IOException {
+      return Optional.ofNullable(
+          readOptionalOrMandatory(submitRequirementExpressionResultAdapter, in));
+    }
+
+    @Override
+    public void write(JsonWriter out, Optional<SubmitRequirementExpressionResult> value)
+        throws IOException {
+      // Serialize the field using the same format used by the AutoValue's default Gson serializer.
+      out.beginObject();
+      out.name("value");
+      if (value.isPresent()) {
+        out.jsonValue(submitRequirementExpressionResultAdapter.toJson(value.get()));
+      } else {
+        out.nullValue();
+      }
+      out.endObject();
+    }
+  }
+
+  /**
+   * A {@code TypeAdapter} that provides forward compatibility for reading the optional {@code
+   * SubmitRequirementExpressionResult} field.
+   *
+   * <p>TODO(mariasavtchouk): Remove once updated to read the new format only.
+   */
+  private static class SubmitRequirementExpressionResultTypeAdapter
+      extends TypeAdapter<SubmitRequirementExpressionResult> {
+
+    private final TypeAdapter<SubmitRequirementExpressionResult>
+        submitRequirementExpressionResultAdapter;
+
+    public SubmitRequirementExpressionResultTypeAdapter(
+        TypeAdapter<SubmitRequirementExpressionResult> submitRequirementResultAdapter) {
+      this.submitRequirementExpressionResultAdapter = submitRequirementResultAdapter;
+    }
+
+    @Override
+    public SubmitRequirementExpressionResult read(JsonReader in) throws IOException {
+      return readOptionalOrMandatory(submitRequirementExpressionResultAdapter, in);
+    }
+
+    @Override
+    public void write(JsonWriter out, SubmitRequirementExpressionResult value) throws IOException {
+      // Serialize the field using the same format used by the AutoValue's default Gson serializer.
+      out.jsonValue(submitRequirementExpressionResultAdapter.toJson(value));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/json/OptionalTypeAdapter.java b/java/com/google/gerrit/json/OptionalTypeAdapter.java
new file mode 100644
index 0000000..9bfa72d
--- /dev/null
+++ b/java/com/google/gerrit/json/OptionalTypeAdapter.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.json;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.Optional;
+
+public class OptionalTypeAdapter
+    implements JsonSerializer<Optional<?>>, JsonDeserializer<Optional<?>> {
+
+  private static final String VALUE = "value";
+
+  @Override
+  public JsonElement serialize(Optional<?> src, Type typeOfSrc, JsonSerializationContext context) {
+    Optional<?> optional = src == null ? Optional.empty() : src;
+    JsonObject json = new JsonObject();
+    json.add(VALUE, optional.map(context::serialize).orElse(JsonNull.INSTANCE));
+    return json;
+  }
+
+  @Override
+  public Optional<?> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
+    if (!json.getAsJsonObject().has(VALUE)) {
+      return Optional.empty();
+    }
+
+    JsonElement value = json.getAsJsonObject().get(VALUE);
+    if (value == null || value.isJsonNull()) {
+      return Optional.empty();
+    }
+
+    // handle the situation when one uses Optional without type parameter which is an equivalent of
+    // <?> type
+    ParameterizedType parameterizedType =
+        (ParameterizedType) new TypeLiteral<Optional<?>>() {}.getType();
+    if (typeOfT instanceof ParameterizedType) {
+      parameterizedType = (ParameterizedType) typeOfT;
+      if (parameterizedType.getActualTypeArguments().length != 1) {
+        throw new JsonParseException("Expected one parameter type in Optional.");
+      }
+    }
+
+    Type optionalOf = parameterizedType.getActualTypeArguments()[0];
+    return Optional.of(context.deserialize(value, optionalOf));
+  }
+}
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index 1999270..a190ebf 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -41,12 +41,12 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.Enumeration;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.NavigableMap;
 import java.util.Properties;
-import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.jar.Attributes;
 import java.util.jar.JarFile;
@@ -266,11 +266,11 @@
       throw e;
     }
 
-    final SortedMap<String, URL> jars = new TreeMap<>();
+    final NavigableMap<String, URL> jars = new TreeMap<>();
     try (ZipFile zf = new ZipFile(path)) {
-      final Enumeration<? extends ZipEntry> e = zf.entries();
-      while (e.hasMoreElements()) {
-        final ZipEntry ze = e.nextElement();
+      Iterator<? extends ZipEntry> zipEntryIt = zf.stream().iterator();
+      while (zipEntryIt.hasNext()) {
+        final ZipEntry ze = zipEntryIt.next();
         if (ze.isDirectory()) {
           continue;
         }
@@ -310,7 +310,7 @@
     return URLClassLoader.newInstance(jars.values().toArray(new URL[jars.size()]), parent);
   }
 
-  private static void extractJar(ZipFile zf, ZipEntry ze, SortedMap<String, URL> jars)
+  private static void extractJar(ZipFile zf, ZipEntry ze, NavigableMap<String, URL> jars)
       throws IOException {
     File tmp = createTempFile(safeName(ze), ".jar");
     try (OutputStream out = Files.newOutputStream(tmp.toPath());
@@ -326,8 +326,8 @@
     jars.put(name.substring(name.lastIndexOf('/')), tmp.toURI().toURL());
   }
 
-  private static void move(SortedMap<String, URL> jars, String prefix, List<URL> extapi) {
-    SortedMap<String, URL> matches = jars.tailMap(prefix);
+  private static void move(NavigableMap<String, URL> jars, String prefix, List<URL> extapi) {
+    NavigableMap<String, URL> matches = jars.tailMap(prefix, /* inclusive= */ true);
     if (!matches.isEmpty()) {
       String first = matches.firstKey();
       if (first.startsWith(prefix)) {
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 08e9357..ca81966 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -111,6 +111,7 @@
   private final AutoFlush autoFlush;
   private ScheduledExecutorService autoCommitExecutor;
 
+  @SuppressWarnings("ThreadPriorityCheck")
   AbstractLuceneIndex(
       Schema<V> schema,
       SitePaths sitePaths,
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 07d1334..59456f6 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -399,7 +399,7 @@
       }
       ImmutableList<FieldBundle> fieldBundles =
           documents.stream().map(rawDocumentMapper).collect(toImmutableList());
-      return new ResultSet<FieldBundle>() {
+      return new ResultSet<>() {
         @Override
         public Iterator<FieldBundle> iterator() {
           return fieldBundles.iterator();
diff --git a/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
index 7d82bf5..e1b56c6 100644
--- a/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -33,7 +33,6 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.RegexPredicate;
 import com.google.gerrit.index.query.TimestampRangePredicate;
-import java.util.Date;
 import java.util.List;
 import org.apache.lucene.analysis.Analyzer;
 import org.apache.lucene.document.IntPoint;
@@ -232,15 +231,17 @@
     if (p instanceof TimestampRangePredicate) {
       TimestampRangePredicate<V> r = (TimestampRangePredicate<V>) p;
       return longRangeQuery.get(
-          r.getField().getName(), r.getMinTimestamp().getTime(), r.getMaxTimestamp().getTime());
+          r.getField().getName(),
+          r.getMinTimestamp().toEpochMilli(),
+          r.getMaxTimestamp().toEpochMilli());
     }
     throw new QueryParseException("not a timestamp: " + p);
   }
 
   private Query notTimestamp(TimestampRangePredicate<V> r) throws QueryParseException {
-    if (r.getMinTimestamp().getTime() == 0) {
+    if (r.getMinTimestamp().toEpochMilli() == 0) {
       return longRangeQuery.get(
-          r.getField().getName(), r.getMaxTimestamp().getTime(), Long.MAX_VALUE);
+          r.getField().getName(), r.getMaxTimestamp().toEpochMilli(), Long.MAX_VALUE);
     }
     throw new QueryParseException("cannot negate: " + r);
   }
@@ -279,10 +280,6 @@
     return query;
   }
 
-  public int toIndexTimeInMinutes(Date ts) {
-    return (int) (ts.getTime() / 60000);
-  }
-
   public Schema<V> getSchema() {
     return schema;
   }
diff --git a/java/com/google/gerrit/mail/BUILD b/java/com/google/gerrit/mail/BUILD
index 59d8227..0fe6c43 100644
--- a/java/com/google/gerrit/mail/BUILD
+++ b/java/com/google/gerrit/mail/BUILD
@@ -12,7 +12,6 @@
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
         "//lib/jsoup",
-        "//lib/log:log4j",
         "//lib/mime4j:core",
         "//lib/mime4j:dom",
     ],
diff --git a/java/com/google/gerrit/mail/RawMailParser.java b/java/com/google/gerrit/mail/RawMailParser.java
index 213cc3f..929e9f9 100644
--- a/java/com/google/gerrit/mail/RawMailParser.java
+++ b/java/com/google/gerrit/mail/RawMailParser.java
@@ -25,6 +25,7 @@
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStreamReader;
+import java.time.Instant;
 import org.apache.james.mime4j.MimeException;
 import org.apache.james.mime4j.dom.Entity;
 import org.apache.james.mime4j.dom.Message;
@@ -66,7 +67,9 @@
       messageBuilder.subject(mimeMessage.getSubject());
     }
     if (mimeMessage.getDate() != null) {
-      messageBuilder.dateReceived(mimeMessage.getDate().toInstant());
+      @SuppressWarnings("JdkObsolete")
+      Instant mimeMessageInstant = mimeMessage.getDate().toInstant();
+      messageBuilder.dateReceived(mimeMessageInstant);
     }
 
     // Add From, To and Cc
diff --git a/java/com/google/gerrit/metrics/DisabledMetricMaker.java b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
index 1fb8c57..234378b 100644
--- a/java/com/google/gerrit/metrics/DisabledMetricMaker.java
+++ b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
@@ -33,7 +33,7 @@
 
   @Override
   public <F1> Counter1<F1> newCounter(String name, Description desc, Field<F1> field1) {
-    return new Counter1<F1>() {
+    return new Counter1<>() {
       @Override
       public void incrementBy(F1 field1, long value) {}
 
@@ -45,7 +45,7 @@
   @Override
   public <F1, F2> Counter2<F1, F2> newCounter(
       String name, Description desc, Field<F1> field1, Field<F2> field2) {
-    return new Counter2<F1, F2>() {
+    return new Counter2<>() {
       @Override
       public void incrementBy(F1 field1, F2 field2, long value) {}
 
@@ -57,7 +57,7 @@
   @Override
   public <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
       String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    return new Counter3<F1, F2, F3>() {
+    return new Counter3<>() {
       @Override
       public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {}
 
@@ -79,7 +79,7 @@
 
   @Override
   public <F1> Timer1<F1> newTimer(String name, Description desc, Field<F1> field1) {
-    return new Timer1<F1>(name, field1) {
+    return new Timer1<>(name, field1) {
       @Override
       protected void doRecord(F1 field1, long value, TimeUnit unit) {}
 
@@ -91,7 +91,7 @@
   @Override
   public <F1, F2> Timer2<F1, F2> newTimer(
       String name, Description desc, Field<F1> field1, Field<F2> field2) {
-    return new Timer2<F1, F2>(name, field1, field2) {
+    return new Timer2<>(name, field1, field2) {
       @Override
       protected void doRecord(F1 field1, F2 field2, long value, TimeUnit unit) {}
 
@@ -103,7 +103,7 @@
   @Override
   public <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
       String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    return new Timer3<F1, F2, F3>(name, field1, field2, field3) {
+    return new Timer3<>(name, field1, field2, field3) {
       @Override
       protected void doRecord(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {}
 
@@ -125,7 +125,7 @@
 
   @Override
   public <F1> Histogram1<F1> newHistogram(String name, Description desc, Field<F1> field1) {
-    return new Histogram1<F1>() {
+    return new Histogram1<>() {
       @Override
       public void record(F1 field1, long value) {}
 
@@ -137,7 +137,7 @@
   @Override
   public <F1, F2> Histogram2<F1, F2> newHistogram(
       String name, Description desc, Field<F1> field1, Field<F2> field2) {
-    return new Histogram2<F1, F2>() {
+    return new Histogram2<>() {
       @Override
       public void record(F1 field1, F2 field2, long value) {}
 
@@ -149,7 +149,7 @@
   @Override
   public <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(
       String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    return new Histogram3<F1, F2, F3>() {
+    return new Histogram3<>() {
       @Override
       public void record(F1 field1, F2 field2, F3 field3, long value) {}
 
@@ -161,7 +161,7 @@
   @Override
   public <V> CallbackMetric0<V> newCallbackMetric(
       String name, Class<V> valueClass, Description desc) {
-    return new CallbackMetric0<V>() {
+    return new CallbackMetric0<>() {
       @Override
       public void set(V value) {}
 
@@ -173,7 +173,7 @@
   @Override
   public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(
       String name, Class<V> valueClass, Description desc, Field<F1> field1) {
-    return new CallbackMetric1<F1, V>() {
+    return new CallbackMetric1<>() {
       @Override
       public void set(F1 field1, V value) {}
 
diff --git a/java/com/google/gerrit/metrics/Field.java b/java/com/google/gerrit/metrics/Field.java
index 5508819..01b89cf 100644
--- a/java/com/google/gerrit/metrics/Field.java
+++ b/java/com/google/gerrit/metrics/Field.java
@@ -21,6 +21,8 @@
 import java.util.Optional;
 import java.util.function.BiConsumer;
 import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /**
  * Describes a bucketing field used by a metric.
@@ -102,6 +104,21 @@
         .metadataMapper(metadataMapper);
   }
 
+  /**
+   * A dedicated field to be used with metrics based on {@link Metadata#projectName()}. It was
+   * introduced to sanitize the project name to avoid sub-metric name's collision.
+   *
+   * @param fieldName name of the field that contains a project name as value
+   * @return builder for the project name field
+   */
+  public static Field.Builder<String> ofProjectName(String fieldName) {
+    return new AutoValue_Field.Builder<String>()
+        .valueType(String.class)
+        .formatter(Field::sanitizeProjectName)
+        .name(fieldName)
+        .metadataMapper(Metadata.Builder::projectName);
+  }
+
   /** Returns name of this field within the metric. */
   public abstract String name();
 
@@ -137,4 +154,33 @@
       return field;
     }
   }
+
+  private static final Pattern SUBMETRIC_NAME_PATTERN =
+      Pattern.compile("[a-zA-Z0-9_-]+([a-zA-Z0-9_-]+)*");
+  private static final Pattern INVALID_CHAR_PATTERN = Pattern.compile("[^\\w-]");
+  private static final String REPLACEMENT_PREFIX = "_0x";
+
+  private static String sanitizeProjectName(String projectName) {
+    if (SUBMETRIC_NAME_PATTERN.matcher(projectName).matches()
+        && !projectName.contains(REPLACEMENT_PREFIX)) {
+      return projectName;
+    }
+
+    String replacmentPrefixSanitizedName =
+        projectName.replaceAll(REPLACEMENT_PREFIX, REPLACEMENT_PREFIX + REPLACEMENT_PREFIX);
+    StringBuilder sanitizedName = new StringBuilder();
+    for (int i = 0; i < replacmentPrefixSanitizedName.length(); i++) {
+      Character c = replacmentPrefixSanitizedName.charAt(i);
+      Matcher matcher = INVALID_CHAR_PATTERN.matcher(c.toString());
+      if (matcher.matches()) {
+        sanitizedName.append(REPLACEMENT_PREFIX);
+        sanitizedName.append(Integer.toHexString(c).toUpperCase());
+        sanitizedName.append('_');
+      } else {
+        sanitizedName.append(c);
+      }
+    }
+
+    return sanitizedName.toString();
+  }
 }
diff --git a/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java b/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
index 0e554a8..92aeb4c 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
@@ -26,7 +26,7 @@
   }
 
   Counter1<F1> counter() {
-    return new Counter1<F1>() {
+    return new Counter1<>() {
       @Override
       public void incrementBy(F1 field1, long value) {
         total.incrementBy(value);
diff --git a/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java b/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
index 07afc2a..e9199d9 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
@@ -29,7 +29,7 @@
   }
 
   <F1, F2> Counter2<F1, F2> counter2() {
-    return new Counter2<F1, F2>() {
+    return new Counter2<>() {
       @Override
       public void incrementBy(F1 field1, F2 field2, long value) {
         total.incrementBy(value);
@@ -44,7 +44,7 @@
   }
 
   <F1, F2, F3> Counter3<F1, F2, F3> counter3() {
-    return new Counter3<F1, F2, F3>() {
+    return new Counter3<>() {
       @Override
       public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {
         total.incrementBy(value);
diff --git a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
index 32be18d..2956a3e 100644
--- a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
+++ b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
@@ -60,6 +60,7 @@
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 /**
@@ -357,6 +358,8 @@
 
   private static final Pattern METRIC_NAME_PATTERN =
       Pattern.compile("[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*");
+  private static final Pattern INVALID_CHAR_PATTERN = Pattern.compile("[^\\w-/]");
+  private static final String REPLACEMENT_PREFIX = "_0x";
 
   private static void checkMetricName(String name) {
     checkArgument(
@@ -366,24 +369,55 @@
         METRIC_NAME_PATTERN.pattern());
   }
 
+  /**
+   * Ensures that the sanitized metric name doesn't contain invalid characters and removes the risk
+   * of collision (between the sanitized metric names). Modifications to the input metric name:
+   *
+   * <ul>
+   *   <li/>leading <code>/</code> is replaced with <code>_</code>
+   *   <li/>doubled (or repeated more times) <code>/</code> are reduced to a single <code>/</code>
+   *   <li/>ending <code>/</code> is removed
+   *   <li/>all characters that are not <code>/a-zA-Z0-9_-</code> are replaced with <code>
+   *       _0x[HEX CODE]_</code> (code is capitalized)
+   *   <li/>the replacement prefix <code>_0x</code> is prepended with another replacement prefix
+   * </ul>
+   *
+   * @param name name of the metric to sanitize
+   * @return sanitized metric name
+   */
   @Override
   public String sanitizeMetricName(String name) {
-    if (METRIC_NAME_PATTERN.matcher(name).matches()) {
+    if (METRIC_NAME_PATTERN.matcher(name).matches() && !name.contains(REPLACEMENT_PREFIX)) {
       return name;
     }
 
-    String first = name.substring(0, 1).replaceFirst("[^\\w-]", "_");
+    StringBuilder sanitizedName =
+        new StringBuilder(name.substring(0, 1).replaceFirst("[^\\w-]", "_"));
     if (name.length() == 1) {
-      return first;
+      return sanitizedName.toString();
     }
 
-    String result = first + name.substring(1).replaceAll("/[/]+", "/").replaceAll("[^\\w-/]", "_");
-
-    if (result.endsWith("/")) {
-      result = result.substring(0, result.length() - 1);
+    String slashSanitizedName = name.substring(1).replaceAll("/[/]+", "/");
+    if (slashSanitizedName.endsWith("/")) {
+      slashSanitizedName = slashSanitizedName.substring(0, slashSanitizedName.length() - 1);
     }
 
-    return result;
+    String replacementPrefixSanitizedName =
+        slashSanitizedName.replaceAll(REPLACEMENT_PREFIX, REPLACEMENT_PREFIX + REPLACEMENT_PREFIX);
+
+    for (int i = 0; i < replacementPrefixSanitizedName.length(); i++) {
+      Character c = replacementPrefixSanitizedName.charAt(i);
+      Matcher matcher = INVALID_CHAR_PATTERN.matcher(c.toString());
+      if (matcher.matches()) {
+        sanitizedName.append(REPLACEMENT_PREFIX);
+        sanitizedName.append(Integer.toHexString(c).toUpperCase());
+        sanitizedName.append('_');
+      } else {
+        sanitizedName.append(c);
+      }
+    }
+
+    return sanitizedName.toString();
   }
 
   static String name(Description.FieldOrdering ordering, String codeName, String fieldValues) {
diff --git a/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
index 4578db1..91e36b9 100644
--- a/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
@@ -26,7 +26,7 @@
   }
 
   Histogram1<F1> histogram1() {
-    return new Histogram1<F1>() {
+    return new Histogram1<>() {
       @Override
       public void record(F1 field1, long value) {
         total.record(value);
diff --git a/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
index 446590c..2caa4c5 100644
--- a/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
@@ -29,7 +29,7 @@
   }
 
   <F1, F2> Histogram2<F1, F2> histogram2() {
-    return new Histogram2<F1, F2>() {
+    return new Histogram2<>() {
       @Override
       public void record(F1 field1, F2 field2, long value) {
         total.record(value);
@@ -44,7 +44,7 @@
   }
 
   <F1, F2, F3> Histogram3<F1, F2, F3> histogram3() {
-    return new Histogram3<F1, F2, F3>() {
+    return new Histogram3<>() {
       @Override
       public void record(F1 field1, F2 field2, F3 field3, long value) {
         total.record(value);
diff --git a/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java b/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
index 7e472c9..6b17456 100644
--- a/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
+++ b/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
@@ -26,7 +26,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.SortedMap;
+import java.util.NavigableMap;
 import java.util.TreeMap;
 import org.kohsuke.args4j.Option;
 
@@ -55,7 +55,7 @@
       throws AuthException, PermissionBackendException {
     permissionBackend.currentUser().check(GlobalPermission.VIEW_CACHES);
 
-    SortedMap<String, MetricJson> out = new TreeMap<>();
+    NavigableMap<String, MetricJson> out = new TreeMap<>();
     List<String> prefixes = new ArrayList<>(query.size());
     for (String q : query) {
       if (q.endsWith("/")) {
diff --git a/java/com/google/gerrit/metrics/dropwizard/MetricResource.java b/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
index 226edc7..8a6db67 100644
--- a/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
+++ b/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
@@ -20,8 +20,7 @@
 import com.google.inject.TypeLiteral;
 
 class MetricResource extends ConfigResource {
-  static final TypeLiteral<RestView<MetricResource>> METRIC_KIND =
-      new TypeLiteral<RestView<MetricResource>>() {};
+  static final TypeLiteral<RestView<MetricResource>> METRIC_KIND = new TypeLiteral<>() {};
 
   private final String name;
   private final Metric metric;
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
index b7d535b..36b52e1 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
@@ -28,7 +28,7 @@
 
   @SuppressWarnings("unchecked")
   Timer1<F1> timer() {
-    return new Timer1<F1>(name, (Field<F1>) fields[0]) {
+    return new Timer1<>(name, (Field<F1>) fields[0]) {
       @Override
       protected void doRecord(F1 field1, long value, TimeUnit unit) {
         total.record(value, unit);
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
index dee800e..77ce8cd 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
@@ -31,7 +31,7 @@
 
   @SuppressWarnings("unchecked")
   <F1, F2> Timer2<F1, F2> timer2() {
-    return new Timer2<F1, F2>(name, (Field<F1>) fields[0], (Field<F2>) fields[1]) {
+    return new Timer2<>(name, (Field<F1>) fields[0], (Field<F2>) fields[1]) {
       @Override
       protected void doRecord(F1 field1, F2 field2, long value, TimeUnit unit) {
         total.record(value, unit);
@@ -47,8 +47,7 @@
 
   @SuppressWarnings("unchecked")
   <F1, F2, F3> Timer3<F1, F2, F3> timer3() {
-    return new Timer3<F1, F2, F3>(
-        name, (Field<F1>) fields[0], (Field<F2>) fields[1], (Field<F3>) fields[2]) {
+    return new Timer3<>(name, (Field<F1>) fields[0], (Field<F2>) fields[1], (Field<F3>) fields[2]) {
       @Override
       protected void doRecord(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {
         total.record(value, unit);
diff --git a/java/com/google/gerrit/metrics/proc/JGitMetricModule.java b/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
index d64bd19..8771448 100644
--- a/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
+++ b/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.server.logging.Metadata;
 import java.util.Map;
 import org.eclipse.jgit.storage.file.WindowCacheStats;
 
@@ -184,7 +183,7 @@
                         + "having most data in the cache.")
                 .setGauge()
                 .setUnit("byte"),
-            Field.ofString("repository_name", Metadata.Builder::projectName)
+            Field.ofProjectName("repository_name")
                 .description("The name of the repository.")
                 .build());
     metrics.newTrigger(
diff --git a/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
index 09e40c1..301ec85 100644
--- a/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
+++ b/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
@@ -34,6 +34,8 @@
 import java.util.concurrent.TimeUnit;
 
 public class ProcMetricModule extends MetricModule {
+  private static final ThreadMXBeanInterface threadMxBean = ThreadMXBeanFactory.create();
+
   @Override
   protected void configure(MetricMaker metrics) {
     buildLabel(metrics);
@@ -167,6 +169,14 @@
 
           objectPendingFinalizationCount.set(memory.getObjectPendingFinalizationCount());
         });
+
+    if (threadMxBean.supportsAllocatedBytes()) {
+      metrics.newCallbackMetric(
+          "proc/jvm/memory/allocated",
+          Long.class,
+          new Description("Allocated memory").setCumulative().setUnit(Units.BYTES),
+          () -> getTotalAllocatedBytes());
+    }
   }
 
   private void procJvmMemoryPool(MetricMaker metrics) {
@@ -312,4 +322,19 @@
           });
     }
   }
+
+  private static long getTotalAllocatedBytes() {
+    if (!threadMxBean.supportsAllocatedBytes()) {
+      return -1;
+    }
+
+    long[] ids = threadMxBean.getAllThreadIds();
+    long[] allocatedBytes = threadMxBean.getAllThreadsAllocatedBytes(ids);
+    long total = 0;
+
+    for (long a : allocatedBytes) {
+      total += a;
+    }
+    return total;
+  }
 }
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java
index 546924f..03e59a0 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanInterface.java
@@ -19,4 +19,12 @@
   long getCurrentThreadUserTime();
 
   long getCurrentThreadAllocatedBytes();
+
+  boolean supportsAllocatedBytes();
+
+  long getThreadAllocatedBytes(long threadId);
+
+  long[] getAllThreadsAllocatedBytes(long[] threadIds);
+
+  long[] getAllThreadIds();
 }
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.java
index 29dd42a..361aba0 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanJava.java
@@ -37,4 +37,24 @@
   public long getCurrentThreadAllocatedBytes() {
     return -1;
   }
+
+  @Override
+  public boolean supportsAllocatedBytes() {
+    return false;
+  }
+
+  @Override
+  public long getThreadAllocatedBytes(long threadId) {
+    return -1;
+  }
+
+  @Override
+  public long[] getAllThreadsAllocatedBytes(long[] threadIds) {
+    return new long[0];
+  }
+
+  @Override
+  public long[] getAllThreadIds() {
+    return sys.getAllThreadIds();
+  }
 }
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
index 9e43a05..fe52618 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
@@ -37,6 +37,26 @@
   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 getThreadAllocatedBytes(Thread.currentThread().getId());
+  }
+
+  @Override
+  public boolean supportsAllocatedBytes() {
+    return true;
+  }
+
+  @Override
+  public long getThreadAllocatedBytes(long threadId) {
+    return sys.getThreadAllocatedBytes(threadId);
+  }
+
+  @Override
+  public long[] getAllThreadsAllocatedBytes(long[] threadIds) {
+    return sys.getThreadAllocatedBytes(threadIds);
+  }
+
+  @Override
+  public long[] getAllThreadIds() {
+    return sys.getAllThreadIds();
   }
 }
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index 3a1de5e..df64bc7 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -48,12 +48,11 @@
         "//lib:servlet-api-without-neverlink",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
-        "//lib/log:log4j",
         "//lib/prolog:cafeteria",
         "//lib/prolog:compiler",
         "//lib/prolog:runtime",
diff --git a/java/com/google/gerrit/pgm/CopyApprovals.java b/java/com/google/gerrit/pgm/CopyApprovals.java
deleted file mode 100644
index f62d60c..0000000
--- a/java/com/google/gerrit/pgm/CopyApprovals.java
+++ /dev/null
@@ -1,138 +0,0 @@
-// 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.pgm;
-
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.index.IndexType;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.lucene.LuceneIndexModule;
-import com.google.gerrit.pgm.util.BatchProgramModule;
-import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.server.LibModuleLoader;
-import com.google.gerrit.server.ModuleOverloader;
-import com.google.gerrit.server.approval.RecursiveApprovalCopier;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.options.AutoFlush;
-import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
-import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
-import com.google.gerrit.server.util.ReplicaUtil;
-import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import com.google.inject.Module;
-import com.google.inject.multibindings.OptionalBinder;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.lib.Config;
-
-public class CopyApprovals extends SiteProgram {
-
-  private Injector dbInjector;
-  private Injector sysInjector;
-  private Injector cfgInjector;
-  private Config globalConfig;
-
-  @Inject private RecursiveApprovalCopier recursiveApprovalCopier;
-
-  @Override
-  public int run() throws Exception {
-    mustHaveValidSite();
-    dbInjector = createDbInjector();
-    cfgInjector = dbInjector.createChildInjector();
-    globalConfig = dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    LifecycleManager dbManager = new LifecycleManager();
-    dbManager.add(dbInjector);
-    dbManager.start();
-
-    sysInjector = createSysInjector();
-    sysInjector.getInstance(PluginGuiceEnvironment.class).setDbCfgInjector(dbInjector, cfgInjector);
-    LifecycleManager sysManager = new LifecycleManager();
-    sysManager.add(sysInjector);
-    sysManager.start();
-
-    sysInjector.injectMembers(this);
-
-    try {
-      recursiveApprovalCopier.persistStandalone();
-      return 0;
-    } catch (Exception e) {
-      throw die(e.getMessage(), e);
-    } finally {
-      sysManager.stop();
-      dbManager.stop();
-    }
-  }
-
-  private Injector createSysInjector() {
-    Map<String, Integer> versions = new HashMap<>();
-    boolean replica = ReplicaUtil.isReplica(globalConfig);
-    List<Module> modules = new ArrayList<>();
-    Module indexModule;
-    IndexType indexType = IndexModule.getIndexType(dbInjector);
-    if (indexType.isLucene()) {
-      indexModule =
-          LuceneIndexModule.singleVersionWithExplicitVersions(
-              versions, 1, replica, AutoFlush.DISABLED);
-    } else if (indexType.isFake()) {
-      // Use Reflection so that we can omit the fake index binary in production code. Test code does
-      // compile the component in.
-      try {
-        Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModule");
-        Method m =
-            clazz.getMethod(
-                "singleVersionWithExplicitVersions", Map.class, int.class, boolean.class);
-        indexModule = (Module) m.invoke(null, versions, 1, replica);
-      } catch (NoSuchMethodException
-          | ClassNotFoundException
-          | IllegalAccessException
-          | InvocationTargetException e) {
-        throw new IllegalStateException("can't create index", e);
-      }
-    } else {
-      throw new IllegalStateException("unsupported index.type = " + indexType);
-    }
-    modules.add(indexModule);
-    modules.add(
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            super.configure();
-            OptionalBinder.newOptionalBinder(binder(), IsFirstInsertForEntry.class)
-                .setBinding()
-                .toInstance(IsFirstInsertForEntry.YES);
-          }
-        });
-
-    modules.add(
-        new FactoryModule() {
-          @Override
-          protected void configure() {
-            factory(ChangeResource.Factory.class);
-          }
-        });
-    modules.add(new BatchProgramModule(dbInjector));
-
-    return dbInjector.createChildInjector(
-        ModuleOverloader.override(
-            modules, LibModuleLoader.loadReindexModules(cfgInjector, versions, 1, replica)));
-  }
-}
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 3b31f4b..2f28db5 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -501,7 +501,7 @@
           });
     }
     modules.add(new DefaultUrlFormatterModule());
-    SshSessionFactoryInitializer.init(config);
+    SshSessionFactoryInitializer.init();
     if (sshd) {
       modules.add(SshKeyCacheImpl.module());
     } else {
diff --git a/java/com/google/gerrit/pgm/JythonShell.java b/java/com/google/gerrit/pgm/JythonShell.java
index 88f7b5d..d85bdc0 100644
--- a/java/com/google/gerrit/pgm/JythonShell.java
+++ b/java/com/google/gerrit/pgm/JythonShell.java
@@ -173,7 +173,7 @@
         logger.atSevere().log("Cannot load resource %s", p);
       }
     } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage());
+      logger.atSevere().withCause(e).log("%s", e.getMessage());
     }
   }
 
diff --git a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index f651994..b7ff1f7 100644
--- a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -38,7 +38,7 @@
 import java.util.Collection;
 import java.util.Locale;
 import java.util.Optional;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.Repository;
diff --git a/java/com/google/gerrit/pgm/Ls.java b/java/com/google/gerrit/pgm/Ls.java
index 4211c17..4b48148 100644
--- a/java/com/google/gerrit/pgm/Ls.java
+++ b/java/com/google/gerrit/pgm/Ls.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.pgm;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.pgm.util.AbstractProgram;
 import java.io.IOException;
-import java.util.Enumeration;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
 
@@ -26,9 +27,7 @@
   @Override
   public int run() throws IOException {
     try (ZipFile zf = new ZipFile(GerritLauncher.getDistributionArchive())) {
-      final Enumeration<? extends ZipEntry> e = zf.entries();
-      while (e.hasMoreElements()) {
-        final ZipEntry ze = e.nextElement();
+      for (ZipEntry ze : entriesOf(zf)) {
         String name = ze.getName();
         boolean show = false;
         show |= name.startsWith("WEB-INF/");
@@ -49,4 +48,8 @@
     }
     return 0;
   }
+
+  private static Iterable<? extends ZipEntry> entriesOf(ZipFile zipFile) {
+    return zipFile.stream().collect(toImmutableList());
+  }
 }
diff --git a/java/com/google/gerrit/pgm/SwitchSecureStore.java b/java/com/google/gerrit/pgm/SwitchSecureStore.java
index 733c9d1..824a9a7 100644
--- a/java/com/google/gerrit/pgm/SwitchSecureStore.java
+++ b/java/com/google/gerrit/pgm/SwitchSecureStore.java
@@ -196,7 +196,7 @@
           return jar;
         }
       } catch (IOException e) {
-        logger.atSevere().withCause(e).log(e.getMessage());
+        logger.atSevere().withCause(e).log("%s", e.getMessage());
       }
     }
     return null;
diff --git a/java/com/google/gerrit/pgm/WarDistribution.java b/java/com/google/gerrit/pgm/WarDistribution.java
index 257fb4e..013c850 100644
--- a/java/com/google/gerrit/pgm/WarDistribution.java
+++ b/java/com/google/gerrit/pgm/WarDistribution.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.pgm.init.InitPlugins.JAR;
 import static com.google.gerrit.pgm.init.InitPlugins.PLUGIN_DIR;
 
@@ -25,7 +26,6 @@
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
-import java.util.Enumeration;
 import java.util.List;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
@@ -39,9 +39,7 @@
     File myWar = GerritLauncher.getDistributionArchive();
     if (myWar.isFile()) {
       try (ZipFile zf = new ZipFile(myWar)) {
-        Enumeration<? extends ZipEntry> e = zf.entries();
-        while (e.hasMoreElements()) {
-          ZipEntry ze = e.nextElement();
+        for (ZipEntry ze : entriesOf(zf)) {
           if (ze.isDirectory()) {
             continue;
           }
@@ -65,4 +63,8 @@
     // not yet used
     throw new UnsupportedOperationException();
   }
+
+  private static Iterable<? extends ZipEntry> entriesOf(ZipFile zipFile) {
+    return zipFile.stream().collect(toImmutableList());
+  }
 }
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 6f3514f..10fe2f3 100644
--- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.pgm.http.jetty;
 
+import static com.google.gerrit.httpd.CacheBasedWebSession.MAX_AGE_MINUTES;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -244,6 +246,15 @@
           }
         });
 
+    sessionHandler.setMaxInactiveInterval(
+        (int)
+            cfg.getTimeUnit(
+                "cache",
+                "web_sessions",
+                "maxAge",
+                SECONDS.convert(MAX_AGE_MINUTES, MINUTES),
+                SECONDS));
+
     Handler app = makeContext(env, cfg, sessionHandler);
     if (cfg.getBoolean("httpd", "requestLog", !reverseProxy)) {
       RequestLogHandler handler = new RequestLogHandler();
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index d9e3a6a..be5fe1a 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -110,7 +110,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/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index c1f5753..4592cbb 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -124,7 +124,7 @@
         } catch (StorageException e) {
           String msg = "Couldn't upgrade schema. Expected if slave and read-only database";
           System.err.println(msg);
-          logger.atSevere().withCause(e).log(msg);
+          logger.atSevere().withCause(e).log("%s", msg);
         }
 
         init.initializer.postRun(sysInjector);
diff --git a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index 9b4d5bb..35892f2 100644
--- a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -64,7 +64,7 @@
     if (path != null) {
       try (Repository allUsersRepo = new FileRepository(path)) {
         ExternalIdNotes extIdNotes =
-            ExternalIdNotes.loadNoCacheUpdate(
+            ExternalIdNotes.load(
                 allUsers,
                 allUsersRepo,
                 externalIdFactory,
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 2f12abb..f8fcadd 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -40,7 +40,7 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.file.FileRepository;
@@ -165,7 +165,7 @@
     return AuditLogFormatter.createBackedBy(ImmutableSet.of(account), ImmutableSet.of(), serverId);
   }
 
-  private void commit(Repository repository, GroupConfig groupConfig, Timestamp groupCreatedOn)
+  private void commit(Repository repository, GroupConfig groupConfig, Instant groupCreatedOn)
       throws IOException {
     PersonIdent personIdent =
         new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), groupCreatedOn);
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index d6a0133..4e854b5 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -120,7 +120,7 @@
 
         Account persistedAccount =
             accounts.insert(
-                Account.builder(id, TimeUtil.nowTs()).setFullName(name).setPreferredEmail(email));
+                Account.builder(id, TimeUtil.now()).setFullName(name).setPreferredEmail(email));
         // Only two groups should exist at this point in time and hence iterating over all of them
         // is cheap.
         Optional<GroupReference> adminGroupReference =
diff --git a/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java b/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
index 8e69eb9..fabad49 100644
--- a/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
@@ -23,7 +23,7 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -65,7 +65,7 @@
   }
 
   @Override
-  public SortedSet<Project.NameKey> list() {
+  public NavigableSet<Project.NameKey> list() {
     throw new UnsupportedOperationException("not implemented");
   }
 
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 48392b5..fe5966c 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -41,7 +42,6 @@
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.externalids.ExternalIdCacheModule;
-import com.google.gerrit.server.approval.ApprovalCacheImpl;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -60,6 +60,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
+import com.google.gerrit.server.config.SkipCurrentRulesEvaluationOnClosedChangesModule;
 import com.google.gerrit.server.config.SysExecutorModule;
 import com.google.gerrit.server.extensions.events.EventUtil;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -83,11 +84,13 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.FileEditsPredicate;
 import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
+import com.google.gerrit.server.query.change.DistinctVotersPredicate;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
@@ -178,7 +181,6 @@
     modules.add(new GroupModule());
     modules.add(new NoteDbModule());
     modules.add(AccountCacheImpl.module());
-    modules.add(ApprovalCacheImpl.module());
     modules.add(ConflictsCacheImpl.module());
     modules.add(DefaultPreferencesCacheImpl.module());
     modules.add(GroupCacheImpl.module());
@@ -195,6 +197,7 @@
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
+    factory(DistinctVotersPredicate.Factory.class);
     factory(ProjectState.Factory.class);
 
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
@@ -207,6 +210,12 @@
     modules.add(new PrologModule(getConfig()));
     modules.add(new DefaultSubmitRuleModule());
     modules.add(new IgnoreSelfApprovalRuleModule());
+    modules.add(new SkipCurrentRulesEvaluationOnClosedChangesModule());
+
+    // Global submit requirements
+    DynamicSet.setOf(binder(), SubmitRequirement.class);
+
+    factory(FileEditsPredicate.Factory.class);
 
     bind(ChangeJson.Factory.class).toProvider(Providers.of(null));
     bind(EventUtil.class).toProvider(Providers.of(null));
diff --git a/java/com/google/gerrit/pgm/util/ProxyUtil.java b/java/com/google/gerrit/pgm/util/ProxyUtil.java
index 3eb8187..c2c1141 100644
--- a/java/com/google/gerrit/pgm/util/ProxyUtil.java
+++ b/java/com/google/gerrit/pgm/util/ProxyUtil.java
@@ -59,7 +59,7 @@
       return;
     }
 
-    final URL u = new URL((!s.contains("://")) ? "http://" + s : s);
+    final URL u = new URL(!s.contains("://") ? "http://" + s : s);
     if (!"http".equals(u.getProtocol())) {
       throw new MalformedURLException("Invalid http_proxy: " + s + ": Only http supported.");
     }
diff --git a/java/com/google/gerrit/server/AssigneeStatusUpdate.java b/java/com/google/gerrit/server/AssigneeStatusUpdate.java
index 3d6242b..812aad1 100644
--- a/java/com/google/gerrit/server/AssigneeStatusUpdate.java
+++ b/java/com/google/gerrit/server/AssigneeStatusUpdate.java
@@ -16,18 +16,18 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.entities.Account;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 /** Change to an assignee's status. */
 @AutoValue
 public abstract class AssigneeStatusUpdate {
   public static AssigneeStatusUpdate create(
-      Timestamp ts, Account.Id updatedBy, Optional<Account.Id> currentAssignee) {
+      Instant ts, Account.Id updatedBy, Optional<Account.Id> currentAssignee) {
     return new AutoValue_AssigneeStatusUpdate(ts, updatedBy, currentAssignee);
   }
 
-  public abstract Timestamp date();
+  public abstract Instant date();
 
   public abstract Account.Id updatedBy();
 
diff --git a/java/com/google/gerrit/server/AuditEvent.java b/java/com/google/gerrit/server/AuditEvent.java
index 773a307..54bbe23 100644
--- a/java/com/google/gerrit/server/AuditEvent.java
+++ b/java/com/google/gerrit/server/AuditEvent.java
@@ -82,6 +82,12 @@
     return uuid.hashCode();
   }
 
+  // This is a value class that allows adding attributes by subclassing.
+  // Doing this is discouraged and using composition rather than inheritance to add fields to value
+  // types is preferred. However this class is part of the plugin API (used in the AuditListener
+  // extension point), hence we cannot change it without breaking plugins. Hence suppress the
+  // EqualsGetClass warning here.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object obj) {
     if (this == obj) {
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 7080417..7a6187d 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -111,9 +111,9 @@
         "//lib/commons:codec",
         "//lib/commons:compress",
         "//lib/commons:dbcp",
-        "//lib/commons:lang",
         "//lib/commons:lang3",
         "//lib/commons:net",
+        "//lib/commons:text",
         "//lib/commons:validator",
         "//lib/errorprone:annotations",
         "//lib/flogger:api",
@@ -144,6 +144,7 @@
     visibility = ["//visibility:public"],
     deps = [
         ":server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/logging",
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 8366b09..81cff6e 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -145,7 +145,7 @@
     ChangeMessageInfo cmi = new ChangeMessageInfo();
     cmi.id = message.getKey().uuid();
     cmi.author = accountLoader.get(message.getAuthor());
-    cmi.date = message.getWrittenOn();
+    cmi.setDate(message.getWrittenOn());
     cmi.message = message.getMessage();
     cmi.tag = message.getTag();
     cmi._revisionNumber = patchNum != null ? patchNum.get() : null;
diff --git a/java/com/google/gerrit/server/CmdLineParserModule.java b/java/com/google/gerrit/server/CmdLineParserModule.java
index d943889..be6b4cd8 100644
--- a/java/com/google/gerrit/server/CmdLineParserModule.java
+++ b/java/com/google/gerrit/server/CmdLineParserModule.java
@@ -23,17 +23,17 @@
 import com.google.gerrit.server.args4j.AccountGroupUUIDHandler;
 import com.google.gerrit.server.args4j.AccountIdHandler;
 import com.google.gerrit.server.args4j.ChangeIdHandler;
+import com.google.gerrit.server.args4j.InstantHandler;
 import com.google.gerrit.server.args4j.ObjectIdHandler;
 import com.google.gerrit.server.args4j.PatchSetIdHandler;
 import com.google.gerrit.server.args4j.ProjectHandler;
 import com.google.gerrit.server.args4j.SocketAddressHandler;
-import com.google.gerrit.server.args4j.TimestampHandler;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.OptionHandlerUtil;
 import com.google.gerrit.util.cli.OptionHandlers;
 import java.net.SocketAddress;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.spi.OptionHandler;
 
@@ -49,11 +49,11 @@
     registerOptionHandler(AccountGroup.Id.class, AccountGroupIdHandler.class);
     registerOptionHandler(AccountGroup.UUID.class, AccountGroupUUIDHandler.class);
     registerOptionHandler(Change.Id.class, ChangeIdHandler.class);
+    registerOptionHandler(Instant.class, InstantHandler.class);
     registerOptionHandler(ObjectId.class, ObjectIdHandler.class);
     registerOptionHandler(PatchSet.Id.class, PatchSetIdHandler.class);
     registerOptionHandler(ProjectState.class, ProjectHandler.class);
     registerOptionHandler(SocketAddress.class, SocketAddressHandler.class);
-    registerOptionHandler(Timestamp.class, TimestampHandler.class);
   }
 
   private <T> void registerOptionHandler(Class<T> type, Class<? extends OptionHandler<T>> impl) {
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index ba9f6d6..8198ce4 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -44,17 +44,20 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 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.gerrit.server.update.ChangeContext;
 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.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -64,7 +67,7 @@
 @Singleton
 public class CommentsUtil {
   public static final Ordering<Comment> COMMENT_ORDER =
-      new Ordering<Comment>() {
+      new Ordering<>() {
         @Override
         public int compare(Comment c1, Comment c2) {
           return ComparisonChain.start()
@@ -78,7 +81,7 @@
       };
 
   public static final Ordering<CommentInfo> COMMENT_INFO_ORDER =
-      new Ordering<CommentInfo>() {
+      new Ordering<>() {
         @Override
         public int compare(CommentInfo a, CommentInfo b) {
           return ComparisonChain.start()
@@ -130,7 +133,7 @@
   public HumanComment newHumanComment(
       ChangeNotes changeNotes,
       CurrentUser currentUser,
-      Timestamp when,
+      Instant when,
       String path,
       PatchSet.Id psId,
       short side,
@@ -302,7 +305,7 @@
   }
 
   private static boolean isAfter(CommentInfo c, ChangeMessage cm) {
-    return c.updated.after(cm.getWrittenOn());
+    return c.getUpdated().isAfter(cm.getWrittenOn());
   }
 
   /**
@@ -335,7 +338,7 @@
   }
 
   public void putHumanComments(
-      ChangeUpdate update, HumanComment.Status status, Iterable<HumanComment> comments) {
+      ChangeUpdate update, Comment.Status status, Iterable<HumanComment> comments) {
     for (HumanComment c : comments) {
       update.putComment(status, c);
     }
@@ -438,7 +441,7 @@
       // unignore the test in PortedCommentsIT.
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchset.commitId(), /* parentNum= */ 0);
+              change.getProject(), patchset.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
       return modifiedFiles.isEmpty()
           ? null
           : modifiedFiles.values().iterator().next().oldCommitId();
@@ -465,10 +468,35 @@
     }
   }
 
+  /** returns all changes that contain draft comments of {@code accountId}. */
+  public Collection<Change.Id> getChangesWithDrafts(Account.Id accountId) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return getChangesWithDrafts(repo, accountId);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
   private Collection<Ref> getDraftRefs(Repository repo, Change.Id changeId) throws IOException {
     return repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(changeId));
   }
 
+  private Collection<Change.Id> getChangesWithDrafts(Repository repo, Account.Id accountId)
+      throws IOException {
+    Set<Change.Id> changes = new HashSet<>();
+    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_DRAFT_COMMENTS)) {
+      Integer accountIdFromRef = RefNames.parseRefSuffix(ref.getName());
+      if (accountIdFromRef != null && accountIdFromRef == accountId.get()) {
+        Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
+        if (changeId == null) {
+          continue;
+        }
+        changes.add(changeId);
+      }
+    }
+    return changes;
+  }
+
   private static <T extends Comment> List<T> sort(List<T> comments) {
     comments.sort(COMMENT_ORDER);
     return comments;
diff --git a/java/com/google/gerrit/server/CommonConverters.java b/java/com/google/gerrit/server/CommonConverters.java
index 2b48169..4ad143b 100644
--- a/java/com/google/gerrit/server/CommonConverters.java
+++ b/java/com/google/gerrit/server/CommonConverters.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server;
 
 import com.google.gerrit.extensions.common.GitPerson;
-import java.sql.Timestamp;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /**
@@ -30,7 +29,7 @@
     GitPerson result = new GitPerson();
     result.name = ident.getName();
     result.email = ident.getEmailAddress();
-    result.date = new Timestamp(ident.getWhen().getTime());
+    result.setDate(ident.getWhenAsInstant());
     result.tz = ident.getTimeZoneOffset();
     return result;
   }
diff --git a/java/com/google/gerrit/server/ExceptionHookImpl.java b/java/com/google/gerrit/server/ExceptionHookImpl.java
index 3986842..781f196 100644
--- a/java/com/google/gerrit/server/ExceptionHookImpl.java
+++ b/java/com/google/gerrit/server/ExceptionHookImpl.java
@@ -18,7 +18,7 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
+import com.google.gerrit.exceptions.MergeUpdateException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.project.ProjectConfig;
 import java.util.Optional;
@@ -74,7 +74,7 @@
               + "\n"
               + CONTACT_PROJECT_OWNER_USER_MESSAGE);
     }
-    if (throwable instanceof InternalServerWithUserMessageException) {
+    if (throwable instanceof MergeUpdateException) {
       return ImmutableList.of(throwable.getMessage());
     }
     return ImmutableList.of();
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index eb3e324..65a81f7 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -49,10 +49,10 @@
 import java.net.MalformedURLException;
 import java.net.SocketAddress;
 import java.net.URL;
-import java.util.Date;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Optional;
 import java.util.Set;
-import java.util.TimeZone;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.SystemReader;
 
@@ -427,10 +427,10 @@
   }
 
   public PersonIdent newRefLogIdent() {
-    return newRefLogIdent(new Date(), TimeZone.getDefault());
+    return newRefLogIdent(Instant.now(), ZoneId.systemDefault());
   }
 
-  public PersonIdent newRefLogIdent(Date when, TimeZone tz) {
+  public PersonIdent newRefLogIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
 
     String name = ua.fullName();
@@ -450,14 +450,19 @@
               ? constructMailAddress(ua, "unknown")
               : ua.preferredEmail();
     }
-    return new PersonIdent(name, user, when, tz);
+
+    return new PersonIdent(name, user, when, zoneId);
   }
 
   private String constructMailAddress(Account ua, String host) {
     return getUserName().orElse("") + "|account-" + ua.id().toString() + "@" + host;
   }
 
-  public PersonIdent newCommitterIdent(Date when, TimeZone tz) {
+  public PersonIdent newCommitterIdent(PersonIdent ident) {
+    return newCommitterIdent(ident.getWhenAsInstant(), ident.getZoneId());
+  }
+
+  public PersonIdent newCommitterIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
     String name = ua.fullName();
     String email = ua.preferredEmail();
@@ -492,7 +497,7 @@
       }
     }
 
-    return new PersonIdent(name, email, when, tz);
+    return new PersonIdent(name, email, when, zoneId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/LibModuleLoader.java b/java/com/google/gerrit/server/LibModuleLoader.java
index f7e04b4..5c0f8e4 100644
--- a/java/com/google/gerrit/server/LibModuleLoader.java
+++ b/java/com/google/gerrit/server/LibModuleLoader.java
@@ -87,9 +87,7 @@
     try {
       return (Class<Module>) Class.forName(className);
     } catch (ClassNotFoundException | LinkageError e) {
-      String msg = "Cannot load LibModule " + className;
-      logger.atSevere().withCause(e).log(msg);
-      throw new ProvisionException(msg, e);
+      throw new ProvisionException("Cannot load LibModule " + className, e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 326ddf4..3d449b7 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -39,7 +39,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
@@ -106,7 +105,7 @@
         .id(psId)
         .commitId(commit)
         .uploader(update.getAccountId())
-        .createdOn(new Timestamp(update.getWhen().getTime()))
+        .createdOn(update.getWhen())
         .groups(groups)
         .pushCertificate(Optional.ofNullable(pushCertificate))
         .description(Optional.ofNullable(description))
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index 4d19dd0..de5f023 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.sql.Timestamp;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Map;
@@ -90,7 +91,7 @@
             draftComment, psIdOfDraftComment, notes.getProjectName());
         continue;
       }
-      draftComment.writtenOn = ctx.getWhen();
+      draftComment.writtenOn = Timestamp.from(ctx.getWhen());
       draftComment.tag = tag;
       // Draft may have been created by a different real user; copy the current real user. (Only
       // applies to X-Gerrit-RunAs, since modifying drafts via on_behalf_of is not allowed.)
diff --git a/java/com/google/gerrit/server/RequestCleanup.java b/java/com/google/gerrit/server/RequestCleanup.java
index e07d148..1d421ed 100644
--- a/java/com/google/gerrit/server/RequestCleanup.java
+++ b/java/com/google/gerrit/server/RequestCleanup.java
@@ -16,8 +16,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.inject.servlet.RequestScoped;
+import java.util.ArrayList;
 import java.util.Iterator;
-import java.util.LinkedList;
 import java.util.List;
 
 /** Registers cleanup activities to be completed when a scope ends. */
@@ -25,7 +25,7 @@
 public class RequestCleanup implements Runnable {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final List<Runnable> cleanup = new LinkedList<>();
+  private final List<Runnable> cleanup = new ArrayList<>();
   private boolean ran;
 
   /** Register a task to be completed after the request ends. */
diff --git a/java/com/google/gerrit/server/ReviewerByEmailSet.java b/java/com/google/gerrit/server/ReviewerByEmailSet.java
index 4a317c3..c6ba7b5 100644
--- a/java/com/google/gerrit/server/ReviewerByEmailSet.java
+++ b/java/com/google/gerrit/server/ReviewerByEmailSet.java
@@ -19,7 +19,7 @@
 import com.google.common.collect.Table;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /**
  * Set of reviewers on a change that do not have a Gerrit account and were added by email instead.
@@ -30,8 +30,7 @@
 public class ReviewerByEmailSet {
   private static final ReviewerByEmailSet EMPTY = new ReviewerByEmailSet(ImmutableTable.of());
 
-  public static ReviewerByEmailSet fromTable(
-      Table<ReviewerStateInternal, Address, Timestamp> table) {
+  public static ReviewerByEmailSet fromTable(Table<ReviewerStateInternal, Address, Instant> table) {
     return new ReviewerByEmailSet(table);
   }
 
@@ -39,10 +38,10 @@
     return EMPTY;
   }
 
-  private final ImmutableTable<ReviewerStateInternal, Address, Timestamp> table;
+  private final ImmutableTable<ReviewerStateInternal, Address, Instant> table;
   private ImmutableSet<Address> users;
 
-  private ReviewerByEmailSet(Table<ReviewerStateInternal, Address, Timestamp> table) {
+  private ReviewerByEmailSet(Table<ReviewerStateInternal, Address, Instant> table) {
     this.table = ImmutableTable.copyOf(table);
   }
 
@@ -58,7 +57,7 @@
     return table.row(state).keySet();
   }
 
-  public ImmutableTable<ReviewerStateInternal, Address, Timestamp> asTable() {
+  public ImmutableTable<ReviewerStateInternal, Address, Instant> asTable() {
     return table;
   }
 
diff --git a/java/com/google/gerrit/server/ReviewerSet.java b/java/com/google/gerrit/server/ReviewerSet.java
index 0f6bf29..0ff68e0 100644
--- a/java/com/google/gerrit/server/ReviewerSet.java
+++ b/java/com/google/gerrit/server/ReviewerSet.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /**
  * Set of reviewers on a change.
@@ -38,7 +38,7 @@
 
   public static ReviewerSet fromApprovals(Iterable<PatchSetApproval> approvals) {
     PatchSetApproval first = null;
-    Table<ReviewerStateInternal, Account.Id, Timestamp> reviewers = HashBasedTable.create();
+    Table<ReviewerStateInternal, Account.Id, Instant> reviewers = HashBasedTable.create();
     for (PatchSetApproval psa : approvals) {
       if (first == null) {
         first = psa;
@@ -58,7 +58,7 @@
     return new ReviewerSet(reviewers);
   }
 
-  public static ReviewerSet fromTable(Table<ReviewerStateInternal, Account.Id, Timestamp> table) {
+  public static ReviewerSet fromTable(Table<ReviewerStateInternal, Account.Id, Instant> table) {
     return new ReviewerSet(table);
   }
 
@@ -66,10 +66,10 @@
     return EMPTY;
   }
 
-  private final ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp> table;
+  private final ImmutableTable<ReviewerStateInternal, Account.Id, Instant> table;
   private ImmutableSet<Account.Id> accounts;
 
-  private ReviewerSet(Table<ReviewerStateInternal, Account.Id, Timestamp> table) {
+  private ReviewerSet(Table<ReviewerStateInternal, Account.Id, Instant> table) {
     this.table = ImmutableTable.copyOf(table);
   }
 
@@ -85,7 +85,7 @@
     return table.row(state).keySet();
   }
 
-  public ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp> asTable() {
+  public ImmutableTable<ReviewerStateInternal, Account.Id, Instant> asTable() {
     return table;
   }
 
diff --git a/java/com/google/gerrit/server/ReviewerStatusUpdate.java b/java/com/google/gerrit/server/ReviewerStatusUpdate.java
index 938d985..1e0aa43 100644
--- a/java/com/google/gerrit/server/ReviewerStatusUpdate.java
+++ b/java/com/google/gerrit/server/ReviewerStatusUpdate.java
@@ -17,17 +17,17 @@
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Change to a reviewer's status. */
 @AutoValue
 public abstract class ReviewerStatusUpdate {
   public static ReviewerStatusUpdate create(
-      Timestamp ts, Account.Id updatedBy, Account.Id reviewer, ReviewerStateInternal state) {
+      Instant ts, Account.Id updatedBy, Account.Id reviewer, ReviewerStateInternal state) {
     return new AutoValue_ReviewerStatusUpdate(ts, updatedBy, reviewer, state);
   }
 
-  public abstract Timestamp date();
+  public abstract Instant date();
 
   public abstract Account.Id updatedBy();
 
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index f7193b7..95d891e 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -54,10 +54,10 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.HashSet;
+import java.util.Collections;
 import java.util.List;
+import java.util.NavigableSet;
 import java.util.Set;
-import java.util.SortedSet;
 import java.util.TreeSet;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Constants;
@@ -113,7 +113,7 @@
   @AutoValue
   public abstract static class StarRef {
     private static final StarRef MISSING =
-        new AutoValue_StarredChangesUtil_StarRef(null, ImmutableSortedSet.of());
+        new AutoValue_StarredChangesUtil_StarRef(null, Collections.emptyNavigableSet());
 
     private static StarRef create(Ref ref, Iterable<String> labels) {
       return new AutoValue_StarredChangesUtil_StarRef(
@@ -123,7 +123,7 @@
     @Nullable
     public abstract Ref ref();
 
-    public abstract ImmutableSortedSet<String> labels();
+    public abstract NavigableSet<String> labels();
 
     public ObjectId objectId() {
       return ref() != null ? ref().getObjectId() : ObjectId.zeroId();
@@ -185,7 +185,7 @@
     this.queryProvider = queryProvider;
   }
 
-  public ImmutableSortedSet<String> getLabels(Account.Id accountId, Change.Id changeId) {
+  public NavigableSet<String> getLabels(Account.Id accountId, Change.Id changeId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       return readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)).labels();
     } catch (IOException e) {
@@ -197,7 +197,7 @@
     }
   }
 
-  public ImmutableSortedSet<String> star(
+  public NavigableSet<String> star(
       Account.Id accountId,
       Project.NameKey project,
       Change.Id changeId,
@@ -208,7 +208,7 @@
       String refName = RefNames.refsStarredChanges(changeId, accountId);
       StarRef old = readLabels(repo, refName);
 
-      Set<String> labels = new HashSet<>(old.labels());
+      NavigableSet<String> labels = new TreeSet<>(old.labels());
       if (labelsToAdd != null) {
         labels.addAll(labelsToAdd);
       }
@@ -224,7 +224,7 @@
       }
 
       indexer.index(project, changeId);
-      return ImmutableSortedSet.copyOf(labels);
+      return Collections.unmodifiableNavigableSet(labels);
     } catch (IOException e) {
       throw new StorageException(
           String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
@@ -289,6 +289,35 @@
     }
   }
 
+  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId, String label) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      ImmutableSet.Builder<Change.Id> builder = ImmutableSet.builder();
+      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
+        Account.Id currentAccountId = Account.Id.fromRef(ref.getName());
+        // Skip all refs that don't correspond with accountId.
+        if (currentAccountId == null || !currentAccountId.equals(accountId)) {
+          continue;
+        }
+        // Skip all refs that don't contain the required label.
+        StarRef starRef = readLabels(repo, ref.getName());
+        if (!starRef.labels().contains(label)) {
+          continue;
+        }
+
+        // Skip invalid change ids.
+        Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
+        if (changeId == null) {
+          continue;
+        }
+        builder.add(changeId);
+      }
+      return builder.build();
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format("Get starred changes for account %d failed", accountId.get()), e);
+    }
+  }
+
   public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId) {
     List<ChangeData> changeData =
         queryProvider
@@ -397,7 +426,7 @@
       return;
     }
 
-    SortedSet<String> invalidLabels = new TreeSet<>();
+    NavigableSet<String> invalidLabels = new TreeSet<>();
     for (String label : labels) {
       if (CharMatcher.whitespace().matchesAnyOf(label)) {
         invalidLabels.add(label);
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 2cf2326..58396f5 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -39,23 +39,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.function.Function;
-import java.util.function.Predicate;
 
 @Singleton
 public class WebLinks {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final Predicate<WebLinkInfo> INVALID_WEBLINK =
-      link -> {
-        if (link == null) {
-          return false;
-        } else if (Strings.isNullOrEmpty(link.name) || Strings.isNullOrEmpty(link.url)) {
-          logger.atWarning().log("%s is missing name and/or url", link.getClass().getName());
-          return false;
-        }
-        return true;
-      };
-
   private final DynamicSet<PatchSetWebLink> patchSetLinks;
   private final DynamicSet<ResolveConflictsWebLink> resolveConflictsLinks;
   private final DynamicSet<ParentWebLink> parentLinks;
@@ -98,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));
   }
 
   /**
@@ -179,7 +174,7 @@
     }
     return Streams.stream(fileHistoryLinks)
         .map(webLink -> webLink.getFileHistoryWebLink(project, revision, file))
-        .filter(INVALID_WEBLINK)
+        .filter(WebLinks::isValid)
         .collect(toImmutableList());
   }
 
@@ -218,7 +213,7 @@
                     patchSetIdB,
                     revisionB,
                     fileB))
-        .filter(INVALID_WEBLINK)
+        .filter(WebLinks::isValid)
         .collect(toImmutableList());
   }
 
@@ -255,7 +250,17 @@
       DynamicSet<T> links, Function<T, WebLinkInfo> transformer) {
     return Streams.stream(links)
         .map(transformer)
-        .filter(INVALID_WEBLINK)
+        .filter(WebLinks::isValid)
         .collect(toImmutableList());
   }
+
+  private static boolean isValid(WebLinkInfo link) {
+    if (link == null) {
+      return false;
+    } else if (Strings.isNullOrEmpty(link.name) || Strings.isNullOrEmpty(link.url)) {
+      logger.atWarning().log("%s is missing name and/or url", link.getClass().getName());
+      return false;
+    }
+    return true;
+  }
 }
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 093af68..1d9150d 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -153,7 +153,7 @@
   }
 
   private AccountState missing(Account.Id accountId) {
-    Account.Builder account = Account.builder(accountId, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(accountId, TimeUtil.now());
     account.setActive(false);
     return AccountState.forAccount(account.build());
   }
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 45f1f35..4143f77 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -34,7 +34,7 @@
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -177,7 +177,7 @@
    * @throws DuplicateKeyException if the user branch already exists
    */
   public Account getNewAccount() throws DuplicateKeyException {
-    return getNewAccount(TimeUtil.nowTs());
+    return getNewAccount(TimeUtil.now());
   }
 
   /**
@@ -186,7 +186,7 @@
    * @return the new account
    * @throws DuplicateKeyException if the user branch already exists
    */
-  Account getNewAccount(Timestamp registeredOn) throws DuplicateKeyException {
+  Account getNewAccount(Instant registeredOn) throws DuplicateKeyException {
     checkLoaded();
     if (revision != null) {
       throw new DuplicateKeyException(String.format("account %s already exists", accountId));
@@ -216,7 +216,7 @@
       rw.reset();
       rw.markStart(revision);
       rw.sort(RevSort.REVERSE);
-      Timestamp registeredOn = new Timestamp(rw.next().getCommitTime() * 1000L);
+      Instant registeredOn = Instant.ofEpochMilli(rw.next().getCommitTime() * 1000L);
 
       Config accountConfig = readConfig(AccountProperties.ACCOUNT_CONFIG);
       loadedAccountProperties =
@@ -274,7 +274,7 @@
         commit.setMessage("Create account\n");
       }
 
-      Timestamp registeredOn = loadedAccountProperties.get().getRegisteredOn();
+      Instant registeredOn = loadedAccountProperties.get().getRegisteredOn();
       commit.setAuthor(new PersonIdent(commit.getAuthor(), registeredOn));
       commit.setCommitter(new PersonIdent(commit.getCommitter(), registeredOn));
     }
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index 3f7f3f2..f5f9b3d 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -259,10 +258,7 @@
   private boolean viewAll() {
     if (viewAll == null) {
       try {
-        perm.check(GlobalPermission.VIEW_ALL_ACCOUNTS);
-        viewAll = true;
-      } catch (AuthException e) {
-        viewAll = false;
+        viewAll = perm.test(GlobalPermission.VIEW_ALL_ACCOUNTS);
       } catch (PermissionBackendException e) {
         logger.atFine().withCause(e).log(
             "Failed to check %s global capability for user %s",
diff --git a/java/com/google/gerrit/server/account/AccountProperties.java b/java/com/google/gerrit/server/account/AccountProperties.java
index 9b7ca81..928d851 100644
--- a/java/com/google/gerrit/server/account/AccountProperties.java
+++ b/java/com/google/gerrit/server/account/AccountProperties.java
@@ -17,7 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -57,16 +57,13 @@
   public static final String KEY_STATUS = "status";
 
   private final Account.Id accountId;
-  private final Timestamp registeredOn;
+  private final Instant registeredOn;
   private final Config accountConfig;
   private @Nullable ObjectId metaId;
   private Account account;
 
   AccountProperties(
-      Account.Id accountId,
-      Timestamp registeredOn,
-      Config accountConfig,
-      @Nullable ObjectId metaId) {
+      Account.Id accountId, Instant registeredOn, Config accountConfig, @Nullable ObjectId metaId) {
     this.accountId = accountId;
     this.registeredOn = registeredOn;
     this.accountConfig = accountConfig;
@@ -80,7 +77,7 @@
     return account;
   }
 
-  public Timestamp getRegisteredOn() {
+  public Instant getRegisteredOn() {
     return registeredOn;
   }
 
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 68f5a85..8824d56 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -27,7 +27,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.server.CurrentUser;
@@ -443,9 +442,10 @@
       // more strict here.
       boolean canSeeSecondaryEmails = false;
       try {
-        permissionBackend.user(self.get()).check(GlobalPermission.MODIFY_ACCOUNT);
-        canSeeSecondaryEmails = true;
-      } catch (AuthException | PermissionBackendException e) {
+        if (permissionBackend.user(self.get()).test(GlobalPermission.MODIFY_ACCOUNT)) {
+          canSeeSecondaryEmails = true;
+        }
+      } catch (PermissionBackendException e) {
         // remains false
       }
       return accountQueryProvider.get().enforceVisibility(true)
@@ -538,12 +538,12 @@
    * @throws IOException if an error occurs.
    */
   public Result resolve(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate());
+    return searchImpl(input, searchers, this::canSeePredicate, AccountResolver::isActive);
   }
 
   public Result resolve(String input, Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate);
+    return searchImpl(input, searchers, this::canSeePredicate, accountActivityPredicate);
   }
 
   /**
@@ -556,17 +556,17 @@
    * instead will be stored as a link to the corresponding Gerrit Account.
    */
   public Result resolveIncludeInactive(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierCanSee(), all());
+    return searchImpl(input, searchers, this::canSeePredicate, AccountResolver::allVisible);
   }
 
   public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate());
+    return searchImpl(input, searchers, this::allVisiblePredicate, AccountResolver::isActive);
   }
 
   public Result resolveIgnoreVisibility(
       String input, Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate);
+    return searchImpl(input, searchers, this::allVisiblePredicate, accountActivityPredicate);
   }
 
   /**
@@ -595,7 +595,7 @@
   @Deprecated
   public Result resolveByNameOrEmail(String input) throws ConfigInvalidException, IOException {
     return searchImpl(
-        input, nameOrEmailSearchers, visibilitySupplierCanSee(), accountActivityPredicate());
+        input, nameOrEmailSearchers, this::canSeePredicate, AccountResolver::isActive);
   }
 
   /**
@@ -614,26 +614,29 @@
     return searchImpl(
         input,
         ImmutableList.of(new ByNameAndEmail(), new ByEmail(), new ByFullName(), new ByUsername()),
-        visibilitySupplierCanSee(),
-        accountActivityPredicate());
+        this::canSeePredicate,
+        AccountResolver::isActive);
   }
 
-  private Supplier<Predicate<AccountState>> visibilitySupplierCanSee() {
-    return () -> accountControlFactory.get()::canSee;
+  private Predicate<AccountState> canSeePredicate() {
+    return this::canSee;
   }
 
-  private Supplier<Predicate<AccountState>> visibilitySupplierAll() {
-    return () -> all();
+  private boolean canSee(AccountState accountState) {
+    return accountControlFactory.get().canSee(accountState);
   }
 
-  private Predicate<AccountState> all() {
-    return accountState -> {
-      return true;
-    };
+  private Predicate<AccountState> allVisiblePredicate() {
+    return AccountResolver::allVisible;
   }
 
-  private Predicate<AccountState> accountActivityPredicate() {
-    return (AccountState accountState) -> accountState.account().isActive();
+  /** @param accountState account state for which the visibility should be checked */
+  private static boolean allVisible(AccountState accountState) {
+    return true;
+  }
+
+  private static boolean isActive(AccountState accountState) {
+    return accountState.account().isActive();
   }
 
   @VisibleForTesting
diff --git a/java/com/google/gerrit/server/account/AccountResource.java b/java/com/google/gerrit/server/account/AccountResource.java
index 4fb69bd..9629809 100644
--- a/java/com/google/gerrit/server/account/AccountResource.java
+++ b/java/com/google/gerrit/server/account/AccountResource.java
@@ -23,20 +23,16 @@
 import java.util.Set;
 
 public class AccountResource implements RestResource {
-  public static final TypeLiteral<RestView<AccountResource>> ACCOUNT_KIND =
-      new TypeLiteral<RestView<AccountResource>>() {};
+  public static final TypeLiteral<RestView<AccountResource>> ACCOUNT_KIND = new TypeLiteral<>() {};
 
-  public static final TypeLiteral<RestView<Capability>> CAPABILITY_KIND =
-      new TypeLiteral<RestView<Capability>>() {};
+  public static final TypeLiteral<RestView<Capability>> CAPABILITY_KIND = new TypeLiteral<>() {};
 
-  public static final TypeLiteral<RestView<Email>> EMAIL_KIND =
-      new TypeLiteral<RestView<Email>>() {};
+  public static final TypeLiteral<RestView<Email>> EMAIL_KIND = new TypeLiteral<>() {};
 
-  public static final TypeLiteral<RestView<SshKey>> SSH_KEY_KIND =
-      new TypeLiteral<RestView<SshKey>>() {};
+  public static final TypeLiteral<RestView<SshKey>> SSH_KEY_KIND = new TypeLiteral<>() {};
 
   public static final TypeLiteral<RestView<StarredChange>> STARRED_CHANGE_KIND =
-      new TypeLiteral<RestView<StarredChange>>() {};
+      new TypeLiteral<>() {};
 
   private final IdentifiedUser user;
 
@@ -106,8 +102,7 @@
   }
 
   public static class Star implements RestResource {
-    public static final TypeLiteral<RestView<Star>> STAR_KIND =
-        new TypeLiteral<RestView<Star>>() {};
+    public static final TypeLiteral<RestView<Star>> STAR_KIND = new TypeLiteral<>() {};
 
     private final IdentifiedUser user;
     private final ChangeResource change;
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/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 93738b0..137fd59 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -49,7 +49,6 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
@@ -201,8 +200,6 @@
   /** Single instance that accumulates updates from the batch. */
   private ExternalIdNotes externalIdNotes;
 
-  private static final Runnable DO_NOTHING = () -> {};
-
   @AssistedInject
   @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
@@ -225,8 +222,8 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.empty()),
-        DO_NOTHING,
-        DO_NOTHING);
+        AccountsUpdate::doNothing,
+        AccountsUpdate::doNothing);
   }
 
   @AssistedInject
@@ -252,8 +249,8 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.of(currentUser)),
-        DO_NOTHING,
-        DO_NOTHING);
+        AccountsUpdate::doNothing,
+        AccountsUpdate::doNothing);
   }
 
   @VisibleForTesting
@@ -297,9 +294,7 @@
 
   private static PersonIdent createPersonIdent(
       PersonIdent serverIdent, Optional<IdentifiedUser> user) {
-    return user.isPresent()
-        ? user.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone())
-        : serverIdent;
+    return user.isPresent() ? user.get().newCommitterIdent(serverIdent) : serverIdent;
   }
 
   /**
@@ -330,9 +325,7 @@
             ImmutableList.of(
                 repo -> {
                   AccountConfig accountConfig = read(repo, accountId);
-                  Account account =
-                      accountConfig.getNewAccount(
-                          new Timestamp(committerIdent.getWhen().getTime()));
+                  Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
                   AccountState accountState = AccountState.forAccount(account);
                   AccountDelta.Builder deltaBuilder = AccountDelta.builder();
                   init.configure(accountState, deltaBuilder);
@@ -586,6 +579,8 @@
     return metaDataUpdate;
   }
 
+  private static void doNothing() {}
+
   @FunctionalInterface
   private interface ExecutableUpdate {
     UpdatedAccount execute(Repository allUsersRepo) throws IOException, ConfigInvalidException;
diff --git a/java/com/google/gerrit/server/account/AuthRequest.java b/java/com/google/gerrit/server/account/AuthRequest.java
index 50ed532..cceda70 100644
--- a/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/java/com/google/gerrit/server/account/AuthRequest.java
@@ -49,20 +49,20 @@
     }
 
     /** Create a request for a local username, such as from LDAP. */
-    public AuthRequest createForUser(String username) {
+    public AuthRequest createForUser(String userName) {
       AuthRequest r =
           new AuthRequest(
-              externalIdKeyFactory.create(SCHEME_GERRIT, username), externalIdKeyFactory);
-      r.setUserName(username);
+              externalIdKeyFactory.create(SCHEME_GERRIT, userName), externalIdKeyFactory);
+      r.setUserName(userName);
       return r;
     }
 
     /** Create a request for an external username. */
-    public AuthRequest createForExternalUser(String username) {
+    public AuthRequest createForExternalUser(String userName) {
       AuthRequest r =
           new AuthRequest(
-              externalIdKeyFactory.create(SCHEME_EXTERNAL, username), externalIdKeyFactory);
-      r.setUserName(username);
+              externalIdKeyFactory.create(SCHEME_EXTERNAL, userName), externalIdKeyFactory);
+      r.setUserName(userName);
       return r;
     }
 
diff --git a/java/com/google/gerrit/server/account/CachedAccountDetails.java b/java/com/google/gerrit/server/account/CachedAccountDetails.java
index f23a766..2ab6174 100644
--- a/java/com/google/gerrit/server/account/CachedAccountDetails.java
+++ b/java/com/google/gerrit/server/account/CachedAccountDetails.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.config.CachedPreferences;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
@@ -107,7 +106,7 @@
       Cache.AccountProto.Builder accountProto =
           Cache.AccountProto.newBuilder()
               .setId(account.id().get())
-              .setRegisteredOn(account.registeredOn().toInstant().toEpochMilli())
+              .setRegisteredOn(account.registeredOn().toEpochMilli())
               .setInactive(account.inactive())
               .setFullName(Strings.nullToEmpty(account.fullName()))
               .setDisplayName(Strings.nullToEmpty(account.displayName()))
@@ -143,7 +142,7 @@
       Account account =
           Account.builder(
                   Account.id(proto.getAccount().getId()),
-                  Timestamp.from(Instant.ofEpochMilli(proto.getAccount().getRegisteredOn())))
+                  Instant.ofEpochMilli(proto.getAccount().getRegisteredOn()))
               .setFullName(Strings.emptyToNull(proto.getAccount().getFullName()))
               .setDisplayName(Strings.emptyToNull(proto.getAccount().getDisplayName()))
               .setPreferredEmail(Strings.emptyToNull(proto.getAccount().getPreferredEmail()))
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 98d0d50..8c3f033 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -29,7 +29,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
@@ -118,7 +117,7 @@
     UserIdentity u = new UserIdentity();
     u.setName(who.getName());
     u.setEmail(who.getEmailAddress());
-    u.setDate(new Timestamp(who.getWhen().getTime()));
+    u.setDate(who.getWhenAsInstant());
     u.setTimeZone(who.getTimeZoneOffset());
 
     // If only one account has access to this email address, select it
diff --git a/java/com/google/gerrit/server/account/GroupControl.java b/java/com/google/gerrit/server/account/GroupControl.java
index d42db60..fd18d3e 100644
--- a/java/com/google/gerrit/server/account/GroupControl.java
+++ b/java/com/google/gerrit/server/account/GroupControl.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -189,10 +188,7 @@
 
   private boolean canAdministrateServer() {
     try {
-      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
-      return true;
-    } catch (AuthException e) {
-      return false;
+      return perm.test(GlobalPermission.ADMINISTRATE_SERVER);
     } catch (PermissionBackendException e) {
       logger.atFine().log(
           "Failed to check %s global capability for user %s",
diff --git a/java/com/google/gerrit/server/account/Realm.java b/java/com/google/gerrit/server/account/Realm.java
index 3f642f7..ffc95a3 100644
--- a/java/com/google/gerrit/server/account/Realm.java
+++ b/java/com/google/gerrit/server/account/Realm.java
@@ -56,7 +56,14 @@
    */
   Account.Id lookup(String accountName) throws IOException;
 
-  /** Returns true if the account is active. */
+  /**
+   * Returns true if the account is active.
+   *
+   * @throws LoginException thrown if login is required and fails
+   * @throws NamingException may be thrown if the name is invalid
+   * @throws AccountException may be thrown in case the username is ambiguous
+   * @throws IOException thrown in case of IO errors
+   */
   default boolean isActive(@SuppressWarnings("unused") String username)
       throws LoginException, NamingException, AccountException, IOException {
     return true;
diff --git a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
index 1cd3de8..2d1ec1a 100644
--- a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
@@ -20,7 +20,6 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -36,13 +35,6 @@
   }
 
   @Override
-  public void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd) {}
-
-  @Override
   public Optional<ExternalId> byKey(ExternalId.Key key) throws IOException {
     throw new UnsupportedOperationException();
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index 0029557..9f766d0 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -18,7 +18,6 @@
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -32,21 +31,6 @@
  * <p>All returned collections are unmodifiable.
  */
 interface ExternalIdCache {
-
-  /**
-   * Updates the cache.
-   *
-   * @param oldNotesRev current revision against which the below updates are applied
-   * @param newNotesRev key for the new cache revision
-   * @param toRemove external IDs to remove
-   * @param toAdd external IDs to add
-   */
-  void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd);
-
   Optional<ExternalId> byKey(ExternalId.Key key) throws IOException;
 
   ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
index e6db593..af8e19f 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -14,66 +14,42 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Optional;
-import java.util.concurrent.ExecutionException;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
-import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Caches external IDs of all accounts. The external IDs are always loaded from NoteDb. */
 @Singleton
 class ExternalIdCacheImpl implements ExternalIdCache {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public static final String CACHE_NAME = "external_ids_map";
 
-  private final LoadingCache<ObjectId, AllExternalIds> extIdsByAccount;
+  private final Cache<ObjectId, AllExternalIds> extIdsByAccount;
   private final ExternalIdReader externalIdReader;
+  private final ExternalIdCacheLoader externalIdCacheLoader;
   private final Lock lock;
 
   @Inject
   ExternalIdCacheImpl(
-      @Named(CACHE_NAME) LoadingCache<ObjectId, AllExternalIds> extIdsByAccount,
-      ExternalIdReader externalIdReader) {
+      @Named(CACHE_NAME) Cache<ObjectId, AllExternalIds> extIdsByAccount,
+      ExternalIdReader externalIdReader,
+      ExternalIdCacheLoader externalIdCacheLoader) {
     this.extIdsByAccount = extIdsByAccount;
     this.externalIdReader = externalIdReader;
+    this.externalIdCacheLoader = externalIdCacheLoader;
     this.lock = new ReentrantLock(true /* fair */);
   }
 
   @Override
-  public void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd) {
-    updateCache(
-        oldNotesRev,
-        newNotesRev,
-        m -> {
-          for (ExternalId extId : toRemove) {
-            m.remove(extId.accountId(), extId);
-          }
-          for (ExternalId extId : toAdd) {
-            extId.checkThatBlobIdIsSet();
-            m.put(extId.accountId(), extId);
-          }
-        });
-  }
-
-  @Override
   public Optional<ExternalId> byKey(ExternalId.Key key) throws IOException {
     return Optional.ofNullable(get().byKey().get(key));
   }
@@ -112,38 +88,39 @@
     return get(externalIdReader.readRevision());
   }
 
+  /**
+   * Returns the cached value or a freshly loaded value that will be cached with this call in case
+   * the value was absent from the cache.
+   *
+   * <p>This method will load the value using {@link ExternalIdCacheLoader} in case it is not
+   * already cached. {@link ExternalIdCacheLoader} requires loading older versions of the cached
+   * value and Caffeine does not support recursive calls to the cache from loaders. Hence, we use a
+   * Cache instead of a LoadingCache and perform the loading ourselves here similar to what a
+   * loading cache would do.
+   */
   private AllExternalIds get(ObjectId rev) throws IOException {
-    try {
-      return extIdsByAccount.get(rev);
-    } catch (ExecutionException e) {
-      throw new IOException("Cannot load external ids", e);
-    }
-  }
-
-  private void updateCache(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Consumer<SetMultimap<Account.Id, ExternalId>> update) {
-    if (oldNotesRev.equals(newNotesRev)) {
-      // No need to update external id cache since there is no update to those external ids.
-      return;
+    AllExternalIds cachedValue = extIdsByAccount.getIfPresent(rev);
+    if (cachedValue != null) {
+      return cachedValue;
     }
 
+    // Load the value and put it in the cache.
     lock.lock();
     try {
-      SetMultimap<Account.Id, ExternalId> m;
-      if (!ObjectId.zeroId().equals(oldNotesRev)) {
-        m =
-            MultimapBuilder.hashKeys()
-                .hashSetValues()
-                .build(extIdsByAccount.get(oldNotesRev).byAccount());
-      } else {
-        m = MultimapBuilder.hashKeys().hashSetValues().build();
+      // Check if value was already loaded while waiting for the lock.
+      cachedValue = extIdsByAccount.getIfPresent(rev);
+      if (cachedValue != null) {
+        return cachedValue;
       }
-      update.accept(m);
-      extIdsByAccount.put(newNotesRev, AllExternalIds.create(m.values().stream()));
-    } catch (ExecutionException e) {
-      logger.atWarning().withCause(e).log("Cannot update external IDs");
+
+      AllExternalIds newlyLoadedValue;
+      try {
+        newlyLoadedValue = externalIdCacheLoader.load(rev);
+      } catch (ConfigInvalidException e) {
+        throw new IOException("Cannot load external ids", e);
+      }
+      extIdsByAccount.put(rev, newlyLoadedValue);
+      return newlyLoadedValue;
     } finally {
       lock.unlock();
     }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
index 72d703b..7984d7e 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.account.externalids;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.cache.Cache;
-import com.google.common.cache.CacheLoader;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
@@ -36,7 +36,6 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.io.IOException;
@@ -45,7 +44,9 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -58,7 +59,7 @@
 
 /** Loads cache values for the external ID cache using either a full or a partial reload. */
 @Singleton
-public class ExternalIdCacheLoader extends CacheLoader<ObjectId, AllExternalIds> {
+public class ExternalIdCacheLoader {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   // Maximum number of prior states we inspect to find a base for differential. If no cached state
@@ -66,12 +67,11 @@
   private static final int MAX_HISTORY_LOOKBACK = 10;
 
   private final ExternalIdReader externalIdReader;
-  private final Provider<Cache<ObjectId, AllExternalIds>> externalIdCache;
+  private final Cache<ObjectId, AllExternalIds> externalIdCache;
   private final GitRepositoryManager gitRepositoryManager;
   private final AllUsersName allUsersName;
   private final Counter1<Boolean> reloadCounter;
   private final Timer0 reloadDifferential;
-  private final boolean enablePartialReloads;
   private final boolean isPersistentCache;
   private final ExternalIdFactory externalIdFactory;
 
@@ -80,8 +80,7 @@
       GitRepositoryManager gitRepositoryManager,
       AllUsersName allUsersName,
       ExternalIdReader externalIdReader,
-      @Named(ExternalIdCacheImpl.CACHE_NAME)
-          Provider<Cache<ObjectId, AllExternalIds>> externalIdCache,
+      @Named(ExternalIdCacheImpl.CACHE_NAME) Cache<ObjectId, AllExternalIds> externalIdCache,
       MetricMaker metricMaker,
       @GerritServerConfig Config config,
       ExternalIdFactory externalIdFactory) {
@@ -105,23 +104,13 @@
                     "Latency for generating a new external ID cache state from a prior state.")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS));
-    this.enablePartialReloads =
-        config.getBoolean("cache", ExternalIdCacheImpl.CACHE_NAME, "enablePartialReloads", true);
     this.isPersistentCache =
         config.getInt("cache", ExternalIdCacheImpl.CACHE_NAME, "diskLimit", 0) > 0;
     this.externalIdFactory = externalIdFactory;
   }
 
-  @Override
   public AllExternalIds load(ObjectId notesRev) throws IOException, ConfigInvalidException {
-    if (!enablePartialReloads) {
-      logger.atInfo().log(
-          "Partial reloads of "
-              + ExternalIdCacheImpl.CACHE_NAME
-              + " disabled. Falling back to full reload.");
-      return reloadAllExternalIds(notesRev);
-    }
-
+    externalIdReader.checkReadEnabled();
     // The requested value was not in the cache (hence, this loader was invoked). Therefore, try to
     // create this entry from a past value using the minimal amount of Git operations possible to
     // reduce latency.
@@ -158,7 +147,7 @@
       while ((parentWithCacheValue = rw.next()) != null
           && i++ < MAX_HISTORY_LOOKBACK
           && parentWithCacheValue.getParentCount() < 2) {
-        oldExternalIds = externalIdCache.get().getIfPresent(parentWithCacheValue.getId());
+        oldExternalIds = externalIdCache.getIfPresent(parentWithCacheValue.getId());
         if (oldExternalIds != null) {
           // We found a previously cached state.
           break;
@@ -198,8 +187,17 @@
         }
       }
 
-      AllExternalIds allExternalIds =
-          buildAllExternalIds(repo, oldExternalIds, additions, removals);
+      AllExternalIds allExternalIds;
+      try {
+        allExternalIds = buildAllExternalIds(repo, oldExternalIds, additions, removals);
+      } catch (IllegalArgumentException e) {
+        Set<String> additionKeys =
+            additions.keySet().stream().map(AnyObjectId::getName).collect(Collectors.toSet());
+        logger.atSevere().withCause(e).log(
+            "Failed to load external ID cache. Repository ref is %s, cache ref is %s, additions are %s",
+            extIdRef.getObjectId().getName(), parentWithCacheValue.getId().getName(), additionKeys);
+        throw e;
+      }
       reloadCounter.increment(true);
       reloadDifferential.record(System.nanoTime() - start, TimeUnit.NANOSECONDS);
       return allExternalIds;
@@ -216,18 +214,25 @@
    *
    * <p>Removals are applied before additions.
    *
+   * <p>This method is accessible in tests to simulate an inconsistent cache status. It wouldn't be
+   * possible to simulate it by invoking "buildAllExternalIds" from the caller
+   * ExternalIdCacheLoader#load.
+   *
    * @param repo open repository
    * @param oldExternalIds prior state that is used as base
    * @param additions map of name to blob ID for each external ID that should be added
    * @param removals set of name {@link ObjectId}s that should be removed
    */
-  private AllExternalIds buildAllExternalIds(
+  @VisibleForTesting
+  AllExternalIds buildAllExternalIds(
       Repository repo,
       AllExternalIds oldExternalIds,
       Map<ObjectId, ObjectId> additions,
       Set<ObjectId> removals)
       throws IOException {
-    ImmutableMap.Builder<ExternalId.Key, ExternalId> byKey = ImmutableMap.builder();
+    // Make sure the addition of external-ids deltas is idempotent in the
+    // key -> external-id map, allowing retry cycles
+    HashMap<ExternalId.Key, ExternalId> byKeyMutableMap = new HashMap<>();
     ImmutableSetMultimap.Builder<Account.Id, ExternalId> byAccount = ImmutableSetMultimap.builder();
     ImmutableSetMultimap.Builder<String, ExternalId> byEmail = ImmutableSetMultimap.builder();
 
@@ -237,7 +242,7 @@
         continue;
       }
 
-      byKey.put(externalId.key(), externalId);
+      byKeyMutableMap.put(externalId.key(), externalId);
       byAccount.put(externalId.accountId(), externalId);
       if (externalId.email() != null) {
         byEmail.put(externalId.email(), externalId);
@@ -260,14 +265,17 @@
           continue;
         }
 
-        byKey.put(parsedExternalId.key(), parsedExternalId);
+        byKeyMutableMap.put(parsedExternalId.key(), parsedExternalId);
         byAccount.put(parsedExternalId.accountId(), parsedExternalId);
         if (parsedExternalId.email() != null) {
           byEmail.put(parsedExternalId.email(), parsedExternalId);
         }
       }
     }
-    return new AutoValue_AllExternalIds(byKey.build(), byAccount.build(), byEmail.build());
+    return new AutoValue_AllExternalIds(
+        ImmutableMap.<ExternalId.Key, ExternalId>builder().putAll(byKeyMutableMap).build(),
+        byAccount.build(),
+        byEmail.build());
   }
 
   private AllExternalIds reloadAllExternalIds(ObjectId notesRev)
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java
index f0ad1b2..1873ea0 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java
@@ -35,7 +35,6 @@
         // Guava calls the loader first and evicts later on.
         .maximumWeight(2)
         .expireFromMemoryAfterAccess(Duration.ofMinutes(1))
-        .loader(ExternalIdCacheLoader.class)
         .diskLimit(-1)
         .version(1)
         .keySerializer(ObjectIdCacheSerializer.INSTANCE)
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
index a59e935..a6ee366c 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
@@ -127,11 +127,11 @@
                   isUserNameCaseInsensitive ? "" : "in"));
           extIdNotes.commit(metaDataUpdate);
         } catch (Exception e) {
-          logger.atSevere().withCause(e).log(e.getMessage());
+          logger.atSevere().withCause(e).log("%s", e.getMessage());
         }
       }
     } catch (DuplicateExternalIdKeyException e) {
-      logger.atSevere().withCause(e).log(e.getMessage());
+      logger.atSevere().withCause(e).log("%s", e.getMessage());
       throw e;
     }
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
index bd9c7df..b16f73f 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
@@ -19,7 +19,6 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.HashedPassword;
@@ -33,7 +32,6 @@
 
 @Singleton
 public class ExternalIdFactory {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private final ExternalIdKeyFactory externalIdKeyFactory;
   private AuthConfig authConfig;
 
@@ -264,7 +262,8 @@
         throw invalidConfig(
             noteId,
             String.format(
-                "Neither case sensitive nor case insensitive SHA1 of external ID '%s' match note ID '%s'",
+                "Neither case sensitive nor case insensitive SHA1 of external ID '%s' match note ID"
+                    + " '%s'",
                 externalIdKeyStr, noteId));
       }
       externalIdKey =
@@ -316,15 +315,17 @@
       }
       return accountId;
     } catch (IllegalArgumentException e) {
-      String msg =
-          String.format(
-              "Value %s for '%s.%s.%s' is invalid, expected account ID",
-              accountIdStr,
-              ExternalId.EXTERNAL_ID_SECTION,
-              externalIdKeyStr,
-              ExternalId.ACCOUNT_ID_KEY);
-      logger.atSevere().withCause(e).log(msg);
-      throw invalidConfig(noteId, msg);
+      ConfigInvalidException newException =
+          invalidConfig(
+              noteId,
+              String.format(
+                  "Value %s for '%s.%s.%s' is invalid, expected account ID",
+                  accountIdStr,
+                  ExternalId.EXTERNAL_ID_SECTION,
+                  externalIdKeyStr,
+                  ExternalId.ACCOUNT_ID_KEY));
+      newException.initCause(e);
+      throw newException;
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index aa37451..b0618ba 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -167,17 +167,6 @@
         cacheUpdate.execute(updates);
       }
 
-      // Perform the cache update.
-      if (!externalIdNotes.noCacheUpdate) {
-        // Regardless of noCacheUpdate it's still possible that the ExternalIdCache instance is of
-        // type DisabledExternalIdCache, making this call a no-op.
-        externalIdCache.onReplace(
-            externalIdNotes.oldRev,
-            externalIdNotes.getRevision(),
-            updates.getRemoved(),
-            updates.getAdded());
-      }
-
       // Reindex accounts (if the subclass implements reindexAccount()).
       if (!externalIdNotes.noReindex) {
         Streams.concat(updates.getAdded().stream(), updates.getRemoved().stream())
@@ -331,14 +320,13 @@
             externalIdFactory,
             isUserNameCaseInsensitiveMigrationMode)
         .setReadOnly()
-        .setNoCacheUpdate()
         .setNoReindex()
         .load(rev);
   }
 
   /**
-   * Loads the external ID notes for updates without cache evictions. The external ID notes are
-   * loaded from the current tip of the {@code refs/meta/external-ids} branch.
+   * Loads the external ID notes for updates. The external ID notes are loaded from the current tip
+   * of the {@code refs/meta/external-ids} branch.
    *
    * <p>Use this only from init, schema upgrades and tests.
    *
@@ -346,7 +334,7 @@
    *
    * @return {@link ExternalIdNotes} instance that doesn't updates caches on save
    */
-  public static ExternalIdNotes loadNoCacheUpdate(
+  public static ExternalIdNotes load(
       AllUsersName allUsersName,
       Repository allUsersRepo,
       ExternalIdFactory externalIdFactory,
@@ -359,7 +347,6 @@
             DynamicMap.emptyMap(),
             externalIdFactory,
             isUserNameCaseInsensitiveMigrationMode)
-        .setNoCacheUpdate()
         .setNoReindex()
         .load();
   }
@@ -393,7 +380,6 @@
 
   private Runnable afterReadRevision;
   private boolean readOnly = false;
-  private boolean noCacheUpdate = false;
   private boolean noReindex = false;
   private boolean isUserNameCaseInsensitiveMigrationMode = false;
   protected final Function<ExternalId, ObjectId> defaultNoteIdResolver =
@@ -450,11 +436,6 @@
     return this;
   }
 
-  private ExternalIdNotes setNoCacheUpdate() {
-    noCacheUpdate = true;
-    return this;
-  }
-
   private ExternalIdNotes setNoReindex() {
     noReindex = true;
     return this;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
index e5aace0..fb7f6c4 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -103,6 +103,12 @@
     this.failOnLoad = failOnLoad;
   }
 
+  public void checkReadEnabled() throws IOException {
+    if (failOnLoad) {
+      throw new IOException("Reading from external IDs is disabled");
+    }
+  }
+
   ObjectId readRevision() throws IOException {
     try (Repository repo = repoManager.openRepository(allUsersName)) {
       return readRevision(repo);
@@ -182,10 +188,4 @@
           .get(key);
     }
   }
-
-  private void checkReadEnabled() throws IOException {
-    if (failOnLoad) {
-      throw new IOException("Reading from external IDs is disabled");
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
index e52991b..72e7e90 100644
--- a/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
+++ b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
@@ -70,7 +70,9 @@
   public void migrate() {
     if (!isUserNameCaseInsensitive || !isUserNameCaseInsensitiveMigrationMode) {
       logger.atSevere().log(
-          "External IDs online migration requires auth.userNameCaseInsensitive and auth.userNameCaseInsensitiveMigrationMode to be set to true. Skipping migration!");
+          "External IDs online migration requires auth.userNameCaseInsensitive and"
+              + " auth.userNameCaseInsensitiveMigrationMode to be set to true. Skipping"
+              + " migration!");
       return;
     }
     executor.execute(
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 764c46d..9aa9306 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -86,7 +86,6 @@
 import com.google.gerrit.server.restapi.change.ListRobotComments;
 import com.google.gerrit.server.restapi.change.Mergeable;
 import com.google.gerrit.server.restapi.change.PostReview;
-import com.google.gerrit.server.restapi.change.PreviewSubmit;
 import com.google.gerrit.server.restapi.change.PutDescription;
 import com.google.gerrit.server.restapi.change.Rebase;
 import com.google.gerrit.server.restapi.change.Reviewed;
@@ -119,7 +118,6 @@
   private final Rebase rebase;
   private final RebaseUtil rebaseUtil;
   private final Submit submit;
-  private final PreviewSubmit submitPreview;
   private final Reviewed.PutReviewed putReviewed;
   private final Reviewed.DeleteReviewed deleteReviewed;
   private final RevisionResource revision;
@@ -167,7 +165,6 @@
       Rebase rebase,
       RebaseUtil rebaseUtil,
       Submit submit,
-      PreviewSubmit submitPreview,
       Reviewed.PutReviewed putReviewed,
       Reviewed.DeleteReviewed deleteReviewed,
       Files files,
@@ -213,7 +210,6 @@
     this.rebaseUtil = rebaseUtil;
     this.review = review;
     this.submit = submit;
-    this.submitPreview = submitPreview;
     this.files = files;
     this.putReviewed = putReviewed;
     this.deleteReviewed = deleteReviewed;
@@ -270,16 +266,6 @@
   }
 
   @Override
-  public BinaryResult submitPreview(String format) throws RestApiException {
-    try {
-      submitPreview.setFormat(format);
-      return submitPreview.apply(revision).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get submit preview", e);
-    }
-  }
-
-  @Override
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
       return changes.id(rebaseAsInfo(in)._number);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 9521759..5baed86 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -507,7 +507,7 @@
 
   @Override
   public ListRefsRequest<BranchInfo> branches() {
-    return new ListRefsRequest<BranchInfo>() {
+    return new ListRefsRequest<>() {
       @Override
       public List<BranchInfo> get() throws RestApiException {
         try {
@@ -521,7 +521,7 @@
 
   @Override
   public ListRefsRequest<TagInfo> tags() {
-    return new ListRefsRequest<TagInfo>() {
+    return new ListRefsRequest<>() {
       @Override
       public List<TagInfo> get() throws RestApiException {
         try {
diff --git a/java/com/google/gerrit/server/approval/ApprovalCache.java b/java/com/google/gerrit/server/approval/ApprovalCache.java
deleted file mode 100644
index 5637249..0000000
--- a/java/com/google/gerrit/server/approval/ApprovalCache.java
+++ /dev/null
@@ -1,28 +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.approval;
-
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.server.notedb.ChangeNotes;
-
-/**
- * Cache that holds approvals per patch set and NoteDb state. This includes approvals copied forward
- * from older patch sets.
- */
-public interface ApprovalCache {
-  /** Returns {@link PatchSetApproval}s for the given patch set. */
-  Iterable<PatchSetApproval> get(ChangeNotes notes, PatchSet.Id psId);
-}
diff --git a/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java b/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java
deleted file mode 100644
index fd31da9..0000000
--- a/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java
+++ /dev/null
@@ -1,133 +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.approval;
-
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableList;
-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.converter.PatchSetApprovalProtoConverter;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.proto.Entities;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.proto.Cache;
-import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
-import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Singleton;
-import com.google.inject.name.Named;
-import com.google.protobuf.ByteString;
-import java.util.concurrent.ExecutionException;
-
-/** Implementation of the {@link ApprovalCache} interface */
-public class ApprovalCacheImpl implements ApprovalCache {
-  private static final String CACHE_NAME = "approvals";
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        bind(ApprovalCache.class).to(ApprovalCacheImpl.class);
-        persist(
-                CACHE_NAME,
-                Cache.PatchSetApprovalsKeyProto.class,
-                Cache.AllPatchSetApprovalsProto.class)
-            .version(2)
-            .loader(Loader.class)
-            .keySerializer(new ProtobufSerializer<>(Cache.PatchSetApprovalsKeyProto.parser()))
-            .valueSerializer(new ProtobufSerializer<>(Cache.AllPatchSetApprovalsProto.parser()));
-      }
-    };
-  }
-
-  private final LoadingCache<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto>
-      cache;
-
-  @Inject
-  ApprovalCacheImpl(
-      @Named(CACHE_NAME)
-          LoadingCache<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto> cache) {
-    this.cache = cache;
-  }
-
-  @Override
-  public Iterable<PatchSetApproval> get(ChangeNotes notes, PatchSet.Id psId) {
-    try {
-      return fromProto(
-          cache.get(
-              Cache.PatchSetApprovalsKeyProto.newBuilder()
-                  .setChangeId(notes.getChangeId().get())
-                  .setPatchSetId(psId.get())
-                  .setProject(notes.getProjectName().get())
-                  .setId(
-                      ByteString.copyFrom(
-                          ObjectIdCacheSerializer.INSTANCE.serialize(notes.getMetaId())))
-                  .build()));
-    } catch (ExecutionException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  @Singleton
-  static class Loader
-      extends CacheLoader<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto> {
-    private final ApprovalInference approvalInference;
-    private final ChangeNotes.Factory changeNotesFactory;
-
-    @Inject
-    Loader(ApprovalInference approvalInference, ChangeNotes.Factory changeNotesFactory) {
-      this.approvalInference = approvalInference;
-      this.changeNotesFactory = changeNotesFactory;
-    }
-
-    @Override
-    public Cache.AllPatchSetApprovalsProto load(Cache.PatchSetApprovalsKeyProto key)
-        throws Exception {
-      Change.Id changeId = Change.id(key.getChangeId());
-      return toProto(
-          approvalInference.forPatchSet(
-              changeNotesFactory.createChecked(
-                  Project.nameKey(key.getProject()),
-                  changeId,
-                  ObjectIdCacheSerializer.INSTANCE.deserialize(key.getId().toByteArray())),
-              PatchSet.id(changeId, key.getPatchSetId()),
-              null
-              /* revWalk= */ ,
-              null
-              /* repoConfig= */ ));
-    }
-  }
-
-  private static Iterable<PatchSetApproval> fromProto(Cache.AllPatchSetApprovalsProto proto) {
-    ImmutableList.Builder<PatchSetApproval> builder = ImmutableList.builder();
-    for (Entities.PatchSetApproval psa : proto.getApprovalList()) {
-      builder.add(PatchSetApprovalProtoConverter.INSTANCE.fromProto(psa));
-    }
-    return builder.build();
-  }
-
-  private static Cache.AllPatchSetApprovalsProto toProto(Iterable<PatchSetApproval> autoValue) {
-    Cache.AllPatchSetApprovalsProto.Builder builder = Cache.AllPatchSetApprovalsProto.newBuilder();
-    for (PatchSetApproval psa : autoValue) {
-      builder.addApproval(PatchSetApprovalProtoConverter.INSTANCE.toProto(psa));
-    }
-    return builder.build();
-  }
-}
diff --git a/java/com/google/gerrit/server/approval/ApprovalInference.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java
similarity index 83%
rename from java/com/google/gerrit/server/approval/ApprovalInference.java
rename to java/com/google/gerrit/server/approval/ApprovalCopier.java
index 695997a..31380f4 100644
--- a/java/com/google/gerrit/server/approval/ApprovalInference.java
+++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -28,6 +28,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;
@@ -39,7 +40,8 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.approval.ApprovalContext;
@@ -49,8 +51,8 @@
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
@@ -59,15 +61,13 @@
 
 /**
  * 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.
+ * and by additionally copying approvals from the previous patch set. The latter is done by
+ * asserting a change's kind and checking the project config for copy conditions.
  *
- * <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>The result of a copy is stored in NoteDb when a new patch set is created.
  */
 @Singleton
-class ApprovalInference {
+class ApprovalCopier {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final DiffOperations diffOperations;
@@ -79,7 +79,7 @@
   private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate;
 
   @Inject
-  ApprovalInference(
+  ApprovalCopier(
       DiffOperations diffOperations,
       ProjectCache projectCache,
       ChangeKindCache changeKindCache,
@@ -97,20 +97,11 @@
   }
 
   /**
-   * Returns all approvals that apply to the given patch set. Honors direct and indirect (approval
-   * on parents) approvals.
+   * Returns all approvals that apply to the given patch set. Honors copied approvals from previous
+   * patch-set.
    */
   Iterable<PatchSetApproval> forPatchSet(
-      ChangeNotes notes, PatchSet.Id psId, @Nullable RevWalk rw, @Nullable Config repoConfig) {
-    PatchSet patchset = notes.getPatchSets().get(psId);
-    if (patchset == null) {
-      return Collections.emptyList();
-    }
-    return forPatchSet(notes, patchset, rw, repoConfig);
-  }
-
-  Iterable<PatchSetApproval> forPatchSet(
-      ChangeNotes notes, PatchSet ps, @Nullable RevWalk rw, @Nullable Config repoConfig) {
+      ChangeNotes notes, PatchSet ps, RevWalk rw, Config repoConfig) {
     ProjectState project;
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
@@ -134,10 +125,11 @@
       PatchSetApproval psa,
       PatchSet.Id psId,
       ChangeKind kind,
+      boolean isMerge,
       LabelType type,
-      @Nullable Map<String, FileDiffOutput> baseVsCurrentDiff,
-      @Nullable Map<String, FileDiffOutput> baseVsPriorDiff,
-      @Nullable Map<String, FileDiffOutput> priorVsCurrentDiff) {
+      @Nullable Map<String, ModifiedFile> baseVsCurrentDiff,
+      @Nullable Map<String, ModifiedFile> baseVsPriorDiff,
+      @Nullable Map<String, ModifiedFile> priorVsCurrentDiff) {
     int n = psa.key().patchSetId().get();
     checkArgument(n != psId.get());
 
@@ -281,7 +273,7 @@
               project.getName());
           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"
@@ -325,11 +317,16 @@
       PatchSetApproval psa,
       PatchSet patchSet,
       LabelType type,
-      ChangeKind changeKind) {
+      ChangeKind changeKind,
+      boolean isMerge,
+      RevWalk revWalk,
+      Config repoConfig) {
     if (!type.getCopyCondition().isPresent()) {
       return false;
     }
-    ApprovalContext ctx = ApprovalContext.create(changeNotes, psa, patchSet, changeKind);
+    ApprovalContext ctx =
+        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
@@ -346,11 +343,7 @@
   }
 
   private Collection<PatchSetApproval> getForPatchSetWithoutNormalization(
-      ChangeNotes notes,
-      ProjectState project,
-      PatchSet patchSet,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig) {
+      ChangeNotes notes, ProjectState project, PatchSet patchSet, RevWalk rw, Config repoConfig) {
     checkState(
         project.getNameKey().equals(notes.getProjectName()),
         "project must match %s, %s",
@@ -360,34 +353,23 @@
     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> approvalsForGivenPatchSet =
+    ImmutableList<PatchSetApproval> nonCopiedApprovalsForGivenPatchSet =
         notes.load().getApprovals().get(patchSet.id());
-    approvalsForGivenPatchSet.forEach(psa -> resultByUser.put(psa.label(), psa.accountId(), psa));
+    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();
     }
-
-    // Call this algorithm recursively to check if the prior patch set had approvals. This has the
-    // advantage that all caches - most importantly ChangeKindCache - have values cached for what we
-    // need for this computation.
-    // The way this algorithm is written is that any approval will be copied forward by one patch
-    // set at a time if configs and change kind allow so. Once an approval is held back - for
-    // example because the patch set is a REWORK - it will not be picked up again in a future
-    // patch set.
     Map.Entry<PatchSet.Id, PatchSet> priorPatchSet = notes.load().getPatchSets().lowerEntry(psId);
     if (priorPatchSet == null) {
       return resultByUser.values();
     }
 
-    Iterable<PatchSetApproval> priorApprovals =
-        getForPatchSetWithoutNormalization(
-            notes, project, priorPatchSet.getValue(), rw, repoConfig);
-    if (!priorApprovals.iterator().hasNext()) {
-      return resultByUser.values();
-    }
+    ImmutableList<PatchSetApproval> priorApprovalsIncludingCopied =
+        notes.load().getApprovalsWithCopied().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.
@@ -398,6 +380,7 @@
             repoConfig,
             priorPatchSet.getValue().commitId(),
             patchSet.commitId());
+    boolean isMerge = isMerge(project.getNameKey(), rw, patchSet);
     logger.atFine().log(
         "change kind for patch set %d of change %d against prior patch set %s is %s",
         patchSet.id().get(),
@@ -405,11 +388,11 @@
         priorPatchSet.getValue().id().changeId(),
         changeKind);
 
-    Map<String, FileDiffOutput> baseVsCurrent = null;
-    Map<String, FileDiffOutput> baseVsPrior = null;
-    Map<String, FileDiffOutput> priorVsCurrent = null;
+    Map<String, ModifiedFile> baseVsCurrent = null;
+    Map<String, ModifiedFile> baseVsPrior = null;
+    Map<String, ModifiedFile> priorVsCurrent = null;
     LabelTypes labelTypes = project.getLabelTypes();
-    for (PatchSetApproval psa : priorApprovals) {
+    for (PatchSetApproval psa : priorApprovalsIncludingCopied) {
       if (resultByUser.contains(psa.label(), psa.accountId())) {
         continue;
       }
@@ -418,10 +401,11 @@
       if (baseVsCurrent == null
           && type.isPresent()
           && type.get().isCopyAllScoresIfListOfFilesDidNotChange()) {
-        baseVsCurrent = listModifiedFiles(project, patchSet);
-        baseVsPrior = listModifiedFiles(project, priorPatchSet.getValue());
+        baseVsCurrent = listModifiedFiles(project, patchSet, rw, repoConfig);
+        baseVsPrior = listModifiedFiles(project, priorPatchSet.getValue(), rw, repoConfig);
         priorVsCurrent =
-            listModifiedFiles(project, priorPatchSet.getValue().commitId(), patchSet.commitId());
+            listModifiedFiles(
+                project, priorPatchSet.getValue().commitId(), patchSet.commitId(), rw, repoConfig);
       }
       if (!type.isPresent()) {
         logger.atFine().log(
@@ -440,11 +424,13 @@
               psa,
               patchSet.id(),
               changeKind,
+              isMerge,
               type.get(),
               baseVsCurrent,
               baseVsPrior,
               priorVsCurrent)
-          && !canCopyBasedOnCopyCondition(notes, psa, patchSet, type.get(), changeKind)) {
+          && !canCopyBasedOnCopyCondition(
+              notes, psa, patchSet, type.get(), changeKind, isMerge, rw, repoConfig)) {
         continue;
       }
       resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(patchSet.id()));
@@ -452,18 +438,36 @@
     return resultByUser.values();
   }
 
+  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);
+    }
+  }
+
   /**
    * Gets the modified files between the two latest patch-sets. Can be used to compute difference in
    * files between those two patch-sets .
    */
-  private Map<String, FileDiffOutput> listModifiedFiles(ProjectState project, PatchSet ps) {
+  private Map<String, ModifiedFile> listModifiedFiles(
+      ProjectState project, PatchSet ps, RevWalk revWalk, Config repoConfig) {
     try {
       Integer parentNum =
           listOfFilesUnchangedPredicate.isInitialCommit(project.getNameKey(), ps.commitId())
               ? 0
               : 1;
-      return diffOperations.listModifiedFilesAgainstParent(
-          project.getNameKey(), ps.commitId(), parentNum);
+      return diffOperations.loadModifiedFilesAgainstParent(
+          project.getNameKey(),
+          ps.commitId(),
+          parentNum,
+          DiffOptions.DEFAULTS,
+          revWalk,
+          repoConfig);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't copy"
@@ -477,10 +481,20 @@
    * Gets the modified files between two commits corresponding to different patchsets of the same
    * change.
    */
-  private Map<String, FileDiffOutput> listModifiedFiles(
-      ProjectState project, ObjectId sourceCommit, ObjectId targetCommit) {
+  private Map<String, ModifiedFile> listModifiedFiles(
+      ProjectState project,
+      ObjectId sourceCommit,
+      ObjectId targetCommit,
+      RevWalk revWalk,
+      Config repoConfig) {
     try {
-      return diffOperations.listModifiedFiles(project.getNameKey(), sourceCommit, targetCommit);
+      return diffOperations.loadModifiedFiles(
+          project.getNameKey(),
+          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 7744f49..e76591b 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -20,16 +20,12 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 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.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
-import com.google.common.collect.Table;
 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.LabelId;
@@ -57,10 +53,10 @@
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Date;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -84,7 +80,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static PatchSetApproval.Builder newApproval(
-      PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Date when) {
+      PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Instant when) {
     PatchSetApproval.Builder b =
         PatchSetApproval.builder()
             .key(PatchSetApproval.key(psId, user.getAccountId(), labelId))
@@ -99,24 +95,21 @@
     return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId));
   }
 
-  private final ApprovalInference approvalInference;
+  private final ApprovalCopier approvalInference;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
-  private final ApprovalCache approvalCache;
   private final LabelNormalizer labelNormalizer;
 
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
-      ApprovalInference approvalInference,
+      ApprovalCopier approvalInference,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
-      ApprovalCache approvalCache,
       LabelNormalizer labelNormalizer) {
     this.approvalInference = approvalInference;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
-    this.approvalCache = approvalCache;
     this.labelNormalizer = labelNormalizer;
   }
 
@@ -230,10 +223,7 @@
           .statePermitsRead()) {
         return false;
       }
-      permissionBackend.absentUser(accountId).change(notes).check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
+      return permissionBackend.absentUser(accountId).change(notes).test(ChangePermission.READ);
     } catch (PermissionBackendException e) {
       logger.atWarning().withCause(e).log(
           "Failed to check if account %d can see change %d",
@@ -304,7 +294,7 @@
     }
     checkApprovals(approvals, permissionBackend.user(user).change(update.getNotes()));
     List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
-    Date ts = update.getWhen();
+    Instant ts = update.getWhen();
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       Optional<LabelType> lt = labelTypes.byLabel(vote.getKey());
       if (!lt.isPresent()) {
@@ -337,16 +327,15 @@
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       String name = vote.getKey();
       Short value = vote.getValue();
-      try {
-        forChange.check(new LabelPermission.WithValue(name, value));
-      } catch (AuthException e) {
+      if (!forChange.test(new LabelPermission.WithValue(name, value))) {
         throw new AuthException(
-            String.format("applying label \"%s\": %d is restricted", name, value), e);
+            String.format("applying label \"%s\": %d is restricted", name, value));
       }
     }
   }
 
-  public ListMultimap<PatchSet.Id, PatchSetApproval> byChange(ChangeNotes notes) {
+  public ListMultimap<PatchSet.Id, PatchSetApproval> byChangeExcludingCopiedApprovals(
+      ChangeNotes notes) {
     return notes.load().getApprovals();
   }
 
@@ -354,15 +343,6 @@
     return notes.load().getApprovalsWithCopied();
   }
 
-  public Iterable<PatchSetApproval> byPatchSet(
-      ChangeNotes notes, PatchSet.Id psId, @Nullable RevWalk rw, @Nullable Config repoConfig) {
-    return approvalInference.forPatchSet(notes, psId, rw, repoConfig);
-  }
-
-  public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet patchSet) {
-    return approvalInference.forPatchSet(notes, patchSet, /* rw= */ null, /* repoConfig= */ null);
-  }
-
   /**
    * 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
@@ -377,46 +357,23 @@
       RevWalk revWalk,
       Config repoConfig,
       ChangeUpdate changeUpdate) {
-    Set<PatchSetApproval> current =
-        ImmutableSet.copyOf(notes.getApprovalsWithCopied().get(notes.getCurrentPatchSet().id()));
-    Iterable<PatchSetApproval> currentNormalized =
-        labelNormalizer.normalize(notes, current).getNormalized();
-    Set<PatchSetApproval> inferred =
-        ImmutableSet.copyOf(approvalInference.forPatchSet(notes, patchSet, revWalk, repoConfig));
-
-    // Exempt granted timestamp from comparisson, otherwise, we would persist the copied
-    // labels every time this method is called.
-    Table<LabelId, Account.Id, Short> approvalTable = HashBasedTable.create();
-    for (PatchSetApproval psa : currentNormalized) {
-      Account.Id id = psa.accountId();
-      approvalTable.put(psa.labelId(), id, psa.value());
-    }
-
-    for (PatchSetApproval psa : inferred) {
-      if (psa.value() != 0) {
-        if (approvalTable.contains(psa.labelId(), psa.accountId())) {
-          Short v = approvalTable.get(psa.labelId(), psa.accountId());
-          if (v.shortValue() != psa.value()) {
-            changeUpdate.putCopiedApproval(psa);
-          }
-        } else {
-          changeUpdate.putCopiedApproval(psa);
-        }
-      }
-    }
+    approvalInference
+        .forPatchSet(notes, patchSet, revWalk, repoConfig)
+        .forEach(a -> changeUpdate.putCopiedApproval(a));
   }
 
+  /**
+   * Gets {@link PatchSetApproval}s for a specified patch-set. The result includes copied votes but
+   * does not include deleted labels.
+   *
+   * @param notes changenotes of the change.
+   * @param psId patch-set id for the change and patch-set we want to get approvals.
+   * @return all approvals for the specified patch-set, including copied votes, not including
+   *     deleted labels.
+   */
   public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
-    return approvalCache.get(notes, psId);
-  }
-
-  public Iterable<PatchSetApproval> byPatchSetUser(
-      ChangeNotes notes,
-      PatchSet.Id psId,
-      Account.Id accountId,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig) {
-    return filterApprovals(byPatchSet(notes, psId, rw, repoConfig), accountId);
+    List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovalsWithCopied().get(psId);
+    return labelNormalizer.normalize(notes, approvalsNotNormalized).getNormalized();
   }
 
   public Iterable<PatchSetApproval> byPatchSetUser(
@@ -429,8 +386,8 @@
       return null;
     }
     try {
-      // Submit approval is never copied, so bypass expensive byPatchSet call.
-      return getSubmitter(c, byChange(notes).get(c));
+      // Submit approval is never copied.
+      return getSubmitter(c, byChangeExcludingCopiedApprovals(notes).get(c));
     } catch (StorageException e) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGenerator.java b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGenerator.java
new file mode 100644
index 0000000..128bee4
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGenerator.java
@@ -0,0 +1,40 @@
+// 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;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApproval.UUID;
+import com.google.inject.ImplementedBy;
+import java.time.Instant;
+
+/**
+ * Generator for {@link com.google.gerrit.entities.PatchSetApproval.UUID}.
+ *
+ * <p>Since {@link com.google.gerrit.entities.PatchSetApproval.UUID} must be unique for each granted
+ * {@link PatchSetApproval}, implementations must generate globally unique UUID for each {@link
+ * #get} invocation.
+ */
+@ImplementedBy(PatchSetApprovalUuidGeneratorImpl.class)
+public interface PatchSetApprovalUuidGenerator {
+
+  /**
+   * Generates {@link com.google.gerrit.entities.PatchSetApproval.UUID} based on the properties of
+   * {@link PatchSetApproval} that is being granted.
+   */
+  UUID get(
+      PatchSet.Id patchSetId, Account.Id accountId, String label, short value, Instant granted);
+}
diff --git a/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGeneratorImpl.java b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGeneratorImpl.java
new file mode 100644
index 0000000..afa0384
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGeneratorImpl.java
@@ -0,0 +1,46 @@
+// 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;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApproval.UUID;
+import com.google.inject.Singleton;
+import java.security.MessageDigest;
+import java.time.Instant;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Default implementation of {@link
+ * com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator}.
+ */
+@Singleton
+public class PatchSetApprovalUuidGeneratorImpl implements PatchSetApprovalUuidGenerator {
+  @Override
+  public UUID get(
+      PatchSet.Id patchSetId, Account.Id accountId, String label, short value, Instant granted) {
+    MessageDigest md = Constants.newMessageDigest();
+    md.update(
+        Constants.encode("patchSetId " + patchSetId.getCommaSeparatedChangeAndPatchSetId() + "\n"));
+    md.update(Constants.encode("accountId " + accountId + "\n"));
+    md.update(Constants.encode("label " + label + "\n"));
+    md.update(Constants.encode("value " + value + "\n"));
+    md.update(Constants.encode("granted " + granted.toEpochMilli() + "\n"));
+    md.update(Constants.encode(String.valueOf(Math.random())));
+    return PatchSetApproval.uuid(ObjectId.fromRaw(md.digest()).name());
+  }
+}
diff --git a/java/com/google/gerrit/server/approval/RecursiveApprovalCopier.java b/java/com/google/gerrit/server/approval/RecursiveApprovalCopier.java
deleted file mode 100644
index d5ee143..0000000
--- a/java/com/google/gerrit/server/approval/RecursiveApprovalCopier.java
+++ /dev/null
@@ -1,314 +0,0 @@
-// 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.approval;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.server.FanOutExecutor;
-import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-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.UpdateException;
-import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.function.Consumer;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.internal.storage.file.RefDirectory;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-public class RecursiveApprovalCopier {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private static final int SLICE_MAX_REFS = 1000;
-
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final GitRepositoryManager repositoryManager;
-  private final InternalUser.Factory internalUserFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeNotes.Factory changeNotesFactory;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final ListeningExecutorService executor;
-
-  private final ConcurrentHashMap<Project.NameKey, List<ReceiveCommand>> pendingRefUpdates =
-      new ConcurrentHashMap<>();
-
-  private volatile boolean failedForAtLeastOneProject;
-
-  private final AtomicInteger totalCopyApprovalsTasks = new AtomicInteger();
-  private final AtomicInteger finishedCopyApprovalsTasks = new AtomicInteger();
-
-  private final AtomicInteger totalRefUpdates = new AtomicInteger();
-  private final AtomicInteger finishedRefUpdates = new AtomicInteger();
-
-  @Inject
-  public RecursiveApprovalCopier(
-      BatchUpdate.Factory batchUpdateFactory,
-      GitRepositoryManager repositoryManager,
-      InternalUser.Factory internalUserFactory,
-      ApprovalsUtil approvalsUtil,
-      ChangeNotes.Factory changeNotesFactory,
-      GitReferenceUpdated gitRefUpdated,
-      @FanOutExecutor ExecutorService executor) {
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.repositoryManager = repositoryManager;
-    this.internalUserFactory = internalUserFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.changeNotesFactory = changeNotesFactory;
-    this.gitRefUpdated = gitRefUpdated;
-    this.executor = MoreExecutors.listeningDecorator(executor);
-  }
-
-  /**
-   * This method assumes it is used as a standalone program having exclusive access to the Git
-   * repositories. Therefore, it will (safely) skip locking of the loose refs when performing batch
-   * ref-updates.
-   */
-  public void persistStandalone()
-      throws RepositoryNotFoundException, IOException, InterruptedException, ExecutionException {
-    persist(repositoryManager.list(), null, false);
-
-    if (failedForAtLeastOneProject) {
-      throw new RuntimeException("There were errors, check the logs for details");
-    }
-  }
-
-  public void persist(Project.NameKey project, @Nullable Consumer<Change> labelsCopiedListener)
-      throws IOException, RepositoryNotFoundException, InterruptedException, ExecutionException {
-    persist(ImmutableList.of(project), labelsCopiedListener, true);
-  }
-
-  private void persist(
-      Collection<Project.NameKey> projects,
-      @Nullable Consumer<Change> labelsCopiedListener,
-      boolean shouldLockLooseRefs)
-      throws InterruptedException, ExecutionException, RepositoryNotFoundException, IOException {
-    List<ListenableFuture<Void>> copyApprovalsTasks = new LinkedList<>();
-    for (Project.NameKey project : projects) {
-      copyApprovalsTasks.addAll(submitCopyApprovalsTasks(project, labelsCopiedListener));
-    }
-    Futures.successfulAsList(copyApprovalsTasks).get();
-
-    List<ListenableFuture<Void>> batchRefUpdateTasks =
-        submitBatchRefUpdateTasks(shouldLockLooseRefs);
-    Futures.successfulAsList(batchRefUpdateTasks).get();
-  }
-
-  private List<ListenableFuture<Void>> submitCopyApprovalsTasks(
-      Project.NameKey project, @Nullable Consumer<Change> labelsCopiedListener)
-      throws RepositoryNotFoundException, IOException {
-    List<ListenableFuture<Void>> futures = new LinkedList<>();
-    try (Repository repository = repositoryManager.openRepository(project)) {
-      ImmutableList<Ref> allMetaRefs =
-          repository.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES).stream()
-              .filter(r -> r.getName().endsWith(RefNames.META_SUFFIX))
-              .collect(toImmutableList());
-
-      totalCopyApprovalsTasks.addAndGet(allMetaRefs.size());
-
-      for (List<Ref> slice : Lists.partition(allMetaRefs, SLICE_MAX_REFS)) {
-        futures.add(
-            executor.submit(
-                () -> {
-                  copyApprovalsForSlice(project, slice, labelsCopiedListener);
-                  return null;
-                }));
-      }
-    }
-    return futures;
-  }
-
-  private void copyApprovalsForSlice(
-      Project.NameKey project, List<Ref> slice, @Nullable Consumer<Change> labelsCopiedListener)
-      throws Exception {
-    try {
-      copyApprovalsForSlice(project, slice, labelsCopiedListener, false);
-    } catch (Exception e) {
-      failedForAtLeastOneProject = true;
-      logger.atSevere().withCause(e).log(
-          "Error in a slice of project %s, will retry and skip corrupt meta-refs", project);
-      copyApprovalsForSlice(project, slice, labelsCopiedListener, true);
-    }
-    logProgress();
-  }
-
-  private void copyApprovalsForSlice(
-      Project.NameKey project,
-      List<Ref> slice,
-      @Nullable Consumer<Change> labelsCopiedListener,
-      boolean checkForCorruptMetaRefs)
-      throws Exception {
-    logger.atInfo().log("copy-approvals for a slice of %s project", project);
-    try (BatchUpdate bu =
-        batchUpdateFactory.create(project, internalUserFactory.create(), TimeUtil.nowTs())) {
-      for (Ref metaRef : slice) {
-        Change.Id changeId = Change.Id.fromRef(metaRef.getName());
-        if (checkForCorruptMetaRefs && isCorrupt(project, changeId)) {
-          logger.atSevere().log("skipping corrupt meta-ref %s", metaRef.getName());
-          continue;
-        }
-        bu.addOp(
-            changeId,
-            new RecursiveApprovalCopier.PersistCopiedVotesOp(approvalsUtil, labelsCopiedListener));
-      }
-
-      BatchRefUpdate bru = bu.prepareRefUpdates();
-      if (bru != null) {
-        List<ReceiveCommand> cmds = bru.getCommands();
-        pendingRefUpdates.compute(
-            project,
-            (p, u) -> {
-              if (u == null) {
-                return new LinkedList<>(cmds);
-              }
-              u.addAll(cmds);
-              return u;
-            });
-        totalRefUpdates.addAndGet(cmds.size());
-      }
-
-      finishedCopyApprovalsTasks.addAndGet(slice.size());
-    }
-  }
-
-  private List<ListenableFuture<Void>> submitBatchRefUpdateTasks(boolean shouldLockLooseRefs) {
-    logger.atInfo().log("submitting batch ref-update tasks");
-    List<ListenableFuture<Void>> futures = new LinkedList<>();
-    for (Map.Entry<Project.NameKey, List<ReceiveCommand>> e : pendingRefUpdates.entrySet()) {
-      Project.NameKey project = e.getKey();
-      List<ReceiveCommand> updates = e.getValue();
-      futures.add(
-          executor.submit(
-              () -> {
-                executeRefUpdates(project, updates, shouldLockLooseRefs);
-                return null;
-              }));
-    }
-    return futures;
-  }
-
-  private void executeRefUpdates(
-      Project.NameKey project, List<ReceiveCommand> updates, boolean shouldLockLooseRefs)
-      throws RepositoryNotFoundException, IOException {
-    logger.atInfo().log(
-        "executing batch ref-update for project %s, size %d", project, updates.size());
-    try (Repository repository = repositoryManager.openRepository(project)) {
-      RefDatabase refdb = repository.getRefDatabase();
-      BatchRefUpdate bu;
-      if (refdb instanceof RefDirectory) {
-        bu = ((RefDirectory) refdb).newBatchUpdate(shouldLockLooseRefs);
-      } else {
-        bu = refdb.newBatchUpdate();
-      }
-      bu.addCommand(updates);
-      RefUpdateUtil.executeChecked(bu, repository);
-      gitRefUpdated.fire(project, bu, null);
-
-      finishedRefUpdates.addAndGet(updates.size());
-      logProgress();
-    }
-  }
-
-  private boolean isCorrupt(Project.NameKey project, Change.Id changeId) {
-    Change c = ChangeNotes.Factory.newChange(project, changeId);
-    try {
-      changeNotesFactory.createForBatchUpdate(c, true);
-      return false;
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log(e.getMessage());
-      return true;
-    }
-  }
-
-  public void persist(Change change) throws UpdateException, RestApiException {
-    Project.NameKey project = change.getProject();
-    try (BatchUpdate bu =
-        batchUpdateFactory.create(project, internalUserFactory.create(), TimeUtil.nowTs())) {
-      Change.Id changeId = change.getId();
-      bu.addOp(changeId, new PersistCopiedVotesOp(approvalsUtil, null));
-      bu.execute();
-    }
-  }
-
-  private void logProgress() {
-    logger.atInfo().log(
-        "copy-approvals tasks done: %d/%d, ref-update tasks done: %d/%d",
-        finishedCopyApprovalsTasks.get(),
-        totalCopyApprovalsTasks.get(),
-        finishedRefUpdates.get(),
-        totalRefUpdates.get());
-  }
-
-  private static class PersistCopiedVotesOp implements BatchUpdateOp {
-    private final ApprovalsUtil approvalsUtil;
-    private final Consumer<Change> listener;
-
-    PersistCopiedVotesOp(
-        ApprovalsUtil approvalsUtil, @Nullable Consumer<Change> labelsCopiedListener) {
-      this.approvalsUtil = approvalsUtil;
-      this.listener = labelsCopiedListener;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws IOException {
-      Change change = ctx.getChange();
-      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId(), change.getLastUpdatedOn());
-      approvalsUtil.persistCopiedApprovals(
-          ctx.getNotes(),
-          ctx.getNotes().getCurrentPatchSet(),
-          ctx.getRevWalk(),
-          ctx.getRepoView().getConfig(),
-          update);
-
-      boolean labelsCopied = update.hasCopiedApprovals();
-
-      if (labelsCopied && listener != null) {
-        listener.accept(change);
-      }
-
-      return labelsCopied;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
new file mode 100644
index 0000000..89727c7
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
@@ -0,0 +1,49 @@
+// 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;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApproval.UUID;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
+import java.time.Instant;
+import javax.inject.Singleton;
+
+/**
+ * Implementation of {@link PatchSetApprovalUuidGenerator} that returns predictable {@link UUID}.
+ */
+@Singleton
+public class TestPatchSetApprovalUuidGenerator implements PatchSetApprovalUuidGenerator {
+
+  private int invocationCount = 0;
+
+  @Override
+  public UUID get(
+      PatchSet.Id patchSetId, Account.Id accountId, String label, short value, Instant granted) {
+    invocationCount++;
+    return PatchSetApproval.uuid(
+        String.format(
+                "%s_%s_%s_%s_%s_%s",
+                patchSetId.changeId().get(),
+                patchSetId.get(),
+                accountId.get(),
+                label,
+                value,
+                invocationCount)
+            .replace("-", "_")
+            .toLowerCase());
+  }
+}
diff --git a/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
index 5df4d28..94dc4c3 100644
--- a/java/com/google/gerrit/server/args4j/AccountIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
@@ -90,9 +90,9 @@
         }
       }
     } catch (StorageException e) {
-      String msg = "database is down";
-      logger.atSevere().withCause(e).log(msg);
-      throw new CmdLineException(owner, localizable(msg));
+      CmdLineException newException = new CmdLineException(owner, localizable("database is down"));
+      newException.initCause(e);
+      throw newException;
     } catch (IOException e) {
       throw new CmdLineException(owner, "Failed to load account", e);
     } catch (ConfigInvalidException e) {
diff --git a/java/com/google/gerrit/server/args4j/TimestampHandler.java b/java/com/google/gerrit/server/args4j/InstantHandler.java
similarity index 73%
rename from java/com/google/gerrit/server/args4j/TimestampHandler.java
rename to java/com/google/gerrit/server/args4j/InstantHandler.java
index eddfbcd..bfca0f6 100644
--- a/java/com/google/gerrit/server/args4j/TimestampHandler.java
+++ b/java/com/google/gerrit/server/args4j/InstantHandler.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2014 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.
@@ -16,11 +16,10 @@
 
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.sql.Timestamp;
-import java.text.DateFormat;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.TimeZone;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -28,14 +27,14 @@
 import org.kohsuke.args4j.spi.Parameters;
 import org.kohsuke.args4j.spi.Setter;
 
-public class TimestampHandler extends OptionHandler<Timestamp> {
+public class InstantHandler extends OptionHandler<Instant> {
   public static final String TIMESTAMP_FORMAT = "yyyyMMdd_HHmm";
 
   @Inject
-  public TimestampHandler(
+  public InstantHandler(
       @Assisted CmdLineParser parser,
       @Assisted OptionDef option,
-      @Assisted Setter<Timestamp> setter) {
+      @Assisted Setter<Instant> setter) {
     super(parser, option, setter);
   }
 
@@ -43,11 +42,12 @@
   public int parseArguments(Parameters params) throws CmdLineException {
     String timestamp = params.getParameter(0);
     try {
-      DateFormat fmt = new SimpleDateFormat(TIMESTAMP_FORMAT);
-      fmt.setTimeZone(TimeZone.getTimeZone("UTC"));
-      setter.addValue(new Timestamp(fmt.parse(timestamp).getTime()));
+      setter.addValue(
+          DateTimeFormatter.ofPattern(TIMESTAMP_FORMAT)
+              .withZone(ZoneId.of("UTC"))
+              .parse(timestamp, Instant::from));
       return 1;
-    } catch (ParseException e) {
+    } catch (DateTimeParseException e) {
       throw new CmdLineException(
           owner,
           String.format("Invalid timestamp: %s; expected format: %s", timestamp, TIMESTAMP_FORMAT),
diff --git a/java/com/google/gerrit/server/audit/AuditService.java b/java/com/google/gerrit/server/audit/AuditService.java
index 2a5d868..695c32a 100644
--- a/java/com/google/gerrit/server/audit/AuditService.java
+++ b/java/com/google/gerrit/server/audit/AuditService.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 @Singleton
 public class AuditService implements GroupAuditService {
@@ -50,7 +50,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> addedMembers,
-      Timestamp addedOn) {
+      Instant addedOn) {
     GroupMemberAuditEvent event =
         GroupMemberAuditEvent.create(actor, updatedGroup, addedMembers, addedOn);
     groupAuditListeners.runEach(l -> l.onAddMembers(event));
@@ -61,7 +61,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> deletedMembers,
-      Timestamp deletedOn) {
+      Instant deletedOn) {
     GroupMemberAuditEvent event =
         GroupMemberAuditEvent.create(actor, updatedGroup, deletedMembers, deletedOn);
     groupAuditListeners.runEach(l -> l.onDeleteMembers(event));
@@ -72,7 +72,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> addedSubgroups,
-      Timestamp addedOn) {
+      Instant addedOn) {
     GroupSubgroupAuditEvent event =
         GroupSubgroupAuditEvent.create(actor, updatedGroup, addedSubgroups, addedOn);
     groupAuditListeners.runEach(l -> l.onAddSubgroups(event));
@@ -83,7 +83,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> deletedSubgroups,
-      Timestamp deletedOn) {
+      Instant deletedOn) {
     GroupSubgroupAuditEvent event =
         GroupSubgroupAuditEvent.create(actor, updatedGroup, deletedSubgroups, deletedOn);
     groupAuditListeners.runEach(l -> l.onDeleteSubgroups(event));
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
index f19cb8b..39cec8b 100644
--- a/java/com/google/gerrit/server/audit/BUILD
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -56,7 +56,7 @@
         "//lib/bouncycastle:bcprov-neverlink",
         "//lib/commons:compress",
         "//lib/commons:dbcp",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/commons:net",
         "//lib/commons:validator",
         "//lib/flogger:api",
@@ -64,7 +64,6 @@
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
         "//lib/jsoup",
-        "//lib/log:log4j",
         "//lib/lucene:lucene-analyzers-common",
         "//lib/lucene:lucene-queryparser",
         "//lib/mime4j:core",
diff --git a/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java b/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
index 252a1e2..c37c583 100644
--- a/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** An audit event for groups. */
 public interface GroupAuditEvent {
@@ -35,9 +35,9 @@
   AccountGroup.UUID getUpdatedGroup();
 
   /**
-   * Gets the {@link Timestamp} of the action.
+   * Gets the {@link Instant} of the action.
    *
-   * @return the {@link Timestamp} of the action.
+   * @return the {@link Instant} of the action.
    */
-  Timestamp getTimestamp();
+  Instant getTimestamp();
 }
diff --git a/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java b/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java
index eccfbf4..95a2dce 100644
--- a/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 @AutoValue
 public abstract class GroupMemberAuditEvent implements GroupAuditEvent {
@@ -26,7 +26,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> modifiedMembers,
-      Timestamp timestamp) {
+      Instant timestamp) {
     return new AutoValue_GroupMemberAuditEvent(actor, updatedGroup, modifiedMembers, timestamp);
   }
 
@@ -40,5 +40,5 @@
   public abstract ImmutableSet<Account.Id> getModifiedMembers();
 
   @Override
-  public abstract Timestamp getTimestamp();
+  public abstract Instant getTimestamp();
 }
diff --git a/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java b/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java
index 0fe3962..ace8312 100644
--- a/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 @AutoValue
 public abstract class GroupSubgroupAuditEvent implements GroupAuditEvent {
@@ -26,7 +26,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> modifiedSubgroups,
-      Timestamp timestamp) {
+      Instant timestamp) {
     return new AutoValue_GroupSubgroupAuditEvent(actor, updatedGroup, modifiedSubgroups, timestamp);
   }
 
@@ -40,5 +40,5 @@
   public abstract ImmutableSet<AccountGroup.UUID> getModifiedSubgroups();
 
   @Override
-  public abstract Timestamp getTimestamp();
+  public abstract Instant getTimestamp();
 }
diff --git a/java/com/google/gerrit/server/cache/CacheBackend.java b/java/com/google/gerrit/server/cache/CacheBackend.java
deleted file mode 100644
index ec9876f..0000000
--- a/java/com/google/gerrit/server/cache/CacheBackend.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// 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.
-
-package com.google.gerrit.server.cache;
-
-/** Caffeine is used as default cache backend, but can be overridden with Guava backend. */
-public enum CacheBackend {
-  CAFFEINE,
-  GUAVA;
-
-  public boolean isLegacyBackend() {
-    return this == GUAVA;
-  }
-}
diff --git a/java/com/google/gerrit/server/cache/CacheModule.java b/java/com/google/gerrit/server/cache/CacheModule.java
index 0fdc6f5..d4e4509 100644
--- a/java/com/google/gerrit/server/cache/CacheModule.java
+++ b/java/com/google/gerrit/server/cache/CacheModule.java
@@ -35,7 +35,7 @@
   public static final String MEMORY_MODULE = "cache-memory";
   public static final String PERSISTENT_MODULE = "cache-persistent";
 
-  private static final TypeLiteral<Cache<?, ?>> ANY_CACHE = new TypeLiteral<Cache<?, ?>>() {};
+  private static final TypeLiteral<Cache<?, ?>> ANY_CACHE = new TypeLiteral<>() {};
 
   /**
    * Declare a named in-memory cache.
@@ -68,8 +68,7 @@
    */
   protected <K, V> CacheBinding<K, V> cache(
       String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
-    CacheProvider<K, V> m =
-        new CacheProvider<>(this, name, keyType, valType, CacheBackend.CAFFEINE);
+    CacheProvider<K, V> m = new CacheProvider<>(this, name, keyType, valType);
     bindCache(m, name, keyType, valType);
     return m;
   }
@@ -124,20 +123,7 @@
    */
   protected <K, V> PersistentCacheBinding<K, V> persist(
       String name, Class<K> keyType, Class<V> valType) {
-    return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType), CacheBackend.CAFFEINE);
-  }
-
-  /**
-   * Declare a named in-memory/on-disk cache.
-   *
-   * @param <K> type of key used to lookup entries.
-   * @param <V> type of value stored by the cache.
-   * @param backend cache backend.
-   * @return binding to describe the cache.
-   */
-  protected <K, V> PersistentCacheBinding<K, V> persist(
-      String name, Class<K> keyType, Class<V> valType, CacheBackend backend) {
-    return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType), backend);
+    return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType));
   }
 
   /**
@@ -149,7 +135,7 @@
    */
   protected <K, V> PersistentCacheBinding<K, V> persist(
       String name, Class<K> keyType, TypeLiteral<V> valType) {
-    return persist(name, TypeLiteral.get(keyType), valType, CacheBackend.CAFFEINE);
+    return persist(name, TypeLiteral.get(keyType), valType);
   }
 
   /**
@@ -160,9 +146,8 @@
    * @return binding to describe the cache.
    */
   protected <K, V> PersistentCacheBinding<K, V> persist(
-      String name, TypeLiteral<K> keyType, TypeLiteral<V> valType, CacheBackend backend) {
-    PersistentCacheProvider<K, V> m =
-        new PersistentCacheProvider<>(this, name, keyType, valType, backend);
+      String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
+    PersistentCacheProvider<K, V> m = new PersistentCacheProvider<>(this, name, keyType, valType);
     bindCache(m, name, keyType, valType);
 
     Type cacheDefType =
diff --git a/java/com/google/gerrit/server/cache/CacheProvider.java b/java/com/google/gerrit/server/cache/CacheProvider.java
index 2dd9e1f..94504b6 100644
--- a/java/com/google/gerrit/server/cache/CacheProvider.java
+++ b/java/com/google/gerrit/server/cache/CacheProvider.java
@@ -30,7 +30,6 @@
 
 class CacheProvider<K, V> implements Provider<Cache<K, V>>, CacheBinding<K, V>, CacheDef<K, V> {
   private final CacheModule module;
-  private final CacheBackend backend;
   final String name;
   private final TypeLiteral<K> keyType;
   private final TypeLiteral<V> valType;
@@ -46,17 +45,11 @@
   private MemoryCacheFactory memoryCacheFactory;
   private boolean frozen;
 
-  CacheProvider(
-      CacheModule module,
-      String name,
-      TypeLiteral<K> keyType,
-      TypeLiteral<V> valType,
-      CacheBackend backend) {
+  CacheProvider(CacheModule module, String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
     this.module = module;
     this.name = name;
     this.keyType = keyType;
     this.valType = valType;
-    this.backend = backend;
   }
 
   @Inject(optional = true)
@@ -179,9 +172,7 @@
   public Cache<K, V> get() {
     freeze();
     CacheLoader<K, V> ldr = loader();
-    return ldr != null
-        ? memoryCacheFactory.build(this, ldr, backend)
-        : memoryCacheFactory.build(this, backend);
+    return ldr != null ? memoryCacheFactory.build(this, ldr) : memoryCacheFactory.build(this);
   }
 
   protected void checkNotFrozen() {
diff --git a/java/com/google/gerrit/server/cache/MemoryCacheFactory.java b/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
index 558380d..fc55753 100644
--- a/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
@@ -19,8 +19,7 @@
 import com.google.common.cache.LoadingCache;
 
 public interface MemoryCacheFactory {
-  <K, V> Cache<K, V> build(CacheDef<K, V> def, CacheBackend backend);
+  <K, V> Cache<K, V> build(CacheDef<K, V> def);
 
-  <K, V> LoadingCache<K, V> build(
-      CacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend);
+  <K, V> LoadingCache<K, V> build(CacheDef<K, V> def, CacheLoader<K, V> loader);
 }
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
index 9553acc..ec527ba 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
@@ -45,33 +45,31 @@
     this.config = config;
   }
 
-  protected abstract <K, V> Cache<K, V> buildImpl(
-      PersistentCacheDef<K, V> in, long diskLimit, CacheBackend backend);
+  protected abstract <K, V> Cache<K, V> buildImpl(PersistentCacheDef<K, V> in, long diskLimit);
 
   protected abstract <K, V> LoadingCache<K, V> buildImpl(
-      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long diskLimit, CacheBackend backend);
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long diskLimit);
 
   @Override
-  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in, CacheBackend backend) {
+  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in) {
     long limit = getDiskLimit(in);
 
     if (isInMemoryCache(limit)) {
-      return memCacheFactory.build(in, backend);
+      return memCacheFactory.build(in);
     }
 
-    return buildImpl(in, limit, backend);
+    return buildImpl(in, limit);
   }
 
   @Override
-  public <K, V> LoadingCache<K, V> build(
-      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, CacheBackend backend) {
+  public <K, V> LoadingCache<K, V> build(PersistentCacheDef<K, V> in, CacheLoader<K, V> loader) {
     long limit = getDiskLimit(in);
 
     if (isInMemoryCache(limit)) {
-      return memCacheFactory.build(in, loader, backend);
+      return memCacheFactory.build(in, loader);
     }
 
-    return buildImpl(in, loader, limit, backend);
+    return buildImpl(in, loader, limit);
   }
 
   private <K, V> long getDiskLimit(PersistentCacheDef<K, V> in) {
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
index 93f91ef..27fa9ca 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
@@ -19,10 +19,9 @@
 import com.google.common.cache.LoadingCache;
 
 public interface PersistentCacheFactory {
-  <K, V> Cache<K, V> build(PersistentCacheDef<K, V> def, CacheBackend backend);
+  <K, V> Cache<K, V> build(PersistentCacheDef<K, V> def);
 
-  <K, V> LoadingCache<K, V> build(
-      PersistentCacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend);
+  <K, V> LoadingCache<K, V> build(PersistentCacheDef<K, V> def, CacheLoader<K, V> loader);
 
   void onStop(String plugin);
 }
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
index 4fc107f..59d66e3 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
@@ -30,7 +30,6 @@
 
 class PersistentCacheProvider<K, V> extends CacheProvider<K, V>
     implements Provider<Cache<K, V>>, PersistentCacheBinding<K, V>, PersistentCacheDef<K, V> {
-  private final CacheBackend backend;
   private int version;
   private long diskLimit;
   private CacheSerializer<K> keySerializer;
@@ -40,19 +39,9 @@
 
   PersistentCacheProvider(
       CacheModule module, String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
-    this(module, name, keyType, valType, CacheBackend.CAFFEINE);
-  }
-
-  PersistentCacheProvider(
-      CacheModule module,
-      String name,
-      TypeLiteral<K> keyType,
-      TypeLiteral<V> valType,
-      CacheBackend backend) {
-    super(module, name, keyType, valType, backend);
+    super(module, name, keyType, valType);
     version = -1;
     diskLimit = 128 << 20;
-    this.backend = backend;
   }
 
   @Inject(optional = true)
@@ -141,8 +130,8 @@
     freeze();
     CacheLoader<K, V> ldr = loader();
     return ldr != null
-        ? persistentCacheFactory.build(this, ldr, backend)
-        : persistentCacheFactory.build(this, backend);
+        ? persistentCacheFactory.build(this, ldr)
+        : persistentCacheFactory.build(this);
   }
 
   private static <T> void checkSerializer(
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index dc7a247..fdd55ac 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
 import com.google.gerrit.server.cache.PersistentCacheBaseFactory;
 import com.google.gerrit.server.cache.PersistentCacheDef;
@@ -42,7 +41,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.time.Duration;
-import java.util.LinkedList;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ExecutorService;
@@ -80,7 +79,7 @@
     super(memCacheFactory, cfg, site);
     h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
     h2AutoServer = cfg.getBoolean("cache", null, "h2AutoServer", false);
-    caches = new LinkedList<>();
+    caches = new ArrayList<>();
     this.cacheMap = cacheMap;
     this.isOfflineReindex =
         isFirstInsertForEntry != null && isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES);
@@ -155,34 +154,28 @@
 
   @SuppressWarnings({"unchecked"})
   @Override
-  public <K, V> Cache<K, V> buildImpl(
-      PersistentCacheDef<K, V> in, long limit, CacheBackend backend) {
+  public <K, V> Cache<K, V> buildImpl(PersistentCacheDef<K, V> in, long limit) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     H2CacheImpl<K, V> cache =
         new H2CacheImpl<>(
-            executor,
-            store,
-            def.keyType(),
-            (Cache<K, ValueHolder<V>>) memCacheFactory.build(def, backend));
+            executor, store, def.keyType(), (Cache<K, ValueHolder<V>>) memCacheFactory.build(def));
     synchronized (caches) {
       caches.add(cache);
     }
     return cache;
   }
 
-  @SuppressWarnings("unchecked")
+  @SuppressWarnings({"unchecked"})
   @Override
   public <K, V> LoadingCache<K, V> buildImpl(
-      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long limit, CacheBackend backend) {
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long limit) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     Cache<K, ValueHolder<V>> mem =
         (Cache<K, ValueHolder<V>>)
             memCacheFactory.build(
-                def,
-                (CacheLoader<K, V>) new H2CacheImpl.Loader<>(executor, store, loader),
-                backend);
+                def, (CacheLoader<K, V>) new H2CacheImpl.Loader<>(executor, store, loader));
     H2CacheImpl<K, V> cache = new H2CacheImpl<>(executor, store, def.keyType(), mem);
     synchronized (caches) {
       caches.add(cache);
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 5c6fd70..2213ce3 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -552,7 +552,7 @@
         c.touch = c.conn.prepareStatement("UPDATE data SET accessed=? WHERE k=? AND version=?");
       }
       try {
-        c.touch.setTimestamp(1, TimeUtil.nowTs());
+        c.touch.setTimestamp(1, new Timestamp(TimeUtil.nowMs()));
         keyType.set(c.touch, 2, key);
         c.touch.setInt(3, version);
         c.touch.executeUpdate();
@@ -585,7 +585,7 @@
           c.put.setBytes(2, valueSerializer.serialize(holder.value));
           c.put.setInt(3, version);
           c.put.setTimestamp(4, Timestamp.from(holder.created));
-          c.put.setTimestamp(5, TimeUtil.nowTs());
+          c.put.setTimestamp(5, new Timestamp(TimeUtil.nowMs()));
           c.put.executeUpdate();
           holder.clean = true;
         } finally {
diff --git a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
index 591883e..1812043 100644
--- a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
@@ -47,7 +47,7 @@
 
   @Override
   public Funnel<K> funnel() {
-    return new Funnel<K>() {
+    return new Funnel<>() {
       private static final long serialVersionUID = 1L;
 
       @Override
diff --git a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
index bf295e0..a580f6d 100644
--- a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
@@ -23,13 +23,11 @@
 import com.github.benmanes.caffeine.guava.CaffeinatedGuava;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.cache.RemovalNotification;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.CacheDef;
 import com.google.gerrit.server.cache.ForwardingRemovalListener;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
@@ -69,77 +67,15 @@
   }
 
   @Override
-  public <K, V> Cache<K, V> build(CacheDef<K, V> def, CacheBackend backend) {
-    return backend.isLegacyBackend()
-        ? createLegacy(def).build()
-        : CaffeinatedGuava.build(create(def));
+  public <K, V> Cache<K, V> build(CacheDef<K, V> def) {
+    return CaffeinatedGuava.build(create(def));
   }
 
   @Override
-  public <K, V> LoadingCache<K, V> build(
-      CacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend) {
+  public <K, V> LoadingCache<K, V> build(CacheDef<K, V> def, CacheLoader<K, V> loader) {
     return cacheMaximumWeight(def) == 0
         ? new PassthroughLoadingCache<>(loader)
-        : (backend.isLegacyBackend()
-            ? createLegacy(def).build(loader)
-            : CaffeinatedGuava.build(create(def), loader));
-  }
-
-  @SuppressWarnings("unchecked")
-  private <K, V> CacheBuilder<K, V> createLegacy(CacheDef<K, V> def) {
-    CacheBuilder<K, V> builder = newLegacyCacheBuilder();
-    builder.recordStats();
-    builder.maximumWeight(cacheMaximumWeight(def));
-
-    builder = builder.removalListener(forwardingRemovalListenerFactory.create(def.name()));
-
-    com.google.common.cache.Weigher<K, V> weigher = def.weigher();
-    if (weigher == null) {
-      weigher = unitWeight();
-    }
-    builder.weigher(weigher);
-
-    Duration expireAfterWrite = def.expireAfterWrite();
-    if (has(def.configKey(), "maxAge")) {
-      builder.expireAfterWrite(
-          ConfigUtil.getTimeUnit(
-              cfg, "cache", def.configKey(), "maxAge", toSeconds(expireAfterWrite), SECONDS),
-          SECONDS);
-    } else if (expireAfterWrite != null) {
-      builder.expireAfterWrite(expireAfterWrite.toNanos(), NANOSECONDS);
-    }
-
-    Duration expireAfterAccess = def.expireFromMemoryAfterAccess();
-    if (has(def.configKey(), "expireFromMemoryAfterAccess")) {
-      builder.expireAfterAccess(
-          ConfigUtil.getTimeUnit(
-              cfg,
-              "cache",
-              def.configKey(),
-              "expireFromMemoryAfterAccess",
-              toSeconds(expireAfterAccess),
-              SECONDS),
-          SECONDS);
-    } else if (expireAfterAccess != null) {
-      builder.expireAfterAccess(expireAfterAccess.toNanos(), NANOSECONDS);
-    }
-
-    Duration refreshAfterWrite = def.refreshAfterWrite();
-    if (has(def.configKey(), "refreshAfterWrite")) {
-      builder.refreshAfterWrite(
-          ConfigUtil.getTimeUnit(
-              cfg,
-              "cache",
-              def.configKey(),
-              "refreshAfterWrite",
-              toSeconds(refreshAfterWrite),
-              SECONDS),
-          SECONDS);
-    } else if (refreshAfterWrite != null) {
-      builder.refreshAfterWrite(refreshAfterWrite.toNanos(), NANOSECONDS);
-    }
-
-    return builder;
+        : CaffeinatedGuava.build(create(def), loader);
   }
 
   private <K, V> Caffeine<K, V> create(CacheDef<K, V> def) {
@@ -209,15 +145,6 @@
   }
 
   @SuppressWarnings("unchecked")
-  private static <K, V> CacheBuilder<K, V> newLegacyCacheBuilder() {
-    return (CacheBuilder<K, V>) CacheBuilder.newBuilder();
-  }
-
-  private static <K, V> com.google.common.cache.Weigher<K, V> unitWeight() {
-    return (key, value) -> 1;
-  }
-
-  @SuppressWarnings("unchecked")
   private static <K, V> Caffeine<K, V> newCacheBuilder() {
     return (Caffeine<K, V>) Caffeine.newBuilder();
   }
diff --git a/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
index 5377fc1..bb28a6d 100644
--- a/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
@@ -31,7 +31,7 @@
    * @return serializer of type {@code T}.
    */
   static <T, D> CacheSerializer<T> convert(CacheSerializer<D> delegate, Converter<T, D> converter) {
-    return new CacheSerializer<T>() {
+    return new CacheSerializer<>() {
       @Override
       public byte[] serialize(T object) {
         return delegate.serialize(converter.convert(object));
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java
index 7449917..09f3543 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper to (de)serialize values for caches. */
 public class InternalGroupSerializer {
@@ -33,7 +33,7 @@
             .setOwnerGroupUUID(AccountGroup.uuid(proto.getOwnerGroupUuid()))
             .setVisibleToAll(proto.getIsVisibleToAll())
             .setGroupUUID(AccountGroup.uuid(proto.getGroupUuid()))
-            .setCreatedOn(new Timestamp(proto.getCreatedOn()))
+            .setCreatedOn(Instant.ofEpochMilli(proto.getCreatedOn()))
             .setMembers(
                 proto.getMembersIdsList().stream()
                     .map(a -> Account.id(a))
@@ -62,7 +62,7 @@
             .setOwnerGroupUuid(autoValue.getOwnerGroupUUID().get())
             .setIsVisibleToAll(autoValue.isVisibleToAll())
             .setGroupUuid(autoValue.getGroupUUID().get())
-            .setCreatedOn(autoValue.getCreatedOn().getTime());
+            .setCreatedOn(autoValue.getCreatedOn().toEpochMilli());
 
     autoValue.getMembers().stream().forEach(m -> builder.addMembersIds(m.get()));
     autoValue.getSubgroups().stream().forEach(s -> builder.addSubgroupUuids(s.get()));
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
index c00961f..5a71ced 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.server.cache.proto.Cache;
+import java.util.Optional;
 
 /** Helper to (de)serialize values for caches. */
 public class LabelTypeSerializer {
@@ -36,6 +37,7 @@
             proto.getValuesList().stream()
                 .map(LabelValueSerializer::deserialize)
                 .collect(toImmutableList()))
+        .setDescription(Optional.of(proto.getDescription()))
         .setFunction(FUNCTION_CONVERTER.convert(proto.getFunction()))
         .setAllowPostSubmit(proto.getAllowPostSubmit())
         .setIgnoreSelfApproval(proto.getIgnoreSelfApproval())
@@ -68,6 +70,7 @@
             autoValue.getValues().stream()
                 .map(LabelValueSerializer::serialize)
                 .collect(toImmutableList()))
+        .setDescription(autoValue.getDescription().orElse(""))
         .setFunction(FUNCTION_CONVERTER.reverse().convert(autoValue.getFunction()))
         .setCopyCondition(autoValue.getCopyCondition().orElse(""))
         .setCopyAnyScore(autoValue.isCopyAnyScore())
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 4e997b4..436fe76 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.cache.serialize.entities;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
+import java.util.Optional;
 
 /**
  * Serializer of a {@link SubmitRequirementExpressionResult} to {@link
@@ -30,7 +32,8 @@
         SubmitRequirementExpression.create(proto.getExpression()),
         SubmitRequirementExpressionResult.Status.valueOf(proto.getStatus()),
         proto.getPassingAtomsList().stream().collect(ImmutableList.toImmutableList()),
-        proto.getFailingAtomsList().stream().collect(ImmutableList.toImmutableList()));
+        proto.getFailingAtomsList().stream().collect(ImmutableList.toImmutableList()),
+        Optional.ofNullable(Strings.emptyToNull(proto.getErrorMessage())));
   }
 
   public static SubmitRequirementExpressionResultProto serialize(
@@ -40,6 +43,7 @@
         .setStatus(r.status().name())
         .addAllPassingAtoms(r.passingAtoms())
         .addAllFailingAtoms(r.failingAtoms())
+        .setErrorMessage(r.errorMessage().orElse(""))
         .build();
   }
 }
diff --git a/java/com/google/gerrit/server/cancellation/BUILD b/java/com/google/gerrit/server/cancellation/BUILD
index 05530a5..40557b1 100644
--- a/java/com/google/gerrit/server/cancellation/BUILD
+++ b/java/com/google/gerrit/server/cancellation/BUILD
@@ -9,6 +9,6 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
-        "//lib/commons:lang",
+        "//lib/commons:text",
     ],
 )
diff --git a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
index d89701f..dc01567 100644
--- a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
+++ b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
@@ -17,7 +17,7 @@
 import com.google.common.base.Throwables;
 import com.google.gerrit.common.Nullable;
 import java.util.Optional;
-import org.apache.commons.lang.WordUtils;
+import org.apache.commons.text.WordUtils;
 
 /** Exception to signal that the current request is cancelled and should be aborted. */
 public class RequestCancelledException extends RuntimeException {
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index d030ec1..29bd045 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -99,7 +99,7 @@
             msg.append(" ").append(change.getId().get());
           }
           msg.append(".");
-          logger.atSevere().withCause(e).log(msg.toString());
+          logger.atSevere().withCause(e).log("%s", msg);
         }
       }
       logger.atInfo().log("Auto-Abandoned %d of %d changes.", count, changesToAbandon.size());
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index 54ebf40..63e2c08 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -118,6 +118,10 @@
     copy.topic = changeInfo.topic;
     copy.attentionSet =
         changeInfo.attentionSet == null ? null : ImmutableMap.copyOf(changeInfo.attentionSet);
+    copy.removedFromAttentionSet =
+        changeInfo.removedFromAttentionSet == null
+            ? null
+            : ImmutableMap.copyOf(changeInfo.removedFromAttentionSet);
     copy.assignee = changeInfo.assignee;
     copy.hashtags = changeInfo.hashtags;
     copy.changeId = changeInfo.changeId;
@@ -202,7 +206,7 @@
     return out;
   }
 
-  private Map<String, ActionInfo> toActionMap(
+  private ImmutableMap<String, ActionInfo> toActionMap(
       RevisionResource rsrc,
       List<ActionVisitor> visitors,
       ChangeInfo changeInfo,
@@ -222,6 +226,6 @@
       }
       out.put(d.getId(), actionInfo);
     }
-    return out;
+    return ImmutableMap.copyOf(out);
   }
 }
diff --git a/java/com/google/gerrit/server/change/AttentionSetEntryResource.java b/java/com/google/gerrit/server/change/AttentionSetEntryResource.java
index 6c6c765..e27a1a7 100644
--- a/java/com/google/gerrit/server/change/AttentionSetEntryResource.java
+++ b/java/com/google/gerrit/server/change/AttentionSetEntryResource.java
@@ -22,7 +22,7 @@
 /** REST resource that represents an entry in the attention set of a change. */
 public class AttentionSetEntryResource implements RestResource {
   public static final TypeLiteral<RestView<AttentionSetEntryResource>> ATTENTION_SET_ENTRY_KIND =
-      new TypeLiteral<RestView<AttentionSetEntryResource>>() {};
+      new TypeLiteral<>() {};
 
   public interface Factory {
     AttentionSetEntryResource create(ChangeResource change, Account.Id id);
diff --git a/java/com/google/gerrit/server/change/BatchAbandon.java b/java/com/google/gerrit/server/change/BatchAbandon.java
index e0a72ac4..2efa027 100644
--- a/java/com/google/gerrit/server/change/BatchAbandon.java
+++ b/java/com/google/gerrit/server/change/BatchAbandon.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
+import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -34,15 +35,18 @@
   private final AbandonOp.Factory abandonOpFactory;
   private final ChangeCleanupConfig cfg;
   private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
+  private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
 
   @Inject
   BatchAbandon(
       AbandonOp.Factory abandonOpFactory,
       ChangeCleanupConfig cfg,
-      PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore) {
+      PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
     this.abandonOpFactory = abandonOpFactory;
     this.cfg = cfg;
     this.accountPatchReviewStore = accountPatchReviewStore;
+    this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
   }
 
   /**
@@ -64,7 +68,7 @@
       return;
     }
     AccountState accountState = user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null;
-    try (BatchUpdate u = updateFactory.create(project, user, TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(project, user, TimeUtil.now())) {
       u.setNotify(notify);
       for (ChangeData change : changes) {
         if (!project.equals(change.project())) {
@@ -74,6 +78,9 @@
                   change.project().get(), project.get()));
         }
         u.addOp(change.getId(), abandonOpFactory.create(accountState, msgTxt));
+        u.addOp(
+            change.getId(),
+            storeSubmitRequirementsOpFactory.create(change.submitRequirements().values(), change));
       }
       u.execute();
 
diff --git a/java/com/google/gerrit/server/change/ChangeEditResource.java b/java/com/google/gerrit/server/change/ChangeEditResource.java
index 392709e..67edb56 100644
--- a/java/com/google/gerrit/server/change/ChangeEditResource.java
+++ b/java/com/google/gerrit/server/change/ChangeEditResource.java
@@ -31,7 +31,7 @@
  */
 public class ChangeEditResource implements RestResource {
   public static final TypeLiteral<RestView<ChangeEditResource>> CHANGE_EDIT_KIND =
-      new TypeLiteral<RestView<ChangeEditResource>>() {};
+      new TypeLiteral<>() {};
 
   private final ChangeResource change;
   private final ChangeEdit edit;
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 85482e4..6ef7f1e 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -313,6 +313,7 @@
 
   public ChangeInserter setValidationOptions(
       ImmutableListMultimap<String, String> validationOptions) {
+    requireNonNull(validationOptions, "validationOptions may not be null");
     checkState(
         patchSet == null,
         "setValidationOptions(ImmutableListMultimap<String, String>) only valid before creating a"
@@ -364,7 +365,7 @@
    * <p>Should not be used in new code, as it doesn't result in a single atomic batch ref update for
    * code and NoteDb meta refs.
    *
-   * @param updateRef whether to update the ref during {@code updateRepo}.
+   * @param updateRef whether to update the ref during {@link #updateRepo(RepoContext)}.
    */
   @Deprecated
   public ChangeInserter setUpdateRef(boolean updateRef) {
@@ -497,15 +498,15 @@
                 emailSender.setPatchSet(patchSet, patchSetInfo);
                 emailSender.setNotify(notify);
                 emailSender.addReviewers(
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
                         .map(PatchSetApproval::accountId)
                         .collect(toImmutableSet()));
                 emailSender.addReviewersByEmail(
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewersByEmail));
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewersByEmail));
                 emailSender.addExtraCC(
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs));
                 emailSender.addExtraCCByEmail(
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCsByEmail));
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCsByEmail));
                 emailSender.setMessageId(
                     messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
                 emailSender.send();
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 328c5de..32e40eb 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -35,11 +35,13 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
 import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
+import static com.google.gerrit.server.util.AttentionSetUtil.removalsOnly;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -60,7 +62,6 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRecord.Status;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
@@ -99,8 +100,6 @@
 import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -255,7 +254,6 @@
       TrackingFooters trackingFooters,
       Metrics metrics,
       RevisionJson.Factory revisionJsonFactory,
-      ExperimentFeatures experimentFeatures,
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
       @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
@@ -274,9 +272,7 @@
     this.revisionJson = revisionJsonFactory.create(options);
     this.options = Sets.immutableEnumSet(options);
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
-    this.lazyLoad =
-        containsAnyOf(this.options, REQUIRE_LAZY_LOAD)
-            || lazyloadSubmitRequirements(this.options, experimentFeatures);
+    this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
     this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
 
     logger.atFine().log("options = %s", options);
@@ -382,10 +378,10 @@
 
   private Collection<SubmitRequirementResultInfo> submitRequirementsFor(ChangeData cd) {
     Collection<SubmitRequirementResultInfo> reqInfos = new ArrayList<>();
-    Map<SubmitRequirement, SubmitRequirementResult> requirements = cd.submitRequirements();
-    for (Map.Entry<SubmitRequirement, SubmitRequirementResult> entry : requirements.entrySet()) {
-      reqInfos.add(SubmitRequirementsJson.toInfo(entry.getKey(), entry.getValue()));
-    }
+    cd.submitRequirementsIncludingLegacy().entrySet().stream()
+        .filter(entry -> !entry.getValue().isHidden())
+        .forEach(
+            entry -> reqInfos.add(SubmitRequirementsJson.toInfo(entry.getKey(), entry.getValue())));
     return reqInfos;
   }
 
@@ -556,8 +552,8 @@
       info.subject = c.getSubject();
       info.status = c.getStatus().asChangeStatus();
       info.owner = new AccountInfo(c.getOwner().get());
-      info.created = c.getCreatedOn();
-      info.updated = c.getLastUpdatedOn();
+      info.setCreated(c.getCreatedOn());
+      info.setUpdated(c.getLastUpdatedOn());
       info._number = c.getId().get();
       info.problems = result.problems();
       info.isPrivate = c.isPrivate() ? true : null;
@@ -603,6 +599,12 @@
     out.branch = in.getDest().shortName();
     out.topic = in.getTopic();
     if (!cd.attentionSet().isEmpty()) {
+      out.removedFromAttentionSet =
+          removalsOnly(cd.attentionSet()).stream()
+              .collect(
+                  toImmutableMap(
+                      a -> a.account().get(),
+                      a -> AttentionSetUtil.createAttentionSetInfo(a, accountLoader)));
       out.attentionSet =
           // This filtering should match GetAttentionSet.
           additionsOnly(cd.attentionSet()).stream()
@@ -639,8 +641,8 @@
     out.subject = in.getSubject();
     out.status = in.getStatus().asChangeStatus();
     out.owner = accountLoader.get(in.getOwner());
-    out.created = in.getCreatedOn();
-    out.updated = in.getLastUpdatedOn();
+    out.setCreated(in.getCreatedOn());
+    out.setUpdated(in.getLastUpdatedOn());
     out._number = in.getId().get();
     out.totalCommentCount = cd.totalCommentCount();
     out.unresolvedCommentCount = cd.unresolvedCommentCount();
@@ -772,18 +774,20 @@
     List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
     List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
     for (ReviewerStatusUpdate c : reviewerUpdates) {
-      ReviewerUpdateInfo change = new ReviewerUpdateInfo();
-      change.updated = c.date();
-      change.state = c.state().asReviewerState();
-      change.updatedBy = accountLoader.get(c.updatedBy());
-      change.reviewer = accountLoader.get(c.reviewer());
+      ReviewerUpdateInfo change =
+          new ReviewerUpdateInfo(
+              c.date(),
+              accountLoader.get(c.updatedBy()),
+              accountLoader.get(c.reviewer()),
+              c.state().asReviewerState());
       result.add(change);
     }
     return result;
   }
 
   private boolean submittable(ChangeData cd) {
-    return SubmitRecord.allRecordsOK(cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT));
+    return cd.submitRequirementsIncludingLegacy().values().stream()
+        .allMatch(SubmitRequirementResult::fulfilled);
   }
 
   private void setSubmitter(ChangeData cd, ChangeInfo out) {
@@ -791,21 +795,20 @@
     if (!s.isPresent()) {
       return;
     }
-    out.submitted = s.get().granted();
-    out.submitter = accountLoader.get(s.get().accountId());
+    out.setSubmitted(s.get().granted(), accountLoader.get(s.get().accountId()));
   }
 
-  private Collection<ChangeMessageInfo> messages(ChangeData cd) {
+  private ImmutableList<ChangeMessageInfo> messages(ChangeData cd) {
     List<ChangeMessage> messages = cmUtil.byChange(cd.notes());
     if (messages.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     List<ChangeMessageInfo> result = Lists.newArrayListWithCapacity(messages.size());
     for (ChangeMessage message : messages) {
       result.add(createChangeMessageInfo(message, accountLoader));
     }
-    return result;
+    return ImmutableList.copyOf(result);
   }
 
   private Collection<AccountInfo> removableReviewers(ChangeData cd, ChangeInfo out)
@@ -943,20 +946,4 @@
     }
     return ImmutableListMultimap.of();
   }
-
-  private static boolean lazyloadSubmitRequirements(
-      Set<ListChangesOption> changeOptions, ExperimentFeatures experimentFeatures) {
-    // TODO(ghareeb,hiesel): Remove this method.
-    // We are testing the new submit requirements with users in lieu of upgrading the change index
-    // to a version that supports the new requirements.
-    // Upgrading now, before the feature is finalized would be counter productive, because the index
-    // format might change while we iterate over the feature.
-    // Allowing changes to lazyload parameters will slow down dashboards for users who have this
-    // feature enabled, but will backfill submit requirements that weren't loaded from the index by
-    // simply computing them.
-    return changeOptions.contains(SUBMIT_REQUIREMENTS)
-        && experimentFeatures.isFeatureEnabled(
-            ExperimentFeaturesConstants
-                .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD);
-  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeMessageResource.java b/java/com/google/gerrit/server/change/ChangeMessageResource.java
index 25f952d..751faa0 100644
--- a/java/com/google/gerrit/server/change/ChangeMessageResource.java
+++ b/java/com/google/gerrit/server/change/ChangeMessageResource.java
@@ -23,7 +23,7 @@
 /** A change message resource. */
 public class ChangeMessageResource implements RestResource {
   public static final TypeLiteral<RestView<ChangeMessageResource>> CHANGE_MESSAGE_KIND =
-      new TypeLiteral<RestView<ChangeMessageResource>>() {};
+      new TypeLiteral<>() {};
 
   private final ChangeResource changeResource;
   private final ChangeMessageInfo changeMessage;
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 970f1b5..919586e 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -62,8 +62,7 @@
    */
   public static final int JSON_FORMAT_VERSION = 1;
 
-  public static final TypeLiteral<RestView<ChangeResource>> CHANGE_KIND =
-      new TypeLiteral<RestView<ChangeResource>>() {};
+  public static final TypeLiteral<RestView<ChangeResource>> CHANGE_KIND = new TypeLiteral<>() {};
 
   public interface Factory {
     ChangeResource create(ChangeNotes notes, CurrentUser user);
@@ -166,7 +165,7 @@
   // unrelated to the UI.
   public void prepareETag(Hasher h, CurrentUser user) {
     h.putInt(JSON_FORMAT_VERSION)
-        .putLong(getChange().getLastUpdatedOn().getTime())
+        .putLong(getChange().getLastUpdatedOn().toEpochMilli())
         .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
 
     if (user.isIdentifiedUser()) {
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 7d0bda1..0775647 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -95,7 +95,7 @@
   public abstract static class Result {
     private static Result create(ChangeNotes notes, List<ProblemInfo> problems) {
       return new AutoValue_ConsistencyChecker_Result(
-          notes.getChangeId(), notes.getChange(), problems);
+          notes.getChangeId(), notes.getChange(), ImmutableList.copyOf(problems));
     }
 
     public abstract Change.Id id();
@@ -103,7 +103,7 @@
     @Nullable
     public abstract Change change();
 
-    public abstract List<ProblemInfo> problems();
+    public abstract ImmutableList<ProblemInfo> problems();
   }
 
   private final ChangeNotes.Factory notesFactory;
@@ -626,7 +626,7 @@
   }
 
   private BatchUpdate newBatchUpdate() {
-    return updateFactory.create(change().getProject(), user.get(), TimeUtil.nowTs());
+    return updateFactory.create(change().getProject(), user.get(), TimeUtil.now());
   }
 
   private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 1e40429..0116b01 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -195,7 +195,12 @@
       try {
         if (notify.shouldNotify()) {
           emailReviewers(
-              ctx.getProject(), currChange, mailMessage, ctx.getWhen(), notify, ctx.getRepoView());
+              ctx.getProject(),
+              currChange,
+              mailMessage,
+              Timestamp.from(ctx.getWhen()),
+              notify,
+              ctx.getRepoView());
         }
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
@@ -223,7 +228,7 @@
 
   private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
     Iterable<PatchSetApproval> approvals;
-    approvals = approvalsUtil.byChange(ctx.getNotes()).values();
+    approvals = ctx.getNotes().getApprovalsWithCopied().values();
     return Iterables.filter(approvals, psa -> accountId.equals(psa.accountId()));
   }
 
@@ -251,7 +256,7 @@
         deleteReviewerSenderFactory.create(projectName, change.getId());
     emailSender.setFrom(userId);
     emailSender.addReviewers(Collections.singleton(reviewer.id()));
-    emailSender.setChangeMessage(mailMessage, timestamp);
+    emailSender.setChangeMessage(mailMessage, timestamp.toInstant());
     emailSender.setNotify(notify);
     emailSender.setMessageId(
         messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
diff --git a/java/com/google/gerrit/server/change/DraftCommentResource.java b/java/com/google/gerrit/server/change/DraftCommentResource.java
index 19a495d..2e40f2c 100644
--- a/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ b/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -25,7 +25,7 @@
 
 public class DraftCommentResource implements RestResource {
   public static final TypeLiteral<RestView<DraftCommentResource>> DRAFT_COMMENT_KIND =
-      new TypeLiteral<RestView<DraftCommentResource>>() {};
+      new TypeLiteral<>() {};
 
   private final RevisionResource rev;
   private final HumanComment comment;
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index 3c7ea44..f94e592 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
@@ -66,7 +66,7 @@
         PatchSet patchSet,
         IdentifiedUser user,
         @Assisted("message") String message,
-        Timestamp timestamp,
+        Instant timestamp,
         List<? extends Comment> comments,
         @Assisted("patchSetComment") String patchSetComment,
         List<LabelVote> labels,
@@ -84,7 +84,7 @@
   private final PatchSet patchSet;
   private final IdentifiedUser user;
   private final String message;
-  private final Timestamp timestamp;
+  private final Instant timestamp;
   private final List<? extends Comment> comments;
   private final String patchSetComment;
   private final List<LabelVote> labels;
@@ -102,7 +102,7 @@
       @Assisted PatchSet patchSet,
       @Assisted IdentifiedUser user,
       @Assisted("message") String message,
-      @Assisted Timestamp timestamp,
+      @Assisted Instant timestamp,
       @Assisted List<? extends Comment> comments,
       @Nullable @Assisted("patchSetComment") String patchSetComment,
       @Assisted List<LabelVote> labels,
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
index b729c11..44b4ded 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 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.FilePathAdapter;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -51,9 +52,11 @@
         // single-parent commits, or the auto-merge otherwise
         return asFileInfo(
             diffs.listModifiedFilesAgainstParent(
-                change.getProject(), objectId, /* parentNum= */ 0));
+                change.getProject(), objectId, /* parentNum= */ 0, DiffOptions.DEFAULTS));
       }
-      return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
+      return asFileInfo(
+          diffs.listModifiedFiles(
+              change.getProject(), base.commitId(), objectId, DiffOptions.DEFAULTS));
     } catch (DiffNotAvailableException e) {
       convertException(e);
       return null; // unreachable. handleAndThrow will throw an exception anyway
@@ -66,7 +69,7 @@
       throws ResourceConflictException, PatchListNotAvailableException {
     try {
       Map<String, FileDiffOutput> modifiedFiles =
-          diffs.listModifiedFilesAgainstParent(project, objectId, parent);
+          diffs.listModifiedFilesAgainstParent(project, objectId, parent, DiffOptions.DEFAULTS);
       return asFileInfo(modifiedFiles);
     } catch (DiffNotAvailableException e) {
       convertException(e);
diff --git a/java/com/google/gerrit/server/change/FileResource.java b/java/com/google/gerrit/server/change/FileResource.java
index 5402338..22fe39c 100644
--- a/java/com/google/gerrit/server/change/FileResource.java
+++ b/java/com/google/gerrit/server/change/FileResource.java
@@ -21,8 +21,7 @@
 import com.google.inject.TypeLiteral;
 
 public class FileResource implements RestResource {
-  public static final TypeLiteral<RestView<FileResource>> FILE_KIND =
-      new TypeLiteral<RestView<FileResource>>() {};
+  public static final TypeLiteral<RestView<FileResource>> FILE_KIND = new TypeLiteral<>() {};
 
   private final RevisionResource rev;
   private final Patch.Key key;
diff --git a/java/com/google/gerrit/server/change/FixResource.java b/java/com/google/gerrit/server/change/FixResource.java
index b6b5894..0c5ee23 100644
--- a/java/com/google/gerrit/server/change/FixResource.java
+++ b/java/com/google/gerrit/server/change/FixResource.java
@@ -21,8 +21,7 @@
 import java.util.List;
 
 public class FixResource implements RestResource {
-  public static final TypeLiteral<RestView<FixResource>> FIX_KIND =
-      new TypeLiteral<RestView<FixResource>>() {};
+  public static final TypeLiteral<RestView<FixResource>> FIX_KIND = new TypeLiteral<>() {};
 
   private final List<FixReplacement> fixReplacements;
   private final RevisionResource revisionResource;
diff --git a/java/com/google/gerrit/server/change/HumanCommentResource.java b/java/com/google/gerrit/server/change/HumanCommentResource.java
index 1611aaa..93a0698 100644
--- a/java/com/google/gerrit/server/change/HumanCommentResource.java
+++ b/java/com/google/gerrit/server/change/HumanCommentResource.java
@@ -23,7 +23,7 @@
 
 public class HumanCommentResource implements RestResource {
   public static final TypeLiteral<RestView<HumanCommentResource>> COMMENT_KIND =
-      new TypeLiteral<RestView<HumanCommentResource>>() {};
+      new TypeLiteral<>() {};
 
   private final RevisionResource rev;
   private final HumanComment comment;
diff --git a/java/com/google/gerrit/server/change/IncludedInRefs.java b/java/com/google/gerrit/server/change/IncludedInRefs.java
index f069251..17a0c9d 100644
--- a/java/com/google/gerrit/server/change/IncludedInRefs.java
+++ b/java/com/google/gerrit/server/change/IncludedInRefs.java
@@ -16,11 +16,8 @@
 
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
@@ -28,7 +25,6 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -55,8 +51,7 @@
 
   public Map<String, Set<String>> apply(
       Project.NameKey project, Set<String> commits, Set<String> refNames)
-      throws ResourceConflictException, BadRequestException, IOException,
-          PermissionBackendException, ResourceNotFoundException, AuthException {
+      throws IOException, PermissionBackendException {
     try (Repository repo = repoManager.openRepository(project)) {
       Set<Ref> visibleRefs = getVisibleRefs(repo, refNames, project);
 
@@ -72,7 +67,7 @@
         }
       }
     }
-    return Collections.EMPTY_MAP;
+    return ImmutableMap.of();
   }
 
   private Set<Ref> getVisibleRefs(Repository repo, Set<String> refNames, Project.NameKey project)
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index 5ce121b..69a84dd8 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -48,7 +48,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -95,53 +95,44 @@
     return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
   }
 
-  /** Returns all labels that the provided user has permission to vote on. */
+  /**
+   * Returns A map of all label names and the values that the provided user has permission to vote
+   * on.
+   *
+   * @param filterApprovalsBy a Gerrit user ID.
+   * @param cd {@link ChangeData} corresponding to a specific gerrit change.
+   * @return A Map where the key contain a label name, and the value is a list of the permissible
+   *     vote values that the user can vote on.
+   */
   Map<String, Collection<String>> permittedLabels(Account.Id filterApprovalsBy, ChangeData cd)
       throws PermissionBackendException {
-    boolean isMerged = cd.change().isMerged();
-    LabelTypes labelTypes = cd.getLabelTypes();
-    Map<String, LabelType> toCheck = new HashMap<>();
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels != null) {
-        for (SubmitRecord.Label r : rec.labels) {
-          Optional<LabelType> type = labelTypes.byLabel(r.label);
-          if (type.isPresent() && (!isMerged || type.get().isAllowPostSubmit())) {
-            toCheck.put(type.get().getName(), type.get());
-          }
-        }
-      }
-    }
-
-    Map<String, Short> labels = null;
-    Set<LabelPermission.WithValue> can =
-        permissionBackend.absentUser(filterApprovalsBy).change(cd).testLabels(toCheck.values());
     SetMultimap<String, String> permitted = LinkedHashMultimap.create();
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels == null) {
+    boolean isMerged = cd.change().isMerged();
+    Map<String, Short> currentUserVotes = currentLabels(filterApprovalsBy, cd);
+    for (LabelType labelType : cd.getLabelTypes().getLabelTypes()) {
+      if (isMerged && !labelType.isAllowPostSubmit()) {
         continue;
       }
-      for (SubmitRecord.Label r : rec.labels) {
-        Optional<LabelType> type = labelTypes.byLabel(r.label);
-        if (!type.isPresent() || (isMerged && !type.get().isAllowPostSubmit())) {
-          continue;
+      Set<LabelPermission.WithValue> can =
+          permissionBackend.absentUser(filterApprovalsBy).change(cd).test(labelType);
+      for (LabelValue v : labelType.getValues()) {
+        boolean ok = can.contains(new LabelPermission.WithValue(labelType, v));
+        if (isMerged) {
+          // Votes cannot be decreased if the change is merged. Only accept the label value if it's
+          // greater or equal than the user's latest vote.
+          short prev = currentUserVotes.getOrDefault(labelType.getName(), (short) 0);
+          ok &= v.getValue() >= prev;
         }
-
-        for (LabelValue v : type.get().getValues()) {
-          boolean ok = can.contains(new LabelPermission.WithValue(type.get(), v));
-          if (isMerged) {
-            if (labels == null) {
-              labels = currentLabels(filterApprovalsBy, cd);
-            }
-            short prev = labels.getOrDefault(type.get().getName(), (short) 0);
-            ok &= v.getValue() >= prev;
-          }
-          if (ok) {
-            permitted.put(r.label, v.formatValue());
-          }
+        if (ok) {
+          permitted.put(labelType.getName(), v.formatValue());
         }
       }
     }
+    clearOnlyZerosEntries(permitted);
+    return permitted.asMap();
+  }
 
+  private static void clearOnlyZerosEntries(SetMultimap<String, String> permitted) {
     List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
     for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
       if (isOnlyZero(e.getValue())) {
@@ -151,7 +142,6 @@
     for (String label : toClear) {
       permitted.removeAll(label);
     }
-    return permitted.asMap();
   }
 
   private static boolean isOnlyZero(Collection<String> values) {
@@ -173,9 +163,8 @@
       boolean detailed)
       throws PermissionBackendException {
     Map<String, LabelWithStatus> labels = initLabels(accountLoader, cd, labelTypes, standard);
-    if (detailed) {
-      setAllApprovals(accountLoader, cd, labels);
-    }
+    setAllApprovals(accountLoader, cd, labels, detailed);
+
     for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
       Optional<LabelType> type = labelTypes.byLabel(e.getKey());
       if (!type.isPresent()) {
@@ -190,9 +179,7 @@
           }
         }
       }
-      if (detailed) {
-        setLabelValues(type.get(), e.getValue());
-      }
+      setLabelValues(type.get(), e.getValue());
     }
     return labels;
   }
@@ -209,10 +196,10 @@
   private ApprovalInfo approvalInfo(
       AccountLoader accountLoader,
       Account.Id id,
-      Integer value,
-      VotingRangeInfo permittedVotingRange,
-      String tag,
-      Timestamp date) {
+      @Nullable Integer value,
+      @Nullable VotingRangeInfo permittedVotingRange,
+      @Nullable String tag,
+      @Nullable Instant date) {
     ApprovalInfo ai = new ApprovalInfo(id.get(), value, permittedVotingRange, tag, date);
     accountLoader.put(ai);
     return ai;
@@ -291,22 +278,20 @@
       }
     }
 
-    if (detailed) {
-      labels.entrySet().stream()
-          .filter(e -> labelTypes.byLabel(e.getKey()).isPresent())
-          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()).get(), e.getValue()));
-    }
+    labels.entrySet().stream()
+        .filter(e -> labelTypes.byLabel(e.getKey()).isPresent())
+        .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()).get(), e.getValue()));
 
     for (Account.Id accountId : allUsers) {
       Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
       Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
       if (detailed) {
         pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
-        for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
-          ApprovalInfo ai = approvalInfo(accountLoader, accountId, 0, null, null, null);
-          byLabel.put(entry.getKey(), ai);
-          addApproval(entry.getValue().label(), ai);
-        }
+      }
+      for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
+        ApprovalInfo ai = approvalInfo(accountLoader, accountId, 0, null, null, null);
+        byLabel.put(entry.getKey(), ai);
+        addApproval(entry.getValue().label(), ai);
       }
       for (PatchSetApproval psa : current.get(accountId)) {
         Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
@@ -319,7 +304,7 @@
         if (info != null) {
           info.value = Integer.valueOf(val);
           info.permittedVotingRange = pvr.getOrDefault(type.get().getName(), null);
-          info.date = psa.granted();
+          info.setDate(psa.granted());
           info.tag = psa.tag().orElse(null);
           if (psa.postSubmit()) {
             info.postSubmit = true;
@@ -368,9 +353,23 @@
         }
       }
     }
+    setLabelsDescription(labels, labelTypes);
     return labels;
   }
 
+  private void setLabelsDescription(
+      Map<String, LabelsJson.LabelWithStatus> labels, LabelTypes labelTypes) {
+    for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
+      String labelName = entry.getKey();
+      Optional<LabelType> type = labelTypes.byLabel(labelName);
+      if (!type.isPresent()) {
+        continue;
+      }
+      LabelWithStatus labelWithStatus = entry.getValue();
+      labelWithStatus.label().description = type.get().getDescription().orElse(null);
+    }
+  }
+
   private void setLabelScores(
       AccountLoader accountLoader,
       LabelType type,
@@ -402,7 +401,10 @@
   }
 
   private void setAllApprovals(
-      AccountLoader accountLoader, ChangeData cd, Map<String, LabelWithStatus> labels)
+      AccountLoader accountLoader,
+      ChangeData cd,
+      Map<String, LabelWithStatus> labels,
+      boolean detailed)
       throws PermissionBackendException {
     checkState(
         !cd.change().isMerged(),
@@ -426,8 +428,12 @@
 
     LabelTypes labelTypes = cd.getLabelTypes();
     for (Account.Id accountId : allUsers) {
-      PermissionBackend.ForChange perm = permissionBackend.absentUser(accountId).change(cd);
-      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
+      Map<String, VotingRangeInfo> pvr = null;
+      PermissionBackend.ForChange perm = null;
+      if (detailed) {
+        perm = permissionBackend.absentUser(accountId).change(cd);
+        pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
+      }
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
         Optional<LabelType> lt = labelTypes.byLabel(e.getKey());
         if (!lt.isPresent()) {
@@ -436,9 +442,10 @@
           continue;
         }
         Integer value;
-        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.get().getName(), null);
+        VotingRangeInfo permittedVotingRange =
+            pvr == null ? null : pvr.getOrDefault(lt.get().getName(), null);
         String tag = null;
-        Timestamp date = null;
+        Instant date = null;
         PatchSetApproval psa = current.get(accountId, lt.get().getName());
         if (psa != null) {
           value = Integer.valueOf(psa.value());
@@ -446,7 +453,7 @@
             // This may be a dummy approval that was inserted when the reviewer
             // was added. Explicitly check whether the user can vote on this
             // label.
-            value = perm.test(new LabelPermission(lt.get())) ? 0 : null;
+            value = perm != null && perm.test(new LabelPermission(lt.get())) ? 0 : null;
           }
           tag = psa.tag().orElse(null);
           date = psa.granted();
@@ -457,7 +464,7 @@
           // Either the user cannot vote on this label, or they were added as a
           // reviewer but have not responded yet. Explicitly check whether the
           // user can vote on this label.
-          value = perm.test(new LabelPermission(lt.get())) ? 0 : null;
+          value = perm != null && perm.test(new LabelPermission(lt.get())) ? 0 : null;
         }
         addApproval(
             e.getValue().label(),
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 209901d..fc56e80 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -100,6 +100,7 @@
   private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private List<String> groups = Collections.emptyList();
+  private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
   private boolean fireRevisionCreated = true;
   private boolean allowClosed;
   private boolean sendEmail = true;
@@ -184,6 +185,13 @@
     return this;
   }
 
+  public PatchSetInserter setValidationOptions(
+      ImmutableListMultimap<String, String> validationOptions) {
+    requireNonNull(validationOptions, "validationOptions may not be null");
+    this.validationOptions = validationOptions;
+    return this;
+  }
+
   public PatchSetInserter setFireRevisionCreated(boolean fireRevisionCreated) {
     this.fireRevisionCreated = fireRevisionCreated;
     return this;
@@ -298,10 +306,9 @@
       }
     }
 
-    // Approvals that are being set in the new patch-set during this operation are not available yet
-    // outside of the scope of this method. Only copied approvals are set here.
     if (storeCopiedVotes) {
-      approvalsUtil.byPatchSet(ctx.getNotes(), patchSet).forEach(a -> update.putCopiedApproval(a));
+      approvalsUtil.persistCopiedApprovals(
+          ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
     }
 
     return true;
@@ -368,7 +375,7 @@
                 .orElseThrow(illegalState(origNotes.getProjectName()))
                 .getProject(),
             origNotes.getChange().getDest().branch(),
-            ImmutableListMultimap.of(),
+            validationOptions,
             ctx.getRepoView().getConfig(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 3e67cca..a0fa8e9 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -17,8 +17,10 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -94,6 +96,7 @@
   private boolean sendEmail = true;
   private boolean storeCopiedVotes = true;
   private boolean matchAuthorToCommitterDate = false;
+  private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
 
   private CodeReviewCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
@@ -191,6 +194,13 @@
     return this;
   }
 
+  public RebaseChangeOp setValidationOptions(
+      ImmutableListMultimap<String, String> validationOptions) {
+    requireNonNull(validationOptions, "validationOptions may not be null");
+    this.validationOptions = validationOptions;
+    return this;
+  }
+
   @Override
   public void updateRepo(RepoContext ctx)
       throws MergeConflictException, InvalidChangeOperationException, RestApiException, IOException,
@@ -241,6 +251,8 @@
       patchSetInserter.setWorkInProgress(true);
     }
 
+    patchSetInserter.setValidationOptions(validationOptions);
+
     if (postMessage) {
       patchSetInserter.setMessage(
           messageForRebasedChange(rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
@@ -392,7 +404,7 @@
     if (committerIdent != null) {
       cb.setCommitter(committerIdent);
     } else {
-      cb.setCommitter(ctx.getIdentifiedUser().newCommitterIdent(ctx.getWhen(), ctx.getTimeZone()));
+      cb.setCommitter(ctx.newCommitterIdent());
     }
     if (matchAuthorToCommitterDate) {
       cb.setAuthor(
diff --git a/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index 547452e..719578f 100644
--- a/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.change.RelatedChangesSorter.PatchSetData;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -196,7 +195,7 @@
       List<PatchSetData> start)
       throws PermissionBackendException {
     if (start.isEmpty()) {
-      return ImmutableList.of();
+      return new ArrayList<>();
     }
     Map<Change.Id, PatchSet.Id> maxPatchSetIds = new HashMap<>();
     Set<PatchSetData> seen = new HashSet<>();
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index 6189708..f0f3a8f 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.permissions.LabelPermission;
@@ -129,11 +128,8 @@
             continue;
           }
 
-          try {
-            perm.check(new LabelPermission(type.get()));
+          if (perm.test(new LabelPermission(type.get()))) {
             out.approvals.put(name, formatValue((short) 0));
-          } catch (AuthException e) {
-            // Do nothing.
           }
         }
       }
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index fffb107..c98fcaa 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -48,7 +48,6 @@
 import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.AnonymousUser;
@@ -378,9 +377,10 @@
   @Nullable
   private ReviewerModification addByEmail(ReviewerInput input, ChangeNotes notes, CurrentUser user)
       throws PermissionBackendException {
-    try {
-      permissionBackend.user(anonymousProvider.get()).change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
+    if (!permissionBackend
+        .user(anonymousProvider.get())
+        .change(notes)
+        .test(ChangePermission.READ)) {
       return fail(
           input,
           FailureType.OTHER,
@@ -399,15 +399,10 @@
 
   private boolean isValidReviewer(BranchNameKey branch, Account member)
       throws PermissionBackendException {
-    try {
-      // Check ref permission instead of change permission, since change permissions take into
-      // account the private bit, whereas adding a user as a reviewer is explicitly allowing them to
-      // see private changes.
-      permissionBackend.absentUser(member.id()).ref(branch).check(RefPermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    // Check ref permission instead of change permission, since change permissions take into
+    // account the private bit, whereas adding a user as a reviewer is explicitly allowing them to
+    // see private changes.
+    return permissionBackend.absentUser(member.id()).ref(branch).test(RefPermission.READ);
   }
 
   private ReviewerModification fail(ReviewerInput input, FailureType failureType, String error) {
@@ -648,7 +643,7 @@
     }
 
     public <T> ImmutableSet<T> flattenResults(
-        Function<AddReviewersOp.Result, ? extends Collection<T>> func) {
+        Function<ReviewerOp.Result, ? extends Collection<T>> func) {
       modifications()
           .forEach(
               a ->
diff --git a/java/com/google/gerrit/server/change/ReviewerResource.java b/java/com/google/gerrit/server/change/ReviewerResource.java
index 7a98f2b..e688a7b 100644
--- a/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -29,7 +29,7 @@
 
 public class ReviewerResource implements RestResource {
   public static final TypeLiteral<RestView<ReviewerResource>> REVIEWER_KIND =
-      new TypeLiteral<RestView<ReviewerResource>>() {};
+      new TypeLiteral<>() {};
 
   public interface Factory {
     ReviewerResource create(ChangeResource change, Account.Id id);
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index fe45aa5..0170f35 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -48,7 +48,6 @@
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.Extension;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
@@ -169,7 +168,8 @@
       RevCommit commit,
       boolean addLinks,
       boolean fillCommit,
-      String branchName)
+      String branchName,
+      String changeKey)
       throws IOException {
     CommitInfo info = new CommitInfo();
     if (fillCommit) {
@@ -183,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(
@@ -286,7 +287,7 @@
     out.isCurrent = in.id().equals(c.currentPatchSetId());
     out._number = in.id().get();
     out.ref = in.refName();
-    out.created = in.createdOn();
+    out.setCreated(in.createdOn());
     out.uploader = accountLoader.get(in.uploader());
     out.fetch = makeFetchMap(cd, in);
     out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
@@ -303,7 +304,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);
@@ -354,9 +357,7 @@
   }
 
   private boolean isWorldReadable(ChangeData cd) throws PermissionBackendException {
-    try {
-      permissionBackend.user(anonymous).change(cd).check(ChangePermission.READ);
-    } catch (AuthException ae) {
+    if (!permissionBackend.user(anonymous).change(cd).test(ChangePermission.READ)) {
       return false;
     }
     ProjectState projectState =
diff --git a/java/com/google/gerrit/server/change/RevisionResource.java b/java/com/google/gerrit/server/change/RevisionResource.java
index 30fa593..e5a57b2 100644
--- a/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/java/com/google/gerrit/server/change/RevisionResource.java
@@ -35,7 +35,7 @@
 
 public class RevisionResource implements RestResource, HasETag {
   public static final TypeLiteral<RestView<RevisionResource>> REVISION_KIND =
-      new TypeLiteral<RestView<RevisionResource>>() {};
+      new TypeLiteral<>() {};
 
   public static RevisionResource createNonCacheable(ChangeResource change, PatchSet ps) {
     return new RevisionResource(change, ps, Optional.empty(), false);
diff --git a/java/com/google/gerrit/server/change/RobotCommentResource.java b/java/com/google/gerrit/server/change/RobotCommentResource.java
index b12727d..3662575 100644
--- a/java/com/google/gerrit/server/change/RobotCommentResource.java
+++ b/java/com/google/gerrit/server/change/RobotCommentResource.java
@@ -23,7 +23,7 @@
 
 public class RobotCommentResource implements RestResource {
   public static final TypeLiteral<RestView<RobotCommentResource>> ROBOT_COMMENT_KIND =
-      new TypeLiteral<RestView<RobotCommentResource>>() {};
+      new TypeLiteral<>() {};
 
   private final RevisionResource rev;
   private final RobotComment comment;
diff --git a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
index 8eeec62..96c863e 100644
--- a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
+++ b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
@@ -36,28 +36,39 @@
     if (req.applicabilityExpression().isPresent()) {
       info.applicabilityExpressionResult =
           submitRequirementExpressionToInfo(
-              req.applicabilityExpression().get(), result.applicabilityExpressionResult().get());
+              req.applicabilityExpression().get(),
+              result.applicabilityExpressionResult().get(),
+              /* hide= */ true); // Always hide applicability expressions on the API
     }
-    if (req.overrideExpression().isPresent()) {
+    if (req.overrideExpression().isPresent() && result.overrideExpressionResult().isPresent()) {
       info.overrideExpressionResult =
           submitRequirementExpressionToInfo(
-              req.overrideExpression().get(), result.overrideExpressionResult().get());
+              req.overrideExpression().get(),
+              result.overrideExpressionResult().get(),
+              /* hide= */ false);
     }
-    info.submittabilityExpressionResult =
-        submitRequirementExpressionToInfo(
-            req.submittabilityExpression(), result.submittabilityExpressionResult());
+    if (result.submittabilityExpressionResult().isPresent()) {
+      info.submittabilityExpressionResult =
+          submitRequirementExpressionToInfo(
+              req.submittabilityExpression(),
+              result.submittabilityExpressionResult().get(),
+              /* hide= */ false);
+    }
     info.status = SubmitRequirementResultInfo.Status.valueOf(result.status().toString());
     info.isLegacy = result.isLegacy();
     return info;
   }
 
   private static SubmitRequirementExpressionInfo submitRequirementExpressionToInfo(
-      SubmitRequirementExpression expression, SubmitRequirementExpressionResult result) {
+      SubmitRequirementExpression expression,
+      SubmitRequirementExpressionResult result,
+      boolean hide) {
     SubmitRequirementExpressionInfo info = new SubmitRequirementExpressionInfo();
-    info.expression = expression.expressionString();
+    info.expression = hide ? null : expression.expressionString();
     info.fulfilled = result.status().equals(SubmitRequirementExpressionResult.Status.PASS);
-    info.passingAtoms = result.passingAtoms();
-    info.failingAtoms = result.failingAtoms();
+    info.passingAtoms = hide ? null : result.passingAtoms();
+    info.failingAtoms = hide ? null : result.failingAtoms();
+    info.errorMessage = result.errorMessage().isPresent() ? result.errorMessage().get() : null;
     return info;
   }
 }
diff --git a/java/com/google/gerrit/server/change/VoteResource.java b/java/com/google/gerrit/server/change/VoteResource.java
index 27b5bec..3f5ec4c 100644
--- a/java/com/google/gerrit/server/change/VoteResource.java
+++ b/java/com/google/gerrit/server/change/VoteResource.java
@@ -19,8 +19,7 @@
 import com.google.inject.TypeLiteral;
 
 public class VoteResource implements RestResource {
-  public static final TypeLiteral<RestView<VoteResource>> VOTE_KIND =
-      new TypeLiteral<RestView<VoteResource>>() {};
+  public static final TypeLiteral<RestView<VoteResource>> VOTE_KIND = new TypeLiteral<>() {};
 
   private final ReviewerResource reviewer;
   private final String label;
diff --git a/java/com/google/gerrit/server/change/WalkSorter.java b/java/com/google/gerrit/server/change/WalkSorter.java
index 816a904..44a3d16 100644
--- a/java/com/google/gerrit/server/change/WalkSorter.java
+++ b/java/com/google/gerrit/server/change/WalkSorter.java
@@ -112,8 +112,8 @@
     return Iterables.concat(sortedByProject);
   }
 
-  private List<PatchSetData> sortProject(Project.NameKey project, Collection<ChangeData> in)
-      throws IOException {
+  private ImmutableList<PatchSetData> sortProject(
+      Project.NameKey project, Collection<ChangeData> in) throws IOException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       rw.setRetainBody(retainBody);
@@ -181,7 +181,7 @@
           }
         }
       }
-      return result;
+      return ImmutableList.copyOf(result);
     }
   }
 
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/comment/CommentContextLoader.java b/java/com/google/gerrit/server/comment/CommentContextLoader.java
index 8fbb259..9a1710d 100644
--- a/java/com/google/gerrit/server/comment/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/comment/CommentContextLoader.java
@@ -200,7 +200,7 @@
       Text src, Range commentRange, int contextPadding, String contentType) {
     if (commentRange.start() < 1 || commentRange.end() - 1 > src.size()) {
       // TODO(ghareeb): We should throw an exception in this case. See
-      // https://bugs.chromium.org/p/gerrit/issues/detail?id=14102 which is an example where the
+      // https://issues.gerritcodereview.com/issues/40013461 which is an example where the
       // diff contains an extra line not in the original file.
       return CommentContext.empty();
     }
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/CacheResource.java b/java/com/google/gerrit/server/config/CacheResource.java
index 7a835b1..b2f790c 100644
--- a/java/com/google/gerrit/server/config/CacheResource.java
+++ b/java/com/google/gerrit/server/config/CacheResource.java
@@ -21,8 +21,7 @@
 import com.google.inject.TypeLiteral;
 
 public class CacheResource extends ConfigResource {
-  public static final TypeLiteral<RestView<CacheResource>> CACHE_KIND =
-      new TypeLiteral<RestView<CacheResource>>() {};
+  public static final TypeLiteral<RestView<CacheResource>> CACHE_KIND = new TypeLiteral<>() {};
 
   private final String name;
   private final Provider<Cache<?, ?>> cacheProvider;
diff --git a/java/com/google/gerrit/server/config/CapabilityResource.java b/java/com/google/gerrit/server/config/CapabilityResource.java
index 7e3c87e..5a1977b 100644
--- a/java/com/google/gerrit/server/config/CapabilityResource.java
+++ b/java/com/google/gerrit/server/config/CapabilityResource.java
@@ -19,5 +19,5 @@
 
 public class CapabilityResource extends ConfigResource {
   public static final TypeLiteral<RestView<CapabilityResource>> CAPABILITY_KIND =
-      new TypeLiteral<RestView<CapabilityResource>>() {};
+      new TypeLiteral<>() {};
 }
diff --git a/java/com/google/gerrit/server/config/ConfigResource.java b/java/com/google/gerrit/server/config/ConfigResource.java
index f2b7c8e..93efd19 100644
--- a/java/com/google/gerrit/server/config/ConfigResource.java
+++ b/java/com/google/gerrit/server/config/ConfigResource.java
@@ -21,8 +21,7 @@
 import java.util.concurrent.TimeUnit;
 
 public class ConfigResource implements RestResource {
-  public static final TypeLiteral<RestView<ConfigResource>> CONFIG_KIND =
-      new TypeLiteral<RestView<ConfigResource>>() {};
+  public static final TypeLiteral<RestView<ConfigResource>> CONFIG_KIND = new TypeLiteral<>() {};
 
   /**
    * Default cache control that gets set on the 'Cache-Control' header for responses on this
diff --git a/java/com/google/gerrit/server/config/ConfigSection.java b/java/com/google/gerrit/server/config/ConfigSection.java
deleted file mode 100644
index 057ce99..0000000
--- a/java/com/google/gerrit/server/config/ConfigSection.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this 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 org.eclipse.jgit.lib.Config;
-
-/** Provides access to one section from {@link Config} */
-public class ConfigSection {
-
-  private final Config cfg;
-  private final String section;
-
-  public ConfigSection(Config cfg, String section) {
-    this.cfg = cfg;
-    this.section = section;
-  }
-
-  public String optional(String name) {
-    return cfg.getString(section, null, name);
-  }
-
-  public String required(String name) {
-    return ConfigUtil.getRequired(cfg, section, name);
-  }
-}
diff --git a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
index 4032e63..7fd075e 100644
--- a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
+++ b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
@@ -20,7 +20,7 @@
 import java.util.LinkedHashSet;
 import java.util.Objects;
 import java.util.Set;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.lib.Config;
 
 /**
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
index c44b0fd..5d94255 100644
--- a/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -145,7 +145,7 @@
             list.add(getEnum(section, subsection, setting, string, all));
           } catch (IllegalArgumentException ex) {
             // It's better to ignore a wrongly configured enum, rather than fail to load Gerrit.
-            logger.atWarning().log(ex.getMessage());
+            logger.atWarning().log("%s", ex.getMessage());
           }
         }
       }
diff --git a/java/com/google/gerrit/server/config/DownloadConfig.java b/java/com/google/gerrit/server/config/DownloadConfig.java
index 7e80409..85081e4 100644
--- a/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -24,7 +24,6 @@
 import com.google.inject.Singleton;
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.HashSet;
@@ -56,16 +55,17 @@
           ImmutableSet.of(
               CoreDownloadSchemes.SSH, CoreDownloadSchemes.HTTP, CoreDownloadSchemes.ANON_HTTP);
     } else {
-      List<String> normalized = new ArrayList<>(allSchemes.length);
+      ImmutableSet.Builder<String> normalized =
+          ImmutableSet.builderWithExpectedSize(allSchemes.length);
       for (String s : allSchemes) {
         String core = toCoreScheme(s);
         if (core == null) {
-          logger.atWarning().log("not a core download scheme: " + s);
+          logger.atWarning().log("not a core download scheme: %s", s);
           continue;
         }
         normalized.add(core);
       }
-      downloadSchemes = ImmutableSet.copyOf(normalized);
+      downloadSchemes = normalized.build();
     }
 
     Set<String> hidden = new HashSet<>(Arrays.asList(cfg.getStringList("download", null, "hide")));
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 75df0e8..d6baa7f 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -16,7 +16,9 @@
 
 import static com.google.inject.Scopes.SINGLETON;
 
+import com.google.common.base.Ticker;
 import com.google.common.cache.Cache;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
@@ -43,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;
@@ -106,7 +109,6 @@
 import com.google.gerrit.server.account.externalids.ExternalIdCacheModule;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
-import com.google.gerrit.server.approval.ApprovalCacheImpl;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
@@ -167,9 +169,8 @@
 import com.google.gerrit.server.mail.send.FromAddressGenerator;
 import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
 import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
-import com.google.gerrit.server.mail.send.MailSoySauceProvider;
+import com.google.gerrit.server.mail.send.MailSoySauceModule;
 import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
-import com.google.gerrit.server.mail.send.MailTemplates;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.notedb.NoteDbModule;
@@ -187,13 +188,16 @@
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.FileEditsPredicate;
 import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
+import com.google.gerrit.server.query.change.DistinctVotersPredicate;
 import com.google.gerrit.server.quota.QuotaEnforcer;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
@@ -224,7 +228,7 @@
 import com.google.inject.Inject;
 import com.google.inject.TypeLiteral;
 import com.google.inject.internal.UniqueAnnotations;
-import com.google.template.soy.jbcsrc.api.SoySauce;
+import com.google.inject.multibindings.OptionalBinder;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.transport.PostReceiveHook;
@@ -248,7 +252,6 @@
     bind(RulesCache.class);
     bind(BlameCache.class).to(BlameCacheImpl.class);
     install(AccountCacheImpl.module());
-    install(ApprovalCacheImpl.module());
     install(BatchUpdate.module());
     install(ChangeKindCacheImpl.module());
     install(ChangeFinder.module());
@@ -286,11 +289,14 @@
     install(new FileInfoJsonModule());
     install(ThreadLocalRequestContext.module());
     install(new ApprovalModule());
+    install(new MailSoySauceModule());
+    install(new SkipCurrentRulesEvaluationOnClosedChangesModule());
 
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeJson.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
+    factory(DistinctVotersPredicate.Factory.class);
     factory(DeadlineChecker.Factory.class);
     factory(MergeUtil.Factory.class);
     factory(MultiProgressMonitor.Factory.class);
@@ -327,7 +333,6 @@
 
     bind(ApprovalsUtil.class);
 
-    bind(SoySauce.class).annotatedWith(MailTemplates.class).toProvider(MailSoySauceProvider.class);
     bind(FromAddressGenerator.class).toProvider(FromAddressGeneratorProvider.class).in(SINGLETON);
     bind(Boolean.class)
         .annotatedWith(EnablePeerIPInReflogRecord.class)
@@ -337,6 +342,9 @@
     bind(PatchSetInfoFactory.class);
     bind(IdentifiedUser.GenericFactory.class).in(SINGLETON);
     bind(AccountControl.Factory.class);
+    OptionalBinder.newOptionalBinder(binder(), Ticker.class)
+        .setDefault()
+        .toInstance(Ticker.systemTicker());
 
     bind(UiActions.class);
 
@@ -346,6 +354,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);
@@ -381,13 +390,15 @@
     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);
     DynamicSet.bind(binder(), EventListener.class).to(EventsMetrics.class);
     DynamicSet.setOf(binder(), UserScopedEventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
+    DynamicSet.bind(binder(), CommitValidationListener.class)
+        .to(SubmitRequirementExpressionsValidator.class);
     DynamicSet.setOf(binder(), CommentValidator.class);
     DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
@@ -430,6 +441,7 @@
     DynamicItem.itemOf(binder(), MergeSuperSetComputation.class);
     DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
     DynamicSet.setOf(binder(), SubmitRule.class);
+    DynamicSet.setOf(binder(), SubmitRequirement.class);
     DynamicSet.setOf(binder(), QuotaEnforcer.class);
     DynamicSet.setOf(binder(), PerformanceLogger.class);
     if (cfg.getBoolean("tracing", "exportPerformanceMetrics", false)) {
@@ -485,6 +497,7 @@
     factory(GitModules.Factory.class);
     factory(VersionedAuthorizedKeys.Factory.class);
     factory(StoreSubmitRequirementsOp.Factory.class);
+    factory(FileEditsPredicate.Factory.class);
 
     bind(AccountManager.class);
     bind(SubscriptionGraph.Factory.class).to(ConfiguredSubscriptionGraphFactory.class);
diff --git a/java/com/google/gerrit/server/config/SkipCurrentRulesEvaluationOnClosedChanges.java b/java/com/google/gerrit/server/config/SkipCurrentRulesEvaluationOnClosedChanges.java
new file mode 100644
index 0000000..b812710
--- /dev/null
+++ b/java/com/google/gerrit/server/config/SkipCurrentRulesEvaluationOnClosedChanges.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/** Marker on a {@link Boolean} holding the evaluation of current Prolog rules on closed changes. */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface SkipCurrentRulesEvaluationOnClosedChanges {}
diff --git a/java/com/google/gerrit/server/config/SkipCurrentRulesEvaluationOnClosedChangesModule.java b/java/com/google/gerrit/server/config/SkipCurrentRulesEvaluationOnClosedChangesModule.java
new file mode 100644
index 0000000..9eaae02
--- /dev/null
+++ b/java/com/google/gerrit/server/config/SkipCurrentRulesEvaluationOnClosedChangesModule.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.inject.AbstractModule;
+
+public class SkipCurrentRulesEvaluationOnClosedChangesModule extends AbstractModule {
+
+  @Override
+  protected void configure() {
+    bind(Boolean.class)
+        .annotatedWith(SkipCurrentRulesEvaluationOnClosedChanges.class)
+        .toProvider(SkipCurrentRulesEvaluationOnClosedChangesProvider.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/config/SkipCurrentRulesEvaluationOnClosedChangesProvider.java b/java/com/google/gerrit/server/config/SkipCurrentRulesEvaluationOnClosedChangesProvider.java
new file mode 100644
index 0000000..bc25a33
--- /dev/null
+++ b/java/com/google/gerrit/server/config/SkipCurrentRulesEvaluationOnClosedChangesProvider.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class SkipCurrentRulesEvaluationOnClosedChangesProvider implements Provider<Boolean> {
+  private final Boolean value;
+
+  @Inject
+  SkipCurrentRulesEvaluationOnClosedChangesProvider(@GerritServerConfig Config config) {
+    value = config.getBoolean("change", null, "skipCurrentRulesEvaluationOnClosedChanges", false);
+  }
+
+  @Override
+  public Boolean get() {
+    return value;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/SshClientImplementation.java b/java/com/google/gerrit/server/config/SshClientImplementation.java
deleted file mode 100644
index 5811e4d..0000000
--- a/java/com/google/gerrit/server/config/SshClientImplementation.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// 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.config;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Enums;
-import com.google.common.base.Strings;
-
-/* SSH implementation to use by JGit SSH client transport protocol. */
-public enum SshClientImplementation {
-  /** JCraft JSch implementation. */
-  JSCH,
-
-  /** Apache MINA implementation. */
-  APACHE;
-
-  private static final String ENV_VAR = "SSH_CLIENT_IMPLEMENTATION";
-  private static final String SYS_PROP = "gerrit.sshClientImplementation";
-
-  @VisibleForTesting
-  public static SshClientImplementation getFromEnvironment() {
-    String value = System.getenv(ENV_VAR);
-    if (Strings.isNullOrEmpty(value)) {
-      value = System.getProperty(SYS_PROP);
-    }
-    if (Strings.isNullOrEmpty(value)) {
-      return APACHE;
-    }
-    SshClientImplementation client =
-        Enums.getIfPresent(SshClientImplementation.class, value).orNull();
-    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
-      checkArgument(
-          client != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
-    } else {
-      checkArgument(
-          client != null,
-          "Invalid value for system property %s: %s",
-          SYS_PROP,
-          System.getProperty(SYS_PROP));
-    }
-    return client;
-  }
-
-  public boolean isMina() {
-    return this == APACHE;
-  }
-}
diff --git a/java/com/google/gerrit/server/config/TaskResource.java b/java/com/google/gerrit/server/config/TaskResource.java
index 7b69533..dac455f 100644
--- a/java/com/google/gerrit/server/config/TaskResource.java
+++ b/java/com/google/gerrit/server/config/TaskResource.java
@@ -19,8 +19,7 @@
 import com.google.inject.TypeLiteral;
 
 public class TaskResource extends ConfigResource {
-  public static final TypeLiteral<RestView<TaskResource>> TASK_KIND =
-      new TypeLiteral<RestView<TaskResource>>() {};
+  public static final TypeLiteral<RestView<TaskResource>> TASK_KIND = new TypeLiteral<>() {};
 
   private final Task<?> task;
 
diff --git a/java/com/google/gerrit/server/config/TopMenuResource.java b/java/com/google/gerrit/server/config/TopMenuResource.java
index bca6331..f5c71ed 100644
--- a/java/com/google/gerrit/server/config/TopMenuResource.java
+++ b/java/com/google/gerrit/server/config/TopMenuResource.java
@@ -18,6 +18,5 @@
 import com.google.inject.TypeLiteral;
 
 public class TopMenuResource extends ConfigResource {
-  public static final TypeLiteral<RestView<TopMenuResource>> TOP_MENU_KIND =
-      new TypeLiteral<RestView<TopMenuResource>>() {};
+  public static final TypeLiteral<RestView<TopMenuResource>> TOP_MENU_KIND = new TypeLiteral<>() {};
 }
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index d71f83e..c2c0b05 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -36,7 +36,7 @@
 import java.net.URL;
 import java.nio.charset.Charset;
 import java.util.concurrent.atomic.AtomicBoolean;
-import org.apache.commons.lang.StringEscapeUtils;
+import org.apache.commons.text.StringEscapeUtils;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.eclipse.jgit.util.TemporaryBuffer;
 
@@ -78,12 +78,12 @@
   }
 
   public MarkdownFormatter setCss(String css) {
-    this.css = StringEscapeUtils.escapeHtml(css);
+    this.css = StringEscapeUtils.escapeHtml4(css);
     return this;
   }
 
   private MutableDataHolder markDownOptions() {
-    int options = ALL & ~(HARDWRAPS);
+    int options = ALL & ~HARDWRAPS;
     if (suppressHtml) {
       options |= SUPPRESS_ALL_HTML;
     }
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index bc905c2..51b0d51 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -33,12 +33,14 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
 import com.google.gerrit.server.edit.tree.DeleteFileModification;
 import com.google.gerrit.server.edit.tree.RenameFileModification;
 import com.google.gerrit.server.edit.tree.RestoreFileModification;
 import com.google.gerrit.server.edit.tree.TreeCreator;
 import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -52,11 +54,11 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.diff.DiffAlgorithm;
 import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm;
 import org.eclipse.jgit.diff.RawText;
@@ -91,7 +93,7 @@
 @Singleton
 public class ChangeEditModifier {
 
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final Provider<CurrentUser> currentUser;
   private final PermissionBackend permissionBackend;
   private final ChangeEditUtil changeEditUtil;
@@ -107,15 +109,16 @@
       PermissionBackend permissionBackend,
       ChangeEditUtil changeEditUtil,
       PatchSetUtil patchSetUtil,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      GitReferenceUpdated gitRefUpdated) {
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
     this.changeEditUtil = changeEditUtil;
     this.patchSetUtil = patchSetUtil;
     this.projectCache = projectCache;
 
-    noteDbEdits = new NoteDbEdits(tz, indexer, currentUser);
+    noteDbEdits = new NoteDbEdits(zoneId, indexer, currentUser, gitRefUpdated);
   }
 
   /**
@@ -139,7 +142,7 @@
 
     PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
     ObjectId patchSetCommitId = currentPatchSet.commitId();
-    noteDbEdits.createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
+    noteDbEdits.createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.now());
   }
 
   /**
@@ -187,7 +190,7 @@
     RevTree basePatchSetTree = basePatchSetCommit.getTree();
 
     ObjectId newTreeId = merge(repository, changeEdit, basePatchSetTree);
-    Timestamp nowTimestamp = TimeUtil.nowTs();
+    Instant nowTimestamp = TimeUtil.now();
     String commitMessage = currentEditCommit.getFullMessage();
     ObjectId newEditCommitId =
         createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
@@ -385,7 +388,7 @@
       return unmodifiedEdit.get();
     }
 
-    Timestamp nowTimestamp = TimeUtil.nowTs();
+    Instant nowTimestamp = TimeUtil.now();
     ObjectId newEditCommit =
         createCommit(repository, basePatchsetCommit, newTreeId, newCommitMessage, nowTimestamp);
 
@@ -407,14 +410,15 @@
 
     // Not allowed to edit if the current patch set is locked.
     patchSetUtil.checkPatchSetNotLocked(notes);
-    try {
-      permissionBackend.currentUser().change(notes).check(ChangePermission.ADD_PATCH_SET);
-      projectCache
-          .get(notes.getProjectName())
-          .orElseThrow(illegalState(notes.getProjectName()))
-          .checkStatePermitsWrite();
-    } catch (AuthException denied) {
-      throw new AuthException("edit not permitted", denied);
+    boolean canEdit =
+        permissionBackend.currentUser().change(notes).test(ChangePermission.ADD_PATCH_SET);
+    canEdit &=
+        projectCache
+            .get(notes.getProjectName())
+            .orElseThrow(illegalState(notes.getProjectName()))
+            .statePermitsWrite();
+    if (!canEdit) {
+      throw new AuthException("edit not permitted");
     }
   }
 
@@ -501,7 +505,7 @@
       RevCommit basePatchsetCommit,
       ObjectId tree,
       String commitMessage,
-      Timestamp timestamp)
+      Instant timestamp)
       throws IOException {
     try (ObjectInserter objectInserter = repository.newObjectInserter()) {
       CommitBuilder builder = new CommitBuilder();
@@ -516,9 +520,9 @@
     }
   }
 
-  private PersonIdent getCommitterIdent(Timestamp commitTimestamp) {
+  private PersonIdent getCommitterIdent(Instant commitTimestamp) {
     IdentifiedUser user = currentUser.get().asIdentifiedUser();
-    return user.newCommitterIdent(commitTimestamp, tz);
+    return user.newCommitterIdent(commitTimestamp, zoneId);
   }
 
   /**
@@ -547,7 +551,7 @@
         ChangeNotes notes,
         PatchSet basePatchSet,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException;
   }
 
@@ -647,7 +651,7 @@
         ChangeNotes notes,
         PatchSet basePatchSet,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       return noteDbEdits.updateEdit(
           notes.getProjectName(), repository, changeEdit, newEditCommitId, timestamp);
@@ -701,21 +705,27 @@
         ChangeNotes notes,
         PatchSet basePatchSet,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       return noteDbEdits.createEdit(repository, notes, basePatchSet, newEditCommitId, timestamp);
     }
   }
 
   private static class NoteDbEdits {
-    private final TimeZone tz;
+    private final ZoneId zoneId;
     private final ChangeIndexer indexer;
     private final Provider<CurrentUser> currentUser;
+    private final GitReferenceUpdated gitRefUpdated;
 
-    NoteDbEdits(TimeZone tz, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
-      this.tz = tz;
+    NoteDbEdits(
+        ZoneId zoneId,
+        ChangeIndexer indexer,
+        Provider<CurrentUser> currentUser,
+        GitReferenceUpdated gitRefUpdated) {
+      this.zoneId = zoneId;
       this.indexer = indexer;
       this.currentUser = currentUser;
+      this.gitRefUpdated = gitRefUpdated;
     }
 
     ChangeEdit createEdit(
@@ -723,7 +733,7 @@
         ChangeNotes notes,
         PatchSet basePatchset,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       Change change = notes.getChange();
       String editRefName = getEditRefName(change, basePatchset);
@@ -750,7 +760,7 @@
         Repository repository,
         ChangeEdit changeEdit,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       String editRefName = changeEdit.getRefName();
       RevCommit currentEditCommit = changeEdit.getEditCommit();
@@ -769,8 +779,9 @@
         String refName,
         ObjectId currentObjectId,
         ObjectId targetObjectId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
+      AccountState userAccountState = currentUser.get().asIdentifiedUser().state();
       RefUpdate ru = repository.updateRef(refName);
       ru.setExpectedOldObjectId(currentObjectId);
       ru.setNewObjectId(targetObjectId);
@@ -786,6 +797,7 @@
         if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
           throw new IOException(message);
         }
+        gitRefUpdated.fire(projectName, ru, userAccountState);
       }
     }
 
@@ -795,7 +807,7 @@
         PatchSet currentPatchSet,
         ObjectId currentEditCommit,
         ObjectId newEditCommitId,
-        Timestamp nowTimestamp)
+        Instant nowTimestamp)
         throws IOException {
       String newEditRefName = getEditRefName(changeEdit.getChange(), currentPatchSet);
       updateReferenceWithNameChange(
@@ -814,7 +826,7 @@
         ObjectId currentObjectId,
         String newRefName,
         ObjectId targetObjectId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
       batchRefUpdate.addCommand(new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
@@ -838,9 +850,9 @@
       }
     }
 
-    private PersonIdent getRefLogIdent(Timestamp timestamp) {
+    private PersonIdent getRefLogIdent(Instant timestamp) {
       IdentifiedUser user = currentUser.get().asIdentifiedUser();
-      return user.newRefLogIdent(timestamp, tz);
+      return user.newRefLogIdent(timestamp, zoneId);
     }
 
     private void reindex(Change change) {
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 6b018ce..e413d43 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -29,9 +29,11 @@
 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.change.ChangeKindCache;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -69,6 +71,7 @@
   private final Provider<CurrentUser> userProvider;
   private final ChangeKindCache changeKindCache;
   private final PatchSetUtil psUtil;
+  private final GitReferenceUpdated gitRefUpdated;
 
   @Inject
   ChangeEditUtil(
@@ -77,13 +80,15 @@
       ChangeIndexer indexer,
       Provider<CurrentUser> userProvider,
       ChangeKindCache changeKindCache,
-      PatchSetUtil psUtil) {
+      PatchSetUtil psUtil,
+      GitReferenceUpdated gitRefUpdated) {
     this.gitManager = gitManager;
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.indexer = indexer;
     this.userProvider = userProvider;
     this.changeKindCache = changeKindCache;
     this.psUtil = psUtil;
+    this.gitRefUpdated = gitRefUpdated;
   }
 
   /**
@@ -185,7 +190,7 @@
         message.append("Published edit on patch set ").append(basePatchSet.number()).append(".");
       }
 
-      try (BatchUpdate bu = updateFactory.create(change.getProject(), user, TimeUtil.nowTs())) {
+      try (BatchUpdate bu = updateFactory.create(change.getProject(), user, TimeUtil.now())) {
         bu.setRepository(repo, rw, oi);
         bu.setNotify(notify);
         bu.addOp(change.getId(), inserter.setMessage(message.toString()));
@@ -237,7 +242,8 @@
     return writeSquashedCommit(rw, inserter, parent, edit);
   }
 
-  private static void deleteRef(Repository repo, ChangeEdit edit) throws IOException {
+  private void deleteRef(Repository repo, ChangeEdit edit) throws IOException {
+    AccountState userAccountState = userProvider.get().asIdentifiedUser().state();
     String refName = edit.getRefName();
     RefUpdate ru = repo.updateRef(refName, true);
     ru.setExpectedOldObjectId(edit.getEditCommit());
@@ -246,6 +252,8 @@
     switch (result) {
       case FORCED:
       case NEW:
+        gitRefUpdated.fire(edit.getChange().getProject(), ru, userAccountState);
+        break;
       case NO_CHANGE:
         break;
       case LOCK_FAILURE:
diff --git a/java/com/google/gerrit/server/edit/ModificationTarget.java b/java/com/google/gerrit/server/edit/ModificationTarget.java
index 0de0149..86de812 100644
--- a/java/com/google/gerrit/server/edit/ModificationTarget.java
+++ b/java/com/google/gerrit/server/edit/ModificationTarget.java
@@ -52,21 +52,21 @@
   /** A specific patchset commit is the target of the modification. */
   class PatchsetCommit implements ModificationTarget {
 
-    private final PatchSet patchset;
+    private final PatchSet patchSet;
 
-    PatchsetCommit(PatchSet patchset) {
-      this.patchset = patchset;
+    PatchsetCommit(PatchSet patchSet) {
+      this.patchSet = patchSet;
     }
 
     @Override
     public void ensureTargetMayBeModifiedDespiteExistingEdit(ChangeEdit changeEdit)
         throws InvalidChangeOperationException {
-      if (!isBasedOn(changeEdit, patchset)) {
+      if (!isBasedOn(changeEdit, patchSet)) {
         throw new InvalidChangeOperationException(
             String.format(
                 "Only the patch set %s on which the existing change edit is based may be modified "
                     + "(specified patch set: %s)",
-                changeEdit.getBasePatchSet().id(), patchset.id()));
+                changeEdit.getBasePatchSet().id(), patchSet.id()));
       }
     }
 
@@ -78,7 +78,7 @@
     @Override
     public void ensureNewEditMayBeBasedOnTarget(Change change)
         throws InvalidChangeOperationException {
-      PatchSet.Id patchSetId = patchset.id();
+      PatchSet.Id patchSetId = patchSet.id();
       PatchSet.Id currentPatchSetId = change.currentPatchSetId();
       if (!patchSetId.equals(currentPatchSetId)) {
         throw new InvalidChangeOperationException(
@@ -91,13 +91,13 @@
     @Override
     public RevCommit getCommit(Repository repository) throws IOException {
       try (RevWalk revWalk = new RevWalk(repository)) {
-        return revWalk.parseCommit(patchset.commitId());
+        return revWalk.parseCommit(patchSet.commitId());
       }
     }
 
     @Override
     public PatchSet getBasePatchset() {
-      return patchset;
+      return patchSet;
     }
   }
 
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index 39ab041..9c0b92a 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -98,7 +98,7 @@
       } catch (IOException e) {
         String message =
             String.format("Could not change the content of %s", dirCacheEntry.getPathString());
-        logger.atSevere().withCause(e).log(message);
+        logger.atSevere().withCause(e).log("%s", message);
       } catch (InvalidObjectIdException e) {
         logger.atSevere().withCause(e).log("Invalid object id in submodule link");
       }
diff --git a/java/com/google/gerrit/server/events/EventBroker.java b/java/com/google/gerrit/server/events/EventBroker.java
index 4001a48..2697da5 100644
--- a/java/com/google/gerrit/server/events/EventBroker.java
+++ b/java/com/google/gerrit/server/events/EventBroker.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritInstanceId;
@@ -170,9 +169,8 @@
         return false;
       }
 
-      permissionBackend.user(user).project(project).check(ProjectPermission.ACCESS);
-      return true;
-    } catch (AuthException | PermissionBackendException e) {
+      return permissionBackend.user(user).project(project).test(ProjectPermission.ACCESS);
+    } catch (PermissionBackendException e) {
       return false;
     }
   }
@@ -185,15 +183,10 @@
     if (!pe.isPresent() || !pe.get().statePermitsRead()) {
       return false;
     }
-    try {
-      permissionBackend
-          .user(user)
-          .change(notesFactory.createChecked(change))
-          .check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return permissionBackend
+        .user(user)
+        .change(notesFactory.createChecked(change))
+        .test(ChangePermission.READ);
   }
 
   protected boolean isVisibleTo(BranchNameKey branchName, CurrentUser user)
@@ -203,12 +196,7 @@
       return false;
     }
 
-    try {
-      permissionBackend.user(user).ref(branchName).check(RefPermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return permissionBackend.user(user).ref(branchName).test(RefPermission.READ);
   }
 
   protected boolean isVisibleTo(Event event, CurrentUser user) throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 8b19ecb..95f6d96 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -59,6 +59,7 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 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.FilePathAdapter;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -133,7 +134,7 @@
     a.owner = asAccountAttribute(change.getOwner(), accountLoader);
     a.assignee = asAccountAttribute(change.getAssignee(), accountLoader);
     a.status = change.getStatus();
-    a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.createdOn = change.getCreatedOn().getEpochSecond();
     a.wip = change.isWorkInProgress() ? true : null;
     a.isPrivate = change.isPrivate() ? true : null;
     a.cherryPickOfChange =
@@ -166,7 +167,7 @@
 
   /** Extend the existing {@link ChangeAttribute} with additional fields. */
   public void extend(ChangeAttribute a, Change change) {
-    a.lastUpdated = change.getLastUpdatedOn().getTime() / 1000L;
+    a.lastUpdated = change.getLastUpdatedOn().getEpochSecond();
     a.open = change.isNew();
   }
 
@@ -408,7 +409,7 @@
     try {
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parentNum= */ 0);
+              change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
 
       for (FileDiffOutput diff : modifiedFiles.values()) {
         if (patchSetAttribute.files == null) {
@@ -452,7 +453,7 @@
     p.number = patchSet.number();
     p.ref = patchSet.refName();
     p.uploader = asAccountAttribute(patchSet.uploader(), accountLoader);
-    p.createdOn = patchSet.createdOn().getTime() / 1000L;
+    p.createdOn = patchSet.createdOn().getEpochSecond();
     PatchSet.Id pId = patchSet.id();
     try {
       p.parents = new ArrayList<>();
@@ -473,7 +474,7 @@
 
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parentNum= */ 0);
+              change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
       for (FileDiffOutput fileDiff : modifiedFiles.values()) {
         p.sizeDeletions += fileDiff.deletions();
         p.sizeInsertions += fileDiff.insertions();
@@ -558,7 +559,7 @@
     a.type = approval.labelId().get();
     a.value = Short.toString(approval.value());
     a.by = asAccountAttribute(approval.accountId(), accountLoader);
-    a.grantedOn = approval.granted().getTime() / 1000L;
+    a.grantedOn = approval.granted().getEpochSecond();
     a.oldValue = null;
 
     Optional<LabelType> lt = labelTypes.byLabel(approval.labelId());
@@ -569,7 +570,7 @@
   public MessageAttribute asMessageAttribute(
       ChangeMessage message, AccountAttributeLoader accountLoader) {
     MessageAttribute a = new MessageAttribute();
-    a.timestamp = message.getWrittenOn().getTime() / 1000L;
+    a.timestamp = message.getWrittenOn().getEpochSecond();
     a.reviewer =
         message.getAuthor() != null
             ? asAccountAttribute(message.getAuthor(), accountLoader)
diff --git a/java/com/google/gerrit/server/events/RefReceivedEvent.java b/java/com/google/gerrit/server/events/RefReceivedEvent.java
index 18783aa..84e57dc 100644
--- a/java/com/google/gerrit/server/events/RefReceivedEvent.java
+++ b/java/com/google/gerrit/server/events/RefReceivedEvent.java
@@ -13,6 +13,7 @@
 // limitations under the License.
 package com.google.gerrit.server.events;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import org.eclipse.jgit.transport.ReceiveCommand;
@@ -22,6 +23,7 @@
   public ReceiveCommand command;
   public Project project;
   public IdentifiedUser user;
+  public ImmutableListMultimap<String, String> pushOptions;
 
   public RefReceivedEvent() {
     super(TYPE);
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index 1486559..ffeb44b 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -22,30 +22,19 @@
   /** 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";
 
-  /** Enable storing submit requirements in NoteDb when the change is merged. */
-  public static final String GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE =
-      "GerritBackendRequestFeature__store_submit_requirements_on_merge";
-
   /**
-   * Allow legacy {@link com.google.gerrit.entities.SubmitRecord}s to be converted and returned as
-   * submit requirements by the {@link
-   * com.google.gerrit.server.project.SubmitRequirementsEvaluator}.
+   * When set, we compute information from All-Users repository if able, instead of computing it
+   * from the change index.
    */
-  public static final String GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS =
-      "GerritBackendRequestFeature__enable_submit_requirements";
-
-  /**
-   * Allow SubmitRequirements to be computed freshly on dashboards irrespective of the value we
-   * retrieved from the change index.
-   */
-  public static final String
-      GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD =
-          "GerritBackendRequestFeature__enable_submit_requirements_backfilling_on_dashboard";
+  public static final String GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY =
+      "GerritBackendRequestFeature__compute_from_all_users_repository";
 
   /** 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/AbstractChangeEvent.java b/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
index b7ee043..fde4088 100644
--- a/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
+++ b/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
@@ -18,17 +18,17 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.ChangeEvent;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Base class for all change events. */
 public abstract class AbstractChangeEvent implements ChangeEvent {
   private final ChangeInfo changeInfo;
   private final AccountInfo who;
-  private final Timestamp when;
+  private final Instant when;
   private final NotifyHandling notify;
 
   protected AbstractChangeEvent(
-      ChangeInfo change, AccountInfo who, Timestamp when, NotifyHandling notify) {
+      ChangeInfo change, AccountInfo who, Instant when, NotifyHandling notify) {
     this.changeInfo = change;
     this.who = who;
     this.when = when;
@@ -46,7 +46,7 @@
   }
 
   @Override
-  public Timestamp getWhen() {
+  public Instant getWhen() {
     return when;
   }
 
diff --git a/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java b/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
index 9d4d299..421a5ad 100644
--- a/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
+++ b/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.RevisionEvent;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Base class for all revision events. */
 public abstract class AbstractRevisionEvent extends AbstractChangeEvent implements RevisionEvent {
@@ -30,7 +30,7 @@
       ChangeInfo change,
       RevisionInfo revision,
       AccountInfo who,
-      Timestamp when,
+      Instant when,
       NotifyHandling notify) {
     super(change, who, when, notify);
     revisionInfo = revision;
diff --git a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
index e31a1b5..8e4d1e2 100644
--- a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a user has been set as assignee on a change. */
 @Singleton
@@ -42,7 +42,7 @@
   }
 
   public void fire(
-      ChangeData changeData, AccountState accountState, AccountState oldAssignee, Timestamp when) {
+      ChangeData changeData, AccountState accountState, AccountState oldAssignee, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -63,7 +63,7 @@
   private static class Event extends AbstractChangeEvent implements AssigneeChangedListener.Event {
     private final AccountInfo oldAssignee;
 
-    Event(ChangeInfo change, AccountInfo editor, AccountInfo oldAssignee, Timestamp when) {
+    Event(ChangeInfo change, AccountInfo editor, AccountInfo oldAssignee, Instant when) {
       super(change, editor, when, NotifyHandling.ALL);
       this.oldAssignee = oldAssignee;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index cbe7c6b..ca1a742 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -32,7 +32,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been abandoned. */
 @Singleton
@@ -53,7 +53,7 @@
       PatchSet ps,
       AccountState abandoner,
       String reason,
-      Timestamp when,
+      Instant when,
       NotifyHandling notifyHandling) {
     if (listeners.isEmpty()) {
       return;
@@ -89,7 +89,7 @@
         RevisionInfo revision,
         AccountInfo abandoner,
         String reason,
-        Timestamp when,
+        Instant when,
         NotifyHandling notifyHandling) {
       super(change, revision, abandoner, when, notifyHandling);
       this.reason = reason;
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
index 23a4583..acca491 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been deleted. */
 @Singleton
@@ -41,7 +41,7 @@
     this.util = util;
   }
 
-  public void fire(ChangeData changeData, AccountState deleter, Timestamp when) {
+  public void fire(ChangeData changeData, AccountState deleter, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -55,7 +55,7 @@
 
   /** Event to be fired when a change has been deleted. */
   private static class Event extends AbstractChangeEvent implements ChangeDeletedListener.Event {
-    Event(ChangeInfo change, AccountInfo deleter, Timestamp when) {
+    Event(ChangeInfo change, AccountInfo deleter, Instant when) {
       super(change, deleter, when, NotifyHandling.ALL);
     }
   }
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index e4896df..870d850 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -32,7 +32,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been merged. */
 @Singleton
@@ -49,11 +49,7 @@
   }
 
   public void fire(
-      ChangeData changeData,
-      PatchSet ps,
-      AccountState merger,
-      String newRevisionId,
-      Timestamp when) {
+      ChangeData changeData, PatchSet ps, AccountState merger, String newRevisionId, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -86,7 +82,7 @@
         RevisionInfo revision,
         AccountInfo merger,
         String newRevisionId,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, merger, when, NotifyHandling.ALL);
       this.newRevisionId = newRevisionId;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index 8bd222a..c71360b 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -32,7 +32,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been restored. */
 @Singleton
@@ -49,7 +49,7 @@
   }
 
   public void fire(
-      ChangeData changeData, PatchSet ps, AccountState restorer, String reason, Timestamp when) {
+      ChangeData changeData, PatchSet ps, AccountState restorer, String reason, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -83,7 +83,7 @@
         RevisionInfo revision,
         AccountInfo restorer,
         String reason,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, restorer, when, NotifyHandling.ALL);
       this.reason = reason;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
index 4a46eb0..1abbebb 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been reverted. */
 @Singleton
@@ -39,7 +39,7 @@
     this.util = util;
   }
 
-  public void fire(ChangeData changeData, ChangeData revertChangeData, Timestamp when) {
+  public void fire(ChangeData changeData, ChangeData revertChangeData, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -55,7 +55,7 @@
   private static class Event extends AbstractChangeEvent implements ChangeRevertedListener.Event {
     private final ChangeInfo revertChange;
 
-    Event(ChangeInfo change, ChangeInfo revertChange, Timestamp when) {
+    Event(ChangeInfo change, ChangeInfo revertChange, Instant when) {
       super(change, revertChange.owner, when, NotifyHandling.ALL);
       this.revertChange = revertChange;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index 20c54cf..79544f2 100644
--- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -33,7 +33,7 @@
 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.Map;
 
 /** Helper class to fire an event when a comment or vote has been added to a change. */
@@ -57,7 +57,7 @@
       String comment,
       Map<String, Short> approvals,
       Map<String, Short> oldApprovals,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -97,7 +97,7 @@
         String comment,
         Map<String, ApprovalInfo> approvals,
         Map<String, ApprovalInfo> oldApprovals,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, author, when, NotifyHandling.ALL);
       this.comment = comment;
       this.approvals = approvals;
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index f0d038a..45f7ecb 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -36,7 +36,7 @@
 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.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
@@ -111,7 +111,7 @@
   }
 
   public Map<String, ApprovalInfo> approvals(
-      AccountState accountState, Map<String, Short> approvals, Timestamp ts) {
+      AccountState accountState, Map<String, Short> approvals, Instant ts) {
     Map<String, ApprovalInfo> result = new HashMap<>();
     for (Map.Entry<String, Short> e : approvals.entrySet()) {
       Integer value = e.getValue() != null ? Integer.valueOf(e.getValue()) : 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..107e987 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.LinkedHashSet;
+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 LinkedHashSet<>();
     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.toCollection(LinkedHashSet::new));
+    }
+
+    @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/HashtagsEdited.java b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
index 846257c..e7903a2 100644
--- a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collection;
 import java.util.Set;
 
@@ -50,7 +50,7 @@
       ImmutableSortedSet<String> hashtags,
       Set<String> added,
       Set<String> removed,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -82,7 +82,7 @@
         Collection<String> updated,
         Collection<String> added,
         Collection<String> removed,
-        Timestamp when) {
+        Instant when) {
       super(change, editor, when, NotifyHandling.ALL);
       this.updatedHashtags = updated;
       this.addedHashtags = added;
diff --git a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
index d81068c..c6076fd 100644
--- a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -31,7 +31,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when the private flag of a change has been toggled. */
 @Singleton
@@ -47,7 +47,7 @@
     this.util = util;
   }
 
-  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {
+  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -72,7 +72,7 @@
   private static class Event extends AbstractRevisionEvent
       implements PrivateStateChangedListener.Event {
 
-    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Instant when) {
       super(change, revision, who, when, NotifyHandling.ALL);
     }
   }
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index ba73ca1..147e372 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -33,7 +33,7 @@
 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.List;
 
 /** Helper class to fire an event when reviewers have been added to a change. */
@@ -55,7 +55,7 @@
       PatchSet patchSet,
       List<AccountState> reviewers,
       AccountState adder,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty() || reviewers.isEmpty()) {
       return;
     }
@@ -89,7 +89,7 @@
         RevisionInfo revision,
         List<AccountInfo> reviewers,
         AccountInfo adder,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, adder, when, NotifyHandling.ALL);
       this.reviewers = reviewers;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index 80037bc..5f9179a 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -33,7 +33,7 @@
 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.Map;
 
 /** Helper class to fire an event when a reviewer has been deleted from a change. */
@@ -59,7 +59,7 @@
       Map<String, Short> newApprovals,
       Map<String, Short> oldApprovals,
       NotifyHandling notify,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -104,7 +104,7 @@
         Map<String, ApprovalInfo> newApprovals,
         Map<String, ApprovalInfo> oldApprovals,
         NotifyHandling notify,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, remover, when, notify);
       this.reviewer = reviewer;
       this.comment = comment;
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 4c78216..a60d982 100644
--- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -33,7 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a revision has been created for a change. */
 @Singleton
@@ -47,7 +47,7 @@
             ChangeData changeData,
             PatchSet patchSet,
             AccountState uploader,
-            Timestamp when,
+            Instant when,
             NotifyResolver.Result notify) {}
       };
 
@@ -69,7 +69,7 @@
       ChangeData changeData,
       PatchSet patchSet,
       AccountState uploader,
-      Timestamp when,
+      Instant when,
       NotifyResolver.Result notify) {
     if (listeners.isEmpty()) {
       return;
@@ -102,7 +102,7 @@
         ChangeInfo change,
         RevisionInfo revision,
         AccountInfo uploader,
-        Timestamp when,
+        Instant when,
         NotifyHandling notify) {
       super(change, revision, uploader, when, notify);
     }
diff --git a/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
index 08b47f1..008ead5 100644
--- a/java/com/google/gerrit/server/extensions/events/TopicEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when the topic of a change has been edited. */
 @Singleton
@@ -41,8 +41,7 @@
     this.util = util;
   }
 
-  public void fire(
-      ChangeData changeData, AccountState account, String oldTopicName, Timestamp when) {
+  public void fire(ChangeData changeData, AccountState account, String oldTopicName, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -59,7 +58,7 @@
   private static class Event extends AbstractChangeEvent implements TopicEditedListener.Event {
     private final String oldTopic;
 
-    Event(ChangeInfo change, AccountInfo editor, String oldTopic, Timestamp when) {
+    Event(ChangeInfo change, AccountInfo editor, String oldTopic, Instant when) {
       super(change, editor, when, NotifyHandling.ALL);
       this.oldTopic = oldTopic;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index 244e46c..deaaff8 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -33,7 +33,7 @@
 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.Map;
 
 /** Helper class to fire an event when a vote has been deleted from a change. */
@@ -59,7 +59,7 @@
       NotifyHandling notify,
       String message,
       AccountState remover,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -103,7 +103,7 @@
         NotifyHandling notify,
         String message,
         AccountInfo remover,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, remover, when, notify);
       this.reviewer = reviewer;
       this.approvals = approvals;
diff --git a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
index bfc068d..5e20c45 100644
--- a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -31,7 +31,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when the work-in-progress state of a change has been toggled. */
 @Singleton
@@ -42,7 +42,7 @@
       new WorkInProgressStateChanged() {
         @Override
         public void fire(
-            ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {}
+            ChangeData changeData, PatchSet patchSet, AccountState account, Instant when) {}
       };
 
   private final PluginSetContext<WorkInProgressStateChangedListener> listeners;
@@ -60,7 +60,7 @@
     this.util = null;
   }
 
-  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {
+  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -85,7 +85,7 @@
   private static class Event extends AbstractRevisionEvent
       implements WorkInProgressStateChangedListener.Event {
 
-    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Instant when) {
       super(change, revision, who, when, NotifyHandling.ALL);
     }
   }
diff --git a/java/com/google/gerrit/server/git/BanCommit.java b/java/com/google/gerrit/server/git/BanCommit.java
index 242c11b..e27197c 100644
--- a/java/com/google/gerrit/server/git/BanCommit.java
+++ b/java/com/google/gerrit/server/git/BanCommit.java
@@ -30,9 +30,9 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Date;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Constants;
@@ -78,7 +78,7 @@
 
   private final Provider<IdentifiedUser> currentUser;
   private final GitRepositoryManager repoManager;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final PermissionBackend permissionBackend;
   private final NotesBranchUtil.Factory notesBranchUtilFactory;
 
@@ -93,7 +93,7 @@
     this.repoManager = repoManager;
     this.notesBranchUtilFactory = notesBranchUtilFactory;
     this.permissionBackend = permissionBackend;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
   }
 
   /**
@@ -155,8 +155,7 @@
   }
 
   private PersonIdent createPersonIdent() {
-    Date now = new Date();
-    return currentUser.get().newCommitterIdent(now, tz);
+    return currentUser.get().newCommitterIdent(Instant.now(), zoneId);
   }
 
   private static String buildCommitMessage(List<ObjectId> bannedCommits, String reason) {
diff --git a/java/com/google/gerrit/server/git/ChangeMessageModifier.java b/java/com/google/gerrit/server/git/ChangeMessageModifier.java
index 580c0b9..3424477 100644
--- a/java/com/google/gerrit/server/git/ChangeMessageModifier.java
+++ b/java/com/google/gerrit/server/git/ChangeMessageModifier.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -42,10 +43,13 @@
    * @param original the commit of the change being submitted. <b>Note that its commit message may
    *     be different than newCommitMessage argument.</b>
    * @param mergeTip the current HEAD of the destination branch, which will be a parent of a new
-   *     commit being generated
+   *     commit being generated. mergeTip can be null if the destination branch does not yet exist.
    * @param destination the branch onto which the change is being submitted
    * @return a new not null commit message.
    */
   String onSubmit(
-      String newCommitMessage, RevCommit original, RevCommit mergeTip, BranchNameKey destination);
+      String newCommitMessage,
+      RevCommit original,
+      @Nullable RevCommit mergeTip,
+      BranchNameKey destination);
 }
diff --git a/java/com/google/gerrit/server/git/ChangesByProjectCache.java b/java/com/google/gerrit/server/git/ChangesByProjectCache.java
index e476208..df91891 100644
--- a/java/com/google/gerrit/server/git/ChangesByProjectCache.java
+++ b/java/com/google/gerrit/server/git/ChangesByProjectCache.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
 import java.io.IOException;
-import java.util.Collection;
+import java.util.stream.Stream;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
@@ -52,12 +52,12 @@
   }
 
   /**
-   * get changeDatas for the project
+   * Stream changeDatas for the project
    *
    * @param project project to read.
    * @param repository repository for the project to read.
-   * @return Collection of known changes; empty if no changes.
+   * @return Stream of known changes; empty if no changes.
    */
-  Collection<ChangeData> getChangeDatas(Project.NameKey project, Repository repo)
+  Stream<ChangeData> streamChangeDatas(Project.NameKey project, Repository repository)
       throws IOException;
 }
diff --git a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
index 2d15a50..1e1c7a3 100644
--- a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
@@ -44,6 +44,7 @@
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutionException;
+import java.util.stream.Stream;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
@@ -87,17 +88,19 @@
 
   /** {@inheritDoc} */
   @Override
-  public Collection<ChangeData> getChangeDatas(Project.NameKey project, Repository repo)
+  public Stream<ChangeData> streamChangeDatas(Project.NameKey project, Repository repo)
       throws IOException {
     CachedProjectChanges projectChanges = cache.getIfPresent(project);
     if (projectChanges != null) {
-      return projectChanges.getUpdatedChangeDatas(
-          project, repo, cdFactory, ChangeNotes.Factory.scanChangeIds(repo), "Updating");
+      return projectChanges
+          .getUpdatedChangeDatas(
+              project, repo, cdFactory, ChangeNotes.Factory.scanChangeIds(repo), "Updating")
+          .stream();
     }
     if (UseIndex.TRUE.equals(useIndex)) {
-      return queryChangeDatasAndLoad(project);
+      return queryChangeDatasAndLoad(project).stream();
     }
-    return scanChangeDatasAndLoad(project, repo);
+    return scanChangeDatasAndLoad(project, repo).stream();
   }
 
   private Collection<ChangeData> scanChangeDatasAndLoad(Project.NameKey project, Repository repo)
diff --git a/java/com/google/gerrit/server/git/CodeReviewCommit.java b/java/com/google/gerrit/server/git/CodeReviewCommit.java
index d7538ba..79df21a 100644
--- a/java/com/google/gerrit/server/git/CodeReviewCommit.java
+++ b/java/com/google/gerrit/server/git/CodeReviewCommit.java
@@ -24,6 +24,9 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -35,7 +38,10 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /** Extended commit entity with code review specific metadata. */
-public class CodeReviewCommit extends RevCommit {
+public class CodeReviewCommit extends RevCommit implements Serializable {
+
+  private static final long serialVersionUID = 1L;
+
   /**
    * Default ordering when merging multiple topologically-equivalent commits.
    *
@@ -126,7 +132,7 @@
    * Message for the status that is returned to the calling user if the status indicates a problem
    * that prevents submit.
    */
-  private Optional<String> statusMessage = Optional.empty();
+  private transient Optional<String> statusMessage = Optional.empty();
 
   /** List of files in this commit that contain Git conflict markers. */
   private ImmutableSet<String> filesWithGitConflicts;
@@ -191,4 +197,22 @@
   public void setNotes(ChangeNotes notes) {
     this.notes = notes;
   }
+
+  /** Custom serialization due to {@link #statusMessage} not being Serializable by default. */
+  private void writeObject(ObjectOutputStream oos) throws IOException {
+    oos.defaultWriteObject();
+    if (this.statusMessage.isPresent()) {
+      oos.writeUTF(this.statusMessage.get());
+    }
+  }
+
+  /** Custom deserialization due to {@link #statusMessage} not being Serializable by default. */
+  private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException {
+    ois.defaultReadObject();
+    String statusMessage = null;
+    if (ois.available() > 0) {
+      statusMessage = ois.readUTF();
+    }
+    this.statusMessage = Optional.ofNullable(statusMessage);
+  }
 }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 73378f6..ae1e559 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -53,8 +53,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.text.MessageFormat;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.Set;
@@ -146,7 +146,7 @@
    * @return ObjectId that represents the newly created commit.
    */
   public Change.Id createRevertChange(
-      ChangeNotes notes, CurrentUser user, RevertInput input, Timestamp timestamp)
+      ChangeNotes notes, CurrentUser user, RevertInput input, Instant timestamp)
       throws RestApiException, UpdateException, ConfigInvalidException, IOException {
     String message = Strings.emptyToNull(input.message);
 
@@ -174,7 +174,7 @@
    * @return ObjectId that represents the newly created commit.
    */
   public ObjectId createRevertCommit(
-      String message, ChangeNotes notes, CurrentUser user, Timestamp ts)
+      String message, ChangeNotes notes, CurrentUser user, Instant ts)
       throws RestApiException, IOException {
 
     try (Repository git = repoManager.openRepository(notes.getProjectName());
@@ -206,7 +206,7 @@
       String message,
       ChangeNotes notes,
       CurrentUser user,
-      Timestamp ts,
+      Instant ts,
       ObjectInserter oi,
       RevWalk revWalk,
       @Nullable ObjectId generatedChangeId)
@@ -220,7 +220,7 @@
 
     PersonIdent committerIdent = serverIdent.get();
     PersonIdent authorIdent =
-        user.asIdentifiedUser().newCommitterIdent(ts, committerIdent.getTimeZone());
+        user.asIdentifiedUser().newCommitterIdent(ts, committerIdent.getZoneId());
 
     RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
     revWalk.parseHeaders(parentToCommitToRevert);
@@ -255,7 +255,7 @@
       ChangeNotes notes,
       CurrentUser user,
       @Nullable ObjectId generatedChangeId,
-      Timestamp ts,
+      Instant ts,
       ObjectInserter oi,
       RevWalk revWalk,
       Repository git)
diff --git a/java/com/google/gerrit/server/git/GarbageCollection.java b/java/com/google/gerrit/server/git/GarbageCollection.java
index 6ae4d62..30330eb 100644
--- a/java/com/google/gerrit/server/git/GarbageCollection.java
+++ b/java/com/google/gerrit/server/git/GarbageCollection.java
@@ -136,7 +136,7 @@
       }
       b.append(s);
     }
-    logger.atInfo().log(b.toString());
+    logger.atInfo().log("%s", b);
   }
 
   private static void logGcConfiguration(
@@ -153,7 +153,7 @@
     }
 
     logGcInfo(projectName, "gc config: " + b.toString());
-    logGcInfo(projectName, "pack config: " + (new PackConfig(repo)).toString());
+    logGcInfo(projectName, "pack config: " + new PackConfig(repo).toString());
   }
 
   private static String formatConfigValues(Config config, String section, String subsection) {
@@ -176,7 +176,7 @@
     print(writer, "failed.\n\n");
     StringBuilder b = new StringBuilder();
     b.append("[").append(projectName.get()).append("]");
-    logger.atSevere().withCause(e).log(b.toString());
+    logger.atSevere().withCause(e).log("%s", b);
   }
 
   private static void print(PrintWriter writer, String message) {
diff --git a/java/com/google/gerrit/server/git/GitRepositoryManager.java b/java/com/google/gerrit/server/git/GitRepositoryManager.java
index 8dba3e1..d045baa 100644
--- a/java/com/google/gerrit/server/git/GitRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/GitRepositoryManager.java
@@ -18,7 +18,7 @@
 import com.google.inject.ImplementedBy;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
 
@@ -74,7 +74,7 @@
       throws RepositoryNotFoundException, RepositoryExistsException, IOException;
 
   /** Returns set of all known projects, sorted by natural NameKey order. */
-  SortedSet<Project.NameKey> list();
+  NavigableSet<Project.NameKey> list();
 
   /**
    * Check if garbage collection can be performed by the repository manager.
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 6b5fd2e..57d37fa 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -35,7 +35,7 @@
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.Map;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import java.util.TreeSet;
 import java.util.concurrent.ConcurrentHashMap;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -263,10 +263,10 @@
   }
 
   @Override
-  public SortedSet<Project.NameKey> list() {
+  public NavigableSet<Project.NameKey> list() {
     ProjectVisitor visitor = new ProjectVisitor(basePath);
     scanProjects(visitor);
-    return Collections.unmodifiableSortedSet(visitor.found);
+    return Collections.unmodifiableNavigableSet(visitor.found);
   }
 
   protected void scanProjects(ProjectVisitor visitor) {
@@ -295,7 +295,7 @@
   }
 
   protected class ProjectVisitor extends SimpleFileVisitor<Path> {
-    private final SortedSet<Project.NameKey> found = new TreeSet<>();
+    private final NavigableSet<Project.NameKey> found = new TreeSet<>();
     private Path startFolder;
 
     public ProjectVisitor(Path startFolder) {
@@ -318,7 +318,7 @@
 
     @Override
     public FileVisitResult visitFileFailed(Path file, IOException e) {
-      logger.atWarning().log(e.getMessage());
+      logger.atWarning().log("%s", e.getMessage());
       return FileVisitResult.CONTINUE;
     }
 
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index fac05d2..3df7288 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -31,6 +31,7 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
@@ -630,7 +631,7 @@
    * @return new message
    */
   public String createCommitMessageOnSubmit(
-      RevCommit n, RevCommit mergeTip, ChangeNotes notes, PatchSet.Id id) {
+      RevCommit n, @Nullable RevCommit mergeTip, ChangeNotes notes, PatchSet.Id id) {
     return commitMessageGenerator.generate(
         n, mergeTip, notes.getChange().getDest(), createDetailedCommitMessage(n, notes, id));
   }
@@ -805,8 +806,7 @@
       try {
         failed(rw, mergeTip, n, getCommitMergeStatus(e.getReason()));
       } catch (IOException e2) {
-        logger.atSevere().withCause(e2).log("Failed to set merge failure status for " + n.name());
-        throw new StorageException("Cannot merge " + n.name(), e);
+        throw new StorageException("Cannot merge " + n.name(), e2);
       }
     } catch (IOException e) {
       throw new StorageException("Cannot merge " + n.name(), e);
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 1acd98e..b88985d 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -19,6 +19,7 @@
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
 import com.google.common.base.Strings;
+import com.google.common.base.Ticker;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.server.CancellationMetrics;
@@ -245,6 +246,7 @@
   private Optional<Long> timeout = Optional.empty();
 
   private final long maxIntervalNanos;
+  private final Ticker ticker;
 
   /**
    * Create a new progress monitor for multiple sub-tasks.
@@ -256,10 +258,11 @@
   @AssistedInject
   private MultiProgressMonitor(
       CancellationMetrics cancellationMetrics,
+      Ticker ticker,
       @Assisted OutputStream out,
       @Assisted TaskKind taskKind,
       @Assisted String taskName) {
-    this(cancellationMetrics, out, taskKind, taskName, 500, MILLISECONDS);
+    this(cancellationMetrics, ticker, out, taskKind, taskName, 500, MILLISECONDS);
   }
 
   /**
@@ -273,12 +276,14 @@
   @AssistedInject
   private MultiProgressMonitor(
       CancellationMetrics cancellationMetrics,
+      Ticker ticker,
       @Assisted OutputStream out,
       @Assisted TaskKind taskKind,
       @Assisted String taskName,
       @Assisted long maxIntervalTime,
       @Assisted TimeUnit maxIntervalUnit) {
     this.cancellationMetrics = cancellationMetrics;
+    this.ticker = ticker;
     this.out = out;
     this.taskKind = taskKind;
     this.taskName = taskName;
@@ -310,7 +315,7 @@
    * calls {@link #end()}, the future has an additional {@code maxInterval} to finish before it is
    * forcefully cancelled and {@link ExecutionException} is thrown.
    *
-   * @see #waitForNonFinalTask(Future, long, TimeUnit)
+   * @see #waitForNonFinalTask(Future, long, TimeUnit, long, TimeUnit)
    * @param workerFuture a future that returns when worker threads are finished.
    * @param taskTimeoutTime overall timeout for the task; the future gets a cancellation signal
    *     after this timeout is exceeded; non-positive values indicate no timeout.
@@ -351,7 +356,7 @@
   /**
    * Wait for a non-final task managed by a {@link Future}, with no timeout.
    *
-   * @see #waitForNonFinalTask(Future, long, TimeUnit)
+   * @see #waitForNonFinalTask(Future, long, TimeUnit, long, TimeUnit)
    */
   public <T> T waitForNonFinalTask(Future<T> workerFuture) {
     try {
@@ -383,7 +388,7 @@
       long cancellationTimeoutTime,
       TimeUnit cancellationTimeoutUnit)
       throws TimeoutException {
-    long overallStart = System.nanoTime();
+    long overallStart = ticker.read();
     long cancellationNanos =
         cancellationTimeoutTime > 0
             ? NANOSECONDS.convert(cancellationTimeoutTime, cancellationTimeoutUnit)
@@ -399,16 +404,33 @@
     synchronized (this) {
       long left = maxIntervalNanos;
       while (!workerFuture.isDone() && !done) {
-        long start = System.nanoTime();
+        long start = ticker.read();
         try {
-          NANOSECONDS.timedWait(this, left);
+          // Conditions below gives better granularity for timeouts.
+          // Originally, code always used fixed interval:
+          // NANOSECONDS.timedWait(this, maxIntervalNanos);
+          // As a result, the actual check for timeouts happened only every maxIntervalNanos
+          // (default value 500ms); so even if timout was set to 1ms, the actual timeout was 500ms.
+          // This is not a big issue, however it made our tests for timeouts flaky. For example,
+          // some tests in the CancellationIT set timeout to 1ms and expect that server returns
+          // timeout. However, server often returned OK result, because a request takes less than
+          // 500ms.
+          if (deadlineExceeded || deadline == 0) {
+            // We want to set deadlineExceeded flag as earliest as possible. If it is already
+            // set - there is no reason to wait less than maxIntervalNanos
+            NANOSECONDS.timedWait(this, maxIntervalNanos);
+          } else if (start <= deadline) {
+            // if deadlineExceeded is not set, then we should wait until deadline, but no longer
+            // than maxIntervalNanos (because we want to report a progress every maxIntervalNanos).
+            NANOSECONDS.timedWait(this, Math.min(deadline - start + 1, maxIntervalNanos));
+          }
         } catch (InterruptedException e) {
           throw new UncheckedExecutionException(e);
         }
 
         // Send an update on every wakeup (manual or spurious), but only move
         // the spinner every maxInterval.
-        long now = System.nanoTime();
+        long now = ticker.read();
 
         if (deadline > 0 && now > deadline) {
           if (!deadlineExceeded) {
diff --git a/java/com/google/gerrit/server/git/PerThreadRequestScope.java b/java/com/google/gerrit/server/git/PerThreadRequestScope.java
index b7db542..9b5a674 100644
--- a/java/com/google/gerrit/server/git/PerThreadRequestScope.java
+++ b/java/com/google/gerrit/server/git/PerThreadRequestScope.java
@@ -89,7 +89,7 @@
       new Scope() {
         @Override
         public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<T>() {
+          return new Provider<>() {
             @Override
             public T get() {
               return requireContext().get(key, creator);
diff --git a/java/com/google/gerrit/server/git/PluggableCommitMessageGenerator.java b/java/com/google/gerrit/server/git/PluggableCommitMessageGenerator.java
index 804a218..1f5a330 100644
--- a/java/com/google/gerrit/server/git/PluggableCommitMessageGenerator.java
+++ b/java/com/google/gerrit/server/git/PluggableCommitMessageGenerator.java
@@ -18,6 +18,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.Extension;
@@ -41,7 +42,10 @@
    * modify the message.
    */
   public String generate(
-      RevCommit original, RevCommit mergeTip, BranchNameKey dest, String originalMessage) {
+      RevCommit original,
+      @Nullable RevCommit mergeTip,
+      BranchNameKey dest,
+      String originalMessage) {
     requireNonNull(original.getRawBuffer());
     if (mergeTip != null) {
       requireNonNull(mergeTip.getRawBuffer());
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index b7731bf..7944913 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -41,10 +41,10 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
+import java.util.stream.Stream;
 import org.eclipse.jgit.lib.Repository;
 
 /**
@@ -104,24 +104,25 @@
    * Additional stored fields are not loaded from the index.
    *
    * @param project project to read.
-   * @param repo repository for the project to read.
-   * @return Collection of known changes; empty if no changes.
+   * @param unusedrepo repository for the project to read.
+   * @return stream of known changes; empty if no changes.
    */
   @Override
-  public Collection<ChangeData> getChangeDatas(Project.NameKey project, Repository unusedrepo) {
+  public Stream<ChangeData> streamChangeDatas(Project.NameKey project, Repository unusedrepo) {
+    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/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 8b59474..3032bfe 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -30,11 +30,10 @@
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.lang.Thread.UncaughtExceptionHandler;
 import java.lang.reflect.Field;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Date;
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
@@ -84,10 +83,6 @@
     }
   }
 
-  private static final UncaughtExceptionHandler LOG_UNCAUGHT_EXCEPTION =
-      (t, e) ->
-          logger.atSevere().withCause(e).log("WorkQueue thread %s threw exception", t.getName());
-
   private final ScheduledExecutorService defaultQueue;
   private final IdGenerator idGenerator;
   private final MetricMaker metrics;
@@ -152,6 +147,7 @@
    * @param threadPriority thread priority.
    * @param withMetrics whether to create metrics.
    */
+  @SuppressWarnings("ThreadPriorityCheck")
   public ScheduledThreadPoolExecutor createQueue(
       int poolsize, String queueName, int threadPriority, boolean withMetrics) {
     Executor executor = new Executor(poolsize, queueName);
@@ -259,7 +255,7 @@
             public Thread newThread(Runnable task) {
               final Thread t = parent.newThread(task);
               t.setName(queueName + "-" + tid.getAndIncrement());
-              t.setUncaughtExceptionHandler(LOG_UNCAUGHT_EXCEPTION);
+              t.setUncaughtExceptionHandler(WorkQueue::logUncaughtException);
               return t;
             }
           });
@@ -444,6 +440,10 @@
     }
   }
 
+  private static void logUncaughtException(Thread t, Throwable e) {
+    logger.atSevere().withCause(e).log("WorkQueue thread %s threw exception", t.getName());
+  }
+
   /**
    * Runnable needing to know it was canceled. Note that cancel is called only in case the task is
    * not in progress already.
@@ -496,7 +496,7 @@
     private final Executor executor;
     private final int taskId;
     private final AtomicBoolean running;
-    private final Date startTime;
+    private final Instant startTime;
 
     Task(Runnable runnable, RunnableScheduledFuture<V> task, Executor executor, int taskId) {
       this.runnable = runnable;
@@ -504,7 +504,7 @@
       this.executor = executor;
       this.taskId = taskId;
       this.running = new AtomicBoolean();
-      this.startTime = new Date();
+      this.startTime = Instant.now();
     }
 
     public int getTaskId() {
@@ -527,7 +527,7 @@
       return State.SLEEPING;
     }
 
-    public Date getStartTime() {
+    public Instant getStartTime() {
       return startTime;
     }
 
diff --git a/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java b/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
index 27d5da9..befdb58 100644
--- a/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
+++ b/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
@@ -135,7 +135,7 @@
 
     private PersonIdent createPersonIdent(IdentifiedUser user) {
       PersonIdent serverIdent = serverIdentProvider.get();
-      return user.newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone());
+      return user.newCommitterIdent(serverIdent);
     }
   }
 
@@ -215,11 +215,7 @@
 
   public void setAuthor(IdentifiedUser author) {
     this.author = author;
-    getCommitBuilder()
-        .setAuthor(
-            author.newCommitterIdent(
-                getCommitBuilder().getCommitter().getWhen(),
-                getCommitBuilder().getCommitter().getTimeZone()));
+    getCommitBuilder().setAuthor(author.newCommitterIdent(getCommitBuilder().getCommitter()));
   }
 
   public void setAllowEmpty(boolean allowEmpty) {
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index aa4e88e..dd5af2c 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -326,9 +326,7 @@
 
   /** Determine if the user can upload commits. */
   public Capable canUpload() throws IOException, PermissionBackendException {
-    try {
-      perm.check(ProjectPermission.PUSH_AT_LEAST_ONE_REF);
-    } catch (AuthException e) {
+    if (!perm.test(ProjectPermission.PUSH_AT_LEAST_ONE_REF)) {
       return new Capable("Upload denied for project '" + projectState.getName() + "'");
     }
 
diff --git a/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java b/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
index a19dbac..a562659 100644
--- a/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
+++ b/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
@@ -72,7 +72,7 @@
             String.format(
                 "%s request failed for project %s with [%s]",
                 REPOSITORY_SIZE_GROUP, project, a.errorMessage());
-        logger.atWarning().log(msg);
+        logger.atWarning().log("%s", msg);
         throw new RuntimeException(msg);
       }
     }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 951eb55..71606dc 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -747,14 +747,19 @@
       return;
     }
 
-    if (!magicCommands.isEmpty()) {
-      metrics.pushCount.increment("magic", project.getName(), getUpdateType(magicCommands));
-    }
-    if (!regularCommands.isEmpty()) {
-      metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
-    }
-
     try {
+      if (!magicCommands.isEmpty()) {
+        parseMagicBranch(Iterables.getLast(magicCommands));
+        // Using the submit option submits the created change(s) immediately without checking labels
+        // nor submit rules. Hence we shouldn't record such pushes as "magic" which implies that
+        // code review is being done.
+        String pushKind = magicBranch != null && magicBranch.submit ? "direct_submit" : "magic";
+        metrics.pushCount.increment(pushKind, project.getName(), getUpdateType(magicCommands));
+      }
+      if (!regularCommands.isEmpty()) {
+        metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
+      }
+
       if (!regularCommands.isEmpty()) {
         handleRegularCommands(regularCommands, progress);
         return;
@@ -763,7 +768,6 @@
       boolean first = true;
       for (ReceiveCommand cmd : magicCommands) {
         if (first) {
-          parseMagicBranch(cmd);
           first = false;
         } else {
           reject(cmd, "duplicate request");
@@ -777,7 +781,7 @@
     Task newProgress = progress.beginSubTask("new", UNKNOWN);
     Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
 
-    List<CreateRequest> newChanges = Collections.emptyList();
+    ImmutableList<CreateRequest> newChanges = ImmutableList.of();
     try {
       if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
         try {
@@ -836,7 +840,7 @@
       Map<BranchNameKey, ReceiveCommand> branches;
       try (BatchUpdate bu =
               batchUpdateFactory.create(
-                  project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+                  project.getNameKey(), user.materializedCopy(), TimeUtil.now());
           ObjectInserter ins = repo.newObjectInserter();
           ObjectReader reader = ins.newReader();
           RevWalk rw = new RevWalk(reader);
@@ -858,7 +862,7 @@
 
         submissionExecutor.execute(ImmutableList.of(bu));
 
-        orm.setContext(TimeUtil.nowTs(), user, NotifyResolver.Result.none());
+        orm.setContext(TimeUtil.now(), user, NotifyResolver.Result.none());
         submissionExecutor.afterExecutions(orm);
 
         branches = bu.getSuccessfullyUpdatedBranches(false);
@@ -1005,7 +1009,8 @@
     addMessage(changeFormatter.changeUpdated(input));
   }
 
-  private void insertChangesAndPatchSets(List<CreateRequest> newChanges, Task replaceProgress) {
+  private void insertChangesAndPatchSets(
+      ImmutableList<CreateRequest> newChanges, Task replaceProgress) {
     try (TraceTimer traceTimer =
         newTimer(
             "insertChangesAndPatchSets", Metadata.builder().resourceCount(newChanges.size()))) {
@@ -1069,7 +1074,7 @@
       throws RestApiException, IOException {
     try (BatchUpdate bu =
             batchUpdateFactory.create(
-                project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+                project.getNameKey(), user.materializedCopy(), TimeUtil.now());
         ObjectInserter ins = repo.newObjectInserter();
         ObjectReader reader = ins.newReader();
         RevWalk rw = new RevWalk(reader)) {
@@ -1090,13 +1095,15 @@
                 publishCommentsOp.create(replace.psId, project.getNameKey()));
             Optional<ChangeNotes> changeNotes = getChangeNotes(replace.notes.getChangeId());
             if (!changeNotes.isPresent()) {
-              // If not present, no need to update attention set here since this is a new change.
+              // If not present, no need to update attention set here since this is a
+              // new change.
               continue;
             }
             List<HumanComment> drafts =
                 commentsUtil.draftByChangeAuthor(changeNotes.get(), user.getAccountId());
             if (drafts.isEmpty()) {
-              // If no comments, attention set shouldn't update since the user didn't reply.
+              // If no comments, attention set shouldn't update since the user didn't
+              // reply.
               continue;
             }
             replyAttentionSetUpdates.processAutomaticAttentionSetRulesOnReply(
@@ -1134,7 +1141,8 @@
         String rejectMessage = replace.getRejectMessage();
         if (rejectMessage == null) {
           if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
-            // Not necessarily the magic branch, so need to set OK on the original value.
+            // Not necessarily the magic branch, so need to set OK on the original
+            // value.
             replace.inputCommand.setResult(OK);
           }
         } else {
@@ -1446,6 +1454,12 @@
   private void parseCreate(ReceiveCommand cmd)
       throws PermissionBackendException, NoSuchProjectException, IOException {
     try (TraceTimer traceTimer = newTimer("parseCreate")) {
+      if (repo.resolve(cmd.getRefName()) != null) {
+        reject(
+            cmd,
+            String.format("Cannot create ref '%s' because it already exists.", cmd.getRefName()));
+        return;
+      }
       RevObject obj;
       try {
         obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
@@ -2248,7 +2262,8 @@
                             comment.message.length()))
                 .collect(toImmutableList());
         CommentValidationContext ctx =
-            CommentValidationContext.create(change.getChangeId(), change.getProject().get());
+            CommentValidationContext.create(
+                change.getChangeId(), change.getProject().get(), change.getDest().branch());
         ImmutableList<CommentValidationFailure> commentValidationFailures =
             PublishCommentUtil.findInvalidComments(ctx, commentValidators, draftsForValidation);
         magicBranch.setWithholdComments(!commentValidationFailures.isEmpty());
@@ -2264,7 +2279,7 @@
     }
   }
 
-  private void warnAboutMissingChangeId(List<CreateRequest> newChanges) {
+  private void warnAboutMissingChangeId(ImmutableList<CreateRequest> newChanges) {
     for (CreateRequest create : newChanges) {
       try {
         receivePack.getRevWalk().parseBody(create.commit);
@@ -2281,7 +2296,7 @@
     }
   }
 
-  private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress)
+  private ImmutableList<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress)
       throws IOException {
     try (TraceTimer traceTimer = newTimer("selectNewAndReplacedChangesFromMagicBranch")) {
       logger.atFine().log("Finding new and replaced changes");
@@ -2296,7 +2311,7 @@
       try {
         RevCommit start = setUpWalkForSelectingChanges();
         if (start == null) {
-          return Collections.emptyList();
+          return ImmutableList.of();
         }
 
         LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
@@ -2374,7 +2389,7 @@
             reject(
                 magicBranch.cmd,
                 "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
-            return Collections.emptyList();
+            return ImmutableList.of();
           }
 
           if (commitAlreadyTracked) {
@@ -2407,7 +2422,7 @@
           if (!validationResult.isValid()) {
             // Not a change the user can propose? Abort as early as possible.
             logger.atFine().log("Aborting early due to invalid commit");
-            return Collections.emptyList();
+            return ImmutableList.of();
           }
 
           // Don't allow merges to be uploaded in commit chain via all-not-in-target
@@ -2444,7 +2459,7 @@
           if (newChangeIds.contains(p.changeKey)) {
             logger.atFine().log("Multiple commits with Change-Id %s", p.changeKey);
             reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
-            return Collections.emptyList();
+            return ImmutableList.of();
           }
 
           List<ChangeData> changes = p.destChanges;
@@ -2460,7 +2475,7 @@
             // this error message as Change-Id should be unique per branch.
             //
             reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
-            return Collections.emptyList();
+            return ImmutableList.of();
           }
 
           if (changes.size() == 1) {
@@ -2485,13 +2500,13 @@
                 magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
               continue;
             }
-            return Collections.emptyList();
+            return ImmutableList.of();
           }
 
           if (changes.isEmpty()) {
             if (!isValidChangeId(p.changeKey.get())) {
               reject(magicBranch.cmd, "invalid Change-Id");
-              return Collections.emptyList();
+              return ImmutableList.of();
             }
 
             // In case the change look up from the index failed,
@@ -2499,7 +2514,7 @@
             if (foundInExistingPatchSets(receivePackRefCache.patchSetIdsFromObjectId(p.commit))) {
               if (pending.size() == 1) {
                 reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
-                return Collections.emptyList();
+                return ImmutableList.of();
               }
               itr.remove();
               continue;
@@ -2519,11 +2534,11 @@
 
       if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
         reject(magicBranch.cmd, "no new changes");
-        return Collections.emptyList();
+        return ImmutableList.of();
       }
       if (!newChanges.isEmpty() && magicBranch.edit) {
         reject(magicBranch.cmd, "edit is not supported for new changes");
-        return newChanges;
+        return ImmutableList.copyOf(newChanges);
       }
 
       SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
@@ -2537,10 +2552,10 @@
         replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
       }
       for (UpdateGroupsRequest update : updateGroups) {
-        update.groups = ImmutableList.copyOf((groups.get(update.commit)));
+        update.groups = ImmutableList.copyOf(groups.get(update.commit));
       }
       logger.atFine().log("Finished updating groups from GroupCollector");
-      return newChanges;
+      return ImmutableList.copyOf(newChanges);
     }
   }
 
@@ -2843,7 +2858,7 @@
     }
   }
 
-  private void preparePatchSetsForReplace(List<CreateRequest> newChanges) {
+  private void preparePatchSetsForReplace(ImmutableList<CreateRequest> newChanges) {
     try (TraceTimer traceTimer =
         newTimer(
             "preparePatchSetsForReplace", Metadata.builder().resourceCount(newChanges.size()))) {
@@ -3357,7 +3372,9 @@
   // REJECTED, and the return value is 'false'
   private boolean validRefOperation(ReceiveCommand cmd) {
     try (TraceTimer traceTimer = newTimer("validRefOperation")) {
-      RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
+      RefOperationValidators refValidators =
+          refValidatorsFactory.create(
+              getProject(), user, cmd, ImmutableListMultimap.copyOf(pushOptions));
 
       try {
         messages.addAll(refValidators.validateForRefOperation());
@@ -3470,7 +3487,7 @@
                 "autoCloseChanges",
                 updateFactory -> {
                   try (BatchUpdate bu =
-                          updateFactory.create(projectState.getNameKey(), user, TimeUtil.nowTs());
+                          updateFactory.create(projectState.getNameKey(), user, TimeUtil.now());
                       ObjectInserter ins = repo.newObjectInserter();
                       ObjectReader reader = ins.newReader();
                       RevWalk rw = new RevWalk(reader)) {
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
index df1888b..e50482d 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
@@ -21,7 +21,7 @@
 
   @VisibleForTesting
   public static final String ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP =
-      "only users with Toogle-Wip-State permission can modify Work-in-Progress";
+      "only users with Toggle-Wip-State permission can modify Work-in-Progress";
 
   static final String COMMAND_REJECTION_MESSAGE_FOOTER =
       "Contact an administrator to fix the permissions";
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index a9ef70e..e7e0e8f 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -48,13 +48,13 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.approval.ApprovalsUtil;
-import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeKindCache;
 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.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
@@ -345,9 +345,8 @@
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
 
-    // Approvals that are being set in the new patch-set during this operation are not available yet
-    // outside of the scope of this method. Only copied approvals are set here.
-    approvalsUtil.byPatchSet(ctx.getNotes(), newPatchSet).forEach(a -> update.putCopiedApproval(a));
+    approvalsUtil.persistCopiedApprovals(
+        ctx.getNotes(), newPatchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
 
     mailMessage = insertChangeMessage(update, ctx, reviewMessage);
     if (mergedByPushOp == null) {
@@ -407,8 +406,7 @@
     return input;
   }
 
-  private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx, String reviewMessage)
-      throws IOException {
+  private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx, String reviewMessage) {
     String approvalMessage =
         ApprovalsUtil.renderMessageWithApprovals(
             patchSetId.get(), approvals, scanLabels(ctx, approvals));
@@ -439,8 +437,8 @@
       case TRIVIAL_REBASE:
         return ": Patch Set " + priorPatchSetId.get() + " was rebased.";
       case NO_CHANGE:
-        return ": New patch set was added with same tree, parent"
-            + (commit.getParentCount() != 1 ? "s" : "")
+        return ": New patch set was added with same tree, parent "
+            + (commit.getParentCount() != 1 ? "trees" : "tree")
             + ", and commit message as Patch Set "
             + priorPatchSetId.get()
             + ".";
@@ -452,18 +450,13 @@
     }
   }
 
-  private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx, Map<String, Short> approvals)
-      throws IOException {
+  private Map<String, PatchSetApproval> scanLabels(
+      ChangeContext ctx, Map<String, Short> approvals) {
     Map<String, PatchSetApproval> current = new HashMap<>();
     // We optimize here and only retrieve current when approvals provided
     if (!approvals.isEmpty()) {
       for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(
-              ctx.getNotes(),
-              priorPatchSetId,
-              ctx.getAccountId(),
-              ctx.getRevWalk(),
-              ctx.getRepoView().getConfig())) {
+          approvalsUtil.byPatchSetUser(ctx.getNotes(), priorPatchSetId, ctx.getAccountId())) {
         if (a.isLegacySubmit()) {
           continue;
         }
@@ -538,13 +531,13 @@
         emailSender.addReviewers(
             Streams.concat(
                     oldRecipients.getReviewers().stream(),
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
                         .map(PatchSetApproval::accountId))
                 .collect(toImmutableSet()));
         emailSender.addExtraCC(
             Streams.concat(
                     oldRecipients.getCcOnly().stream(),
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs).stream())
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs).stream())
                 .collect(toImmutableSet()));
         emailSender.setMessageId(
             messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSetId));
diff --git a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
index c54ab25..4d2805d 100644
--- a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
+++ b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
@@ -19,6 +19,8 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -37,12 +39,13 @@
   @VisibleForTesting
   @AutoValue
   public abstract static class Result {
-    public abstract Map<String, Ref> allRefs();
+    public abstract ImmutableMap<String, Ref> allRefs();
 
-    public abstract Set<ObjectId> additionalHaves();
+    public abstract ImmutableSet<ObjectId> additionalHaves();
 
     public static Result create(Map<String, Ref> allRefs, Set<ObjectId> additionalHaves) {
-      return new AutoValue_TestRefAdvertiser_Result(allRefs, additionalHaves);
+      return new AutoValue_TestRefAdvertiser_Result(
+          ImmutableMap.copyOf(allRefs), ImmutableSet.copyOf(additionalHaves));
     }
   }
 
diff --git a/java/com/google/gerrit/server/git/validators/AccountValidator.java b/java/com/google/gerrit/server/git/validators/AccountValidator.java
index c2a5047..b38f405 100644
--- a/java/com/google/gerrit/server/git/validators/AccountValidator.java
+++ b/java/com/google/gerrit/server/git/validators/AccountValidator.java
@@ -28,7 +28,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -77,7 +76,7 @@
       }
     }
 
-    List<String> messages = new ArrayList<>();
+    ImmutableList.Builder<String> messages = ImmutableList.builder();
     Optional<Account> newAccount;
     try {
       newAccount = loadAccount(accountId, allUsersRepo, rw, newId, messages);
@@ -108,7 +107,7 @@
       }
     }
 
-    return ImmutableList.copyOf(messages);
+    return messages.build();
   }
 
   private Optional<Account> loadAccount(
@@ -116,7 +115,7 @@
       Repository allUsersRepo,
       RevWalk rw,
       ObjectId commit,
-      @Nullable List<String> messages)
+      @Nullable ImmutableList.Builder<String> messages)
       throws IOException, ConfigInvalidException {
     rw.reset();
     AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo);
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
index 6e640f3..b887323 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -23,7 +23,6 @@
 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.config.GerritServerConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
@@ -32,7 +31,8 @@
 
 /**
  * Limits the total size of all comments and change messages to prevent space/time complexity
- * issues. Note that autogenerated change messages are not subject to validation.
+ * issues. Note that autogenerated change messages are not subject to validation. However, we still
+ * count autogenerated messages for the limit (which will be notified on a further comment).
  */
 public class CommentCumulativeSizeValidator implements CommentValidator {
   public static final int DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT = 3 << 20;
@@ -60,17 +60,11 @@
                     notes.getRobotComments().values().stream())
                 .mapToInt(Comment::getApproximateSize)
                 .sum()
-            + notes.getChangeMessages().stream()
-                // Auto-generated change messages are not counted for the limit. This method is not
-                // called when those change messages are created, but we should also skip them when
-                // counting the size for unrelated messages.
-                .filter(cm -> !ChangeMessagesUtil.isAutogenerated(cm.getTag()))
-                .mapToInt(cm -> cm.getMessage().length())
-                .sum();
+            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
     int newCumulativeSize =
         comments.stream().mapToInt(CommentForValidation::getApproximateSize).sum();
     ImmutableList.Builder<CommentValidationFailure> failures = ImmutableList.builder();
-    if (!comments.isEmpty() && existingCumulativeSize + newCumulativeSize > maxCumulativeSize) {
+    if (!comments.isEmpty() && !isEnoughSpace(notes, newCumulativeSize, maxCumulativeSize)) {
       // This warning really applies to the set of all comments, but we need to pick one to attach
       // the message to.
       CommentForValidation commentForFailureMessage = Iterables.getLast(comments);
@@ -84,4 +78,19 @@
     }
     return failures.build();
   }
+
+  /**
+   * Returns {@code true} if there is available space and the new size that we wish to add is less
+   * than the maximum allowed size. {@code false} otherwise (if there is not enough space).
+   */
+  public static boolean isEnoughSpace(ChangeNotes notes, int addedBytes, int maxCumulativeSize) {
+    int existingCumulativeSize =
+        Stream.concat(
+                    notes.getHumanComments().values().stream(),
+                    notes.getRobotComments().values().stream())
+                .mapToInt(Comment::getApproximateSize)
+                .sum()
+            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
+    return existingCumulativeSize + addedBytes < maxCumulativeSize;
+  }
 }
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 596efb5..6118157 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;
@@ -265,12 +266,6 @@
         "multiple Change-Id lines in message footer";
     private static final String INVALID_CHANGE_ID_MSG =
         "invalid Change-Id line format in message footer";
-    private static final String HTTP_INSTALL_HOOK =
-        "f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\""
-            + " %stools/hooks/commit-msg ; chmod +x \"$f\"";
-    private static final String SSH_INSTALL_HOOK =
-        "gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/";
-    private static final String CHANGE_ID_MISSING_INSTALL_HOOKS = "  %s\nor, for http(s):\n  %s";
 
     @VisibleForTesting
     public static final String CHANGE_ID_MISMATCH_MSG =
@@ -378,7 +373,10 @@
       // HTTP(S)
       Optional<String> webUrl = urlFormatter.getWebUrl();
 
-      String httpHook = String.format(HTTP_INSTALL_HOOK, webUrl.get());
+      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());
@@ -405,8 +403,9 @@
 
       String sshHook =
           String.format(
-              SSH_INSTALL_HOOK, sshPort, user.getUserName().orElse("<USERNAME>"), sshHost);
-      return String.format(CHANGE_ID_MISSING_INSTALL_HOOKS, sshHook, httpHook);
+              "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);
     }
   }
 
@@ -449,7 +448,7 @@
         // This happens e.g. for cherrypicks.
         if (!receiveEvent.command.getRefName().startsWith(REFS_CHANGES)) {
           logger.atWarning().withCause(e).log(
-              "Failed to validate file count for commit: %s", receiveEvent.commit.toString());
+              "Failed to validate file count for commit: %s", receiveEvent.commit);
         }
       }
       return Collections.emptyList();
@@ -510,7 +509,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))) {
@@ -518,9 +517,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(),
@@ -647,10 +649,10 @@
       }
       if (!sboAuthor && !sboCommitter && !sboMe) {
         try {
-          perm.check(RefPermission.FORGE_COMMITTER);
-        } catch (AuthException denied) {
-          throw new CommitValidationException(
-              "not Signed-off-by author/committer/uploader in message footer", denied);
+          if (!perm.test(RefPermission.FORGE_COMMITTER)) {
+            throw new CommitValidationException(
+                "not Signed-off-by author/committer/uploader in message footer");
+          }
         } catch (PermissionBackendException e) {
           logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER");
           throw new CommitValidationException("internal auth error");
@@ -681,11 +683,11 @@
         return Collections.emptyList();
       }
       try {
-        perm.check(RefPermission.FORGE_AUTHOR);
+        if (!perm.test(RefPermission.FORGE_AUTHOR)) {
+          throw new CommitValidationException(
+              "invalid author", invalidEmail("author", author, user, urlFormatter));
+        }
         return Collections.emptyList();
-      } catch (AuthException e) {
-        throw new CommitValidationException(
-            "invalid author", invalidEmail("author", author, user, urlFormatter), e);
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check FORGE_AUTHOR");
         throw new CommitValidationException("internal auth error");
@@ -714,11 +716,11 @@
         return Collections.emptyList();
       }
       try {
-        perm.check(RefPermission.FORGE_COMMITTER);
+        if (!perm.test(RefPermission.FORGE_COMMITTER)) {
+          throw new CommitValidationException(
+              "invalid committer", invalidEmail("committer", committer, user, urlFormatter));
+        }
         return Collections.emptyList();
-      } catch (AuthException e) {
-        throw new CommitValidationException(
-            "invalid committer", invalidEmail("committer", committer, user, urlFormatter), e);
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER");
         throw new CommitValidationException("internal auth error");
@@ -786,9 +788,7 @@
         }
         return Collections.emptyList();
       } catch (IOException e) {
-        String m = "error checking banned commits";
-        logger.atWarning().withCause(e).log(m);
-        throw new CommitValidationException(m, e);
+        throw new CommitValidationException("error checking banned commits", e);
       }
     }
   }
@@ -827,9 +827,7 @@
           }
           return msgs;
         } catch (IOException | ConfigInvalidException e) {
-          String m = "error validating external IDs";
-          logger.atWarning().withCause(e).log(m);
-          throw new CommitValidationException(m, e);
+          throw new CommitValidationException("error validating external IDs", e);
         }
       }
       return Collections.emptyList();
@@ -884,9 +882,8 @@
                   .collect(toList()));
         }
       } catch (IOException e) {
-        String m = String.format("Validating update for account %s failed", accountId.get());
-        logger.atSevere().withCause(e).log(m);
-        throw new CommitValidationException(m, e);
+        throw new CommitValidationException(
+            String.format("Validating update for account %s failed", accountId.get()), e);
       }
       return Collections.emptyList();
     }
@@ -977,6 +974,9 @@
       try {
         return new URL(canonicalWebUrl).getHost();
       } catch (MalformedURLException ignored) {
+        logger.atWarning().log(
+            "configured canonical web URL is invalid, using system default: %s",
+            ignored.getMessage());
       }
     }
 
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 325de5b..811e960 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -196,9 +196,9 @@
             if (!oldParent.equals(newParent)) {
               if (!allowProjectOwnersToChangeParent) {
                 try {
-                  permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
-                } catch (AuthException e) {
-                  throw new MergeValidationException(SET_BY_ADMIN, e);
+                  if (!permissionBackend.user(caller).test(GlobalPermission.ADMINISTRATE_SERVER)) {
+                    throw new MergeValidationException(SET_BY_ADMIN);
+                  }
                 } catch (PermissionBackendException e) {
                   logger.atWarning().withCause(e).log("Cannot check ADMINISTRATE_SERVER");
                   throw new MergeValidationException("validation unavailable", e);
@@ -236,7 +236,7 @@
             String oldValue =
                 destProject.getPluginConfig(e.getPluginName()).getString(e.getExportName());
 
-            if ((!Objects.equals(value, oldValue)) && !configEntry.isEditable(destProject)) {
+            if (!Objects.equals(value, oldValue) && !configEntry.isEditable(destProject)) {
               throw new MergeValidationException(PLUGIN_VALUE_NOT_EDITABLE);
             }
 
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index f3b6983..85d232e 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -16,6 +16,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
@@ -33,6 +34,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Locale;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -46,7 +48,11 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    RefOperationValidators create(Project project, IdentifiedUser user, ReceiveCommand cmd);
+    RefOperationValidators create(
+        Project project,
+        IdentifiedUser user,
+        ReceiveCommand cmd,
+        ImmutableListMultimap<String, String> pushOptions);
   }
 
   public static ReceiveCommand getCommand(RefUpdate update, ReceiveCommand.Type type) {
@@ -66,7 +72,8 @@
       PluginSetContext<RefOperationValidationListener> refOperationValidationListeners,
       @Assisted Project project,
       @Assisted IdentifiedUser user,
-      @Assisted ReceiveCommand cmd) {
+      @Assisted ReceiveCommand cmd,
+      @Assisted ImmutableListMultimap<String, String> pushOptions) {
     this.perm = permissionBackend.user(user);
     this.allUsersName = allUsersName;
     this.refOperationValidationListeners = refOperationValidationListeners;
@@ -74,6 +81,7 @@
     event.command = cmd;
     event.project = project;
     event.user = user;
+    event.pushOptions = pushOptions;
   }
 
   /**
@@ -89,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;
@@ -106,13 +114,30 @@
       throws RefOperationValidationException {
     String header =
         String.format(
-            "Ref \"%s\" %S in project %s validation failed",
-            event.command.getRefName(), event.command.getType(), event.project.getName());
-    logger.atSevere().log(header);
+            "Validation for %s of ref '%s' in project %s failed:",
+            formatReceiveCommandType(event.command.getType()),
+            event.command.getRefName(),
+            event.project.getName());
+    logger.atSevere().log("%s", header);
     throw new RefOperationValidationException(
         header, messages.stream().filter(ValidationMessage::isError).collect(toImmutableList()));
   }
 
+  private static String formatReceiveCommandType(ReceiveCommand.Type type) {
+    switch (type) {
+      case CREATE:
+        return "creation";
+      case DELETE:
+        return "deletion";
+      case UPDATE:
+        return "update";
+      case UPDATE_NONFASTFORWARD:
+        return "non-fast-forward update";
+      default:
+        return type.toString().toLowerCase(Locale.US);
+    }
+  }
+
   private static class DisallowCreationAndDeletionOfGerritMaintainedBranches
       implements RefOperationValidationListener {
     private final PermissionBackend.WithUser perm;
diff --git a/java/com/google/gerrit/server/group/GroupAuditService.java b/java/com/google/gerrit/server/group/GroupAuditService.java
index 30e5d3c..21959ec 100644
--- a/java/com/google/gerrit/server/group/GroupAuditService.java
+++ b/java/com/google/gerrit/server/group/GroupAuditService.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.AuditEvent;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 public interface GroupAuditService {
   void dispatch(AuditEvent action);
@@ -27,23 +27,23 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> addedMembers,
-      Timestamp addedOn);
+      Instant addedOn);
 
   void dispatchDeleteMembers(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> deletedMembers,
-      Timestamp deletedOn);
+      Instant deletedOn);
 
   void dispatchAddSubgroups(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> addedSubgroups,
-      Timestamp addedOn);
+      Instant addedOn);
 
   void dispatchDeleteSubgroups(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> deletedSubgroups,
-      Timestamp deletedOn);
+      Instant deletedOn);
 }
diff --git a/java/com/google/gerrit/server/group/GroupResource.java b/java/com/google/gerrit/server/group/GroupResource.java
index b0e81ec..dfcdbd7 100644
--- a/java/com/google/gerrit/server/group/GroupResource.java
+++ b/java/com/google/gerrit/server/group/GroupResource.java
@@ -22,8 +22,7 @@
 import java.util.Optional;
 
 public class GroupResource implements RestResource {
-  public static final TypeLiteral<RestView<GroupResource>> GROUP_KIND =
-      new TypeLiteral<RestView<GroupResource>>() {};
+  public static final TypeLiteral<RestView<GroupResource>> GROUP_KIND = new TypeLiteral<>() {};
 
   private final GroupControl control;
 
diff --git a/java/com/google/gerrit/server/group/InternalGroupDescription.java b/java/com/google/gerrit/server/group/InternalGroupDescription.java
index 62ebcfe..984daea 100644
--- a/java/com/google/gerrit/server/group/InternalGroupDescription.java
+++ b/java/com/google/gerrit/server/group/InternalGroupDescription.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.InternalGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 public class InternalGroupDescription implements GroupDescription.Internal {
 
@@ -77,7 +77,7 @@
   }
 
   @Override
-  public Timestamp getCreatedOn() {
+  public Instant getCreatedOn() {
     return internalGroup.getCreatedOn();
   }
 
diff --git a/java/com/google/gerrit/server/group/MemberResource.java b/java/com/google/gerrit/server/group/MemberResource.java
index b12cadd..de8cc02 100644
--- a/java/com/google/gerrit/server/group/MemberResource.java
+++ b/java/com/google/gerrit/server/group/MemberResource.java
@@ -19,8 +19,7 @@
 import com.google.inject.TypeLiteral;
 
 public class MemberResource extends GroupResource {
-  public static final TypeLiteral<RestView<MemberResource>> MEMBER_KIND =
-      new TypeLiteral<RestView<MemberResource>>() {};
+  public static final TypeLiteral<RestView<MemberResource>> MEMBER_KIND = new TypeLiteral<>() {};
 
   private final IdentifiedUser user;
 
diff --git a/java/com/google/gerrit/server/group/SubgroupResource.java b/java/com/google/gerrit/server/group/SubgroupResource.java
index 21356be..7d917a7 100644
--- a/java/com/google/gerrit/server/group/SubgroupResource.java
+++ b/java/com/google/gerrit/server/group/SubgroupResource.java
@@ -21,7 +21,7 @@
 
 public class SubgroupResource extends GroupResource {
   public static final TypeLiteral<RestView<SubgroupResource>> SUBGROUP_KIND =
-      new TypeLiteral<RestView<SubgroupResource>>() {};
+      new TypeLiteral<>() {};
 
   private final GroupDescription.Basic member;
 
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index 5d50d22..5a9b9e5 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -45,9 +45,9 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.NavigableMap;
 import java.util.Optional;
 import java.util.Set;
-import java.util.SortedMap;
 import java.util.TreeMap;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -89,7 +89,7 @@
   }
 
   private final ImmutableSet<String> reservedNames;
-  private final SortedMap<String, GroupReference> namesToGroups;
+  private final NavigableMap<String, GroupReference> namesToGroups;
   private final ImmutableSet<String> names;
   private final ImmutableMap<AccountGroup.UUID, GroupReference> uuids;
   private final ImmutableSet<AccountGroup.UUID> externalUserMemberships;
@@ -97,7 +97,7 @@
   @Inject
   @VisibleForTesting
   public SystemGroupBackend(@GerritServerConfig Config cfg) {
-    SortedMap<String, GroupReference> n = new TreeMap<>();
+    NavigableMap<String, GroupReference> n = new TreeMap<>();
     ImmutableMap.Builder<AccountGroup.UUID, GroupReference> u = ImmutableMap.builder();
 
     ImmutableSet.Builder<String> reservedNamesBuilder = ImmutableSet.builder();
@@ -112,7 +112,7 @@
       u.put(ref.getUUID(), ref);
     }
     reservedNames = reservedNamesBuilder.build();
-    namesToGroups = Collections.unmodifiableSortedMap(n);
+    namesToGroups = Collections.unmodifiableNavigableMap(n);
     names =
         ImmutableSet.copyOf(
             namesToGroups.values().stream().map(GroupReference::getName).collect(toSet()));
@@ -172,9 +172,10 @@
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
     String nameLC = name.toLowerCase(Locale.US);
-    SortedMap<String, GroupReference> matches = namesToGroups.tailMap(nameLC);
+    NavigableMap<String, GroupReference> matches =
+        namesToGroups.tailMap(nameLC, /* inclusive= */ true);
     if (matches.isEmpty()) {
-      return Collections.emptyList();
+      return new ArrayList<>();
     }
 
     List<GroupReference> r = new ArrayList<>(matches.size());
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
index d8f0a0f..4c1f69b 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogReader.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -31,7 +31,7 @@
 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.List;
 import java.util.Optional;
@@ -166,7 +166,7 @@
     return Optional.of(
         new AutoValue_AuditLogReader_ParsedCommit(
             authorId.get(),
-            new Timestamp(c.getAuthorIdent().getWhen().getTime()),
+            c.getAuthorIdent().getWhenAsInstant(),
             ImmutableList.copyOf(addedMembers),
             ImmutableList.copyOf(removedMembers),
             ImmutableList.copyOf(addedSubgroups),
@@ -257,7 +257,7 @@
   abstract static class ParsedCommit {
     abstract Account.Id authorId();
 
-    abstract Timestamp when();
+    abstract Instant when();
 
     abstract ImmutableList<Account.Id> addedMembers();
 
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index a00d529..682fd15 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -34,7 +34,7 @@
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Optional;
 import java.util.function.Function;
@@ -278,7 +278,7 @@
       rw.markStart(revision);
       rw.sort(RevSort.REVERSE);
       RevCommit earliestCommit = rw.next();
-      Timestamp createdOn = new Timestamp(earliestCommit.getCommitTime() * 1000L);
+      Instant createdOn = Instant.ofEpochSecond(earliestCommit.getCommitTime());
 
       Config config = readConfig(GROUP_CONFIG_FILE);
       ImmutableSet<Account.Id> members = readMembers();
@@ -313,9 +313,9 @@
 
     // Commit timestamps are internally truncated to seconds. To return the correct 'createdOn' time
     // for new groups, we explicitly need to truncate the timestamp here.
-    Timestamp commitTimestamp =
+    Instant commitTimestamp =
         TimeUtil.truncateToSecond(
-            groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::nowTs));
+            groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::now));
     commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
     commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
 
@@ -345,7 +345,7 @@
     return Optional.empty();
   }
 
-  private InternalGroup updateGroup(Timestamp commitTimestamp)
+  private InternalGroup updateGroup(Instant commitTimestamp)
       throws IOException, ConfigInvalidException {
     Config config = updateGroupProperties();
 
@@ -357,7 +357,7 @@
         loadedGroup.map(InternalGroup::getSubgroups).orElseGet(ImmutableSet::of);
     Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups = updateSubgroups(originalSubgroups);
 
-    Timestamp createdOn = loadedGroup.map(InternalGroup::getCreatedOn).orElse(commitTimestamp);
+    Instant createdOn = loadedGroup.map(InternalGroup::getCreatedOn).orElse(commitTimestamp);
 
     return createFrom(
         groupUuid,
@@ -452,7 +452,7 @@
       Config config,
       ImmutableSet<Account.Id> members,
       ImmutableSet<AccountGroup.UUID> subgroups,
-      Timestamp createdOn,
+      Instant createdOn,
       ObjectId refState)
       throws ConfigInvalidException {
     InternalGroup.Builder group = InternalGroup.builder();
diff --git a/java/com/google/gerrit/server/group/db/GroupDelta.java b/java/com/google/gerrit/server/group/db/GroupDelta.java
index 69cb936..ad9c8bd 100644
--- a/java/com/google/gerrit/server/group/db/GroupDelta.java
+++ b/java/com/google/gerrit/server/group/db/GroupDelta.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 import java.util.Set;
 
@@ -107,7 +107,7 @@
    * in the audit log. For this reason, specifying this field will have an effect on the resulting
    * audit log.
    */
-  public abstract Optional<Timestamp> getUpdatedOn();
+  public abstract Optional<Instant> getUpdatedOn();
 
   public abstract Builder toBuilder();
 
@@ -184,12 +184,12 @@
     public abstract SubgroupModification getSubgroupModification();
 
     /**
-     * Defines the {@code Timestamp} to be used for the NoteDb commits of the update. If not
-     * specified, the current {@code Timestamp} when creating the commit will be used.
+     * Defines the {@code Instant} to be used for the NoteDb commits of the update. If not
+     * specified, the current {@code Instant} when creating the commit will be used.
      *
      * <p>See {@link #getUpdatedOn()}
      */
-    public abstract Builder setUpdatedOn(Timestamp timestamp);
+    public abstract Builder setUpdatedOn(Instant timestamp);
 
     public abstract GroupDelta build();
   }
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
index 24bcaf0..dd8534d 100644
--- a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -231,7 +231,7 @@
 
   public static void ensureConsistentWithGroupNameNotes(
       Repository allUsersRepo, InternalGroup group) throws IOException {
-    List<ConsistencyCheckInfo.ConsistencyProblemInfo> problems =
+    ImmutableList<ConsistencyCheckInfo.ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
             allUsersRepo, group.getNameKey(), group.getGroupUUID());
     problems.forEach(GroupsNoteDbConsistencyChecker::logConsistencyProblem);
@@ -246,7 +246,7 @@
    * @return a list of {@code ConsistencyProblemInfo} containing the problem details.
    */
   @VisibleForTesting
-  static List<ConsistencyProblemInfo> checkWithGroupNameNotes(
+  static ImmutableList<ConsistencyProblemInfo> checkWithGroupNameNotes(
       Repository allUsersRepo, AccountGroup.NameKey groupName, AccountGroup.UUID groupUUID)
       throws IOException {
     try {
@@ -259,7 +259,7 @@
 
       AccountGroup.UUID uuid = groupRef.get().getUUID();
 
-      List<ConsistencyProblemInfo> problems = new ArrayList<>();
+      ImmutableList.Builder<ConsistencyProblemInfo> problems = ImmutableList.builder();
       if (!Objects.equals(groupUUID, uuid)) {
         problems.add(
             warning(
@@ -273,7 +273,7 @@
         problems.add(
             warning("group note of name '%s' claims to represent name of '%s'", name, actualName));
       }
-      return problems;
+      return problems.build();
     } catch (ConfigInvalidException e) {
       return ImmutableList.of(
           warning("fail to check consistency with group name notes: %s", e.getMessage()));
@@ -287,9 +287,9 @@
 
   public static void logConsistencyProblem(ConsistencyProblemInfo p) {
     if (p.status == ConsistencyProblemInfo.Status.WARNING) {
-      logger.atWarning().log(p.message);
+      logger.atWarning().log("%s", p.message);
     } else {
-      logger.atSevere().log(p.message);
+      logger.atSevere().log("%s", p.message);
     }
   }
 
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 9aa5cfd..c0c934b 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -50,7 +50,7 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
@@ -248,7 +248,7 @@
   }
 
   private static PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
-    return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
+    return user.newCommitterIdent(ident);
   }
 
   /**
@@ -292,9 +292,9 @@
     try (TraceTimer ignored =
         TraceContext.newTimer(
             "Updating group", Metadata.builder().groupUuid(groupUuid.get()).build())) {
-      Optional<Timestamp> updatedOn = groupDelta.getUpdatedOn();
+      Optional<Instant> updatedOn = groupDelta.getUpdatedOn();
       if (!updatedOn.isPresent()) {
-        updatedOn = Optional.of(TimeUtil.nowTs());
+        updatedOn = Optional.of(TimeUtil.now());
         groupDelta = groupDelta.toBuilder().setUpdatedOn(updatedOn.get()).build();
       }
 
@@ -505,7 +505,7 @@
     }
   }
 
-  private void dispatchAuditEventsOnGroupUpdate(UpdateResult result, Timestamp updatedOn) {
+  private void dispatchAuditEventsOnGroupUpdate(UpdateResult result, Instant updatedOn) {
     if (!currentUser.isPresent()) {
       return;
     }
diff --git a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
index 8a1221e..f422f6a 100644
--- a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
+++ b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
@@ -24,7 +24,7 @@
 import com.google.common.truth.Subject;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.ObjectId;
 
 public class InternalGroupSubject extends Subject {
@@ -79,7 +79,7 @@
     return check("isVisibleToAll()").that(group.isVisibleToAll());
   }
 
-  public ComparableSubject<Timestamp> createdOn() {
+  public ComparableSubject<Instant> createdOn() {
     isNotNull();
     return check("getCreatedOn()").that(group.getCreatedOn());
   }
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index 85f423b..a6aeb6b 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 
+import com.google.common.base.Ticker;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
@@ -115,6 +116,9 @@
   @Override
   protected void configure() {
     factory(MultiProgressMonitor.Factory.class);
+    OptionalBinder.newOptionalBinder(binder(), Ticker.class)
+        .setDefault()
+        .toInstance(Ticker.systemTicker());
 
     bind(AccountIndexRewriter.class);
     bind(AccountIndexCollection.class);
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index 0dd22ce..416b175 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -116,8 +116,9 @@
   public static final FieldDef<AccountState, String> PREFERRED_EMAIL_EXACT =
       exact("preferredemail_exact").build(a -> a.account().preferredEmail());
 
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
   public static final FieldDef<AccountState, Timestamp> REGISTERED =
-      timestamp("registered").build(a -> a.account().registeredOn());
+      timestamp("registered").build(a -> Timestamp.from(a.account().registeredOn()));
 
   public static final FieldDef<AccountState, String> USERNAME =
       exact("username").build(a -> a.userName().map(String::toLowerCase).orElse(""));
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 7a1cd6e..ace3d6c 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -145,7 +145,7 @@
     try {
       futures = new SliceScheduler(index, ok).schedule();
     } catch (ProjectsCollectionFailure e) {
-      logger.atSevere().log(e.getMessage());
+      logger.atSevere().log("%s", e.getMessage());
       return Result.create(sw, false, 0, 0);
     }
 
@@ -186,7 +186,7 @@
       return reindexProject(
           indexer, project, 0, 1, ChangeNotes.Factory.scanChangeIds(repo), done, failed);
     } catch (IOException e) {
-      logger.atSevere().log(e.getMessage());
+      logger.atSevere().log("%s", e.getMessage());
       return null;
     }
   }
@@ -266,7 +266,7 @@
         this.failed.update(1);
       }
 
-      logger.atWarning().withCause(e).log(error);
+      logger.atWarning().withCause(e).log("%s", error);
       verboseWriter.println(error);
     }
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 4a2419b..c302a36 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -33,6 +33,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
+import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -55,6 +56,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.converter.ChangeProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
@@ -81,6 +83,7 @@
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
@@ -90,6 +93,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -111,6 +115,12 @@
 
   private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
 
+  /**
+   * To avoid the non-google dependency on org.apache.lucene.index.IndexWriter.MAX_TERM_LENGTH it is
+   * redefined here.
+   */
+  public static final int MAX_TERM_LENGTH = (1 << 15) - 2;
+
   // TODO: Rename LEGACY_ID to NUMERIC_ID
   /** Legacy change ID. */
   public static final FieldDef<ChangeData, Integer> LEGACY_ID =
@@ -150,19 +160,29 @@
   public static final FieldDef<ChangeData, String> FUZZY_TOPIC =
       fullText("topic5").build(ChangeField::getTopic);
 
+  /** Topic, a short annotation on the branch. */
+  public static final FieldDef<ChangeData, String> PREFIX_TOPIC =
+      prefix("topic6").build(ChangeField::getTopic);
+
   /** Submission id assigned by MergeOp. */
   public static final FieldDef<ChangeData, String> SUBMISSIONID =
       exact(ChangeQueryBuilder.FIELD_SUBMISSIONID).build(changeGetter(Change::getSubmissionId));
 
   /** Last update time since January 1, 1970. */
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
   public static final FieldDef<ChangeData, Timestamp> UPDATED =
-      timestamp("updated2").stored().build(changeGetter(Change::getLastUpdatedOn));
+      timestamp("updated2")
+          .stored()
+          .build(changeGetter(change -> Timestamp.from(change.getLastUpdatedOn())));
 
   /** When this change was merged, time since January 1, 1970. */
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
   public static final FieldDef<ChangeData, Timestamp> MERGED_ON =
       timestamp(ChangeQueryBuilder.FIELD_MERGED_ON)
           .stored()
-          .build(cd -> cd.getMergedOn().orElse(null), (cd, field) -> cd.setMergedOn(field));
+          .build(
+              cd -> cd.getMergedOn().map(Timestamp::from).orElse(null),
+              (cd, field) -> cd.setMergedOn(field != null ? field.toInstant() : null));
 
   /** List of full file paths modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> PATH =
@@ -193,6 +213,11 @@
       fullText("hashtag2")
           .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
 
+  /** Hashtags as prefix field for in-string search. */
+  public static final FieldDef<ChangeData, Iterable<String>> PREFIX_HASHTAG =
+      prefix("hashtag3")
+          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+
   /** Hashtags with original case. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
       storedOnly("_hashtag")
@@ -254,6 +279,14 @@
         .collect(toSet());
   }
 
+  /** Footers from the commit message of the current patch set. */
+  public static final FieldDef<ChangeData, Iterable<String>> FOOTER_NAME =
+      exact(ChangeQueryBuilder.FIELD_FOOTER_NAME).buildRepeatable(ChangeField::getFootersNames);
+
+  public static Set<String> getFootersNames(ChangeData cd) {
+    return cd.commitFooters().stream().map(f -> f.getKey()).collect(toSet());
+  }
+
   /** Folders that are touched by the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> DIRECTORY =
       exact(ChangeQueryBuilder.FIELD_DIRECTORY).buildRepeatable(ChangeField::getDirectories);
@@ -412,14 +445,31 @@
       integer(ChangeQueryBuilder.FIELD_REVERTOF)
           .build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null);
 
+  public static final FieldDef<ChangeData, String> IS_PURE_REVERT =
+      fullText(ChangeQueryBuilder.FIELD_PURE_REVERT)
+          .build(cd -> Boolean.TRUE.equals(cd.isPureRevert()) ? "1" : "0");
+
+  /**
+   * Determines if a change is submittable based on {@link
+   * com.google.gerrit.entities.SubmitRequirement}s.
+   */
+  public static final FieldDef<ChangeData, String> IS_SUBMITTABLE =
+      exact(ChangeQueryBuilder.FIELD_IS_SUBMITTABLE)
+          .build(
+              cd ->
+                  // All submit requirements should be fulfilled
+                  cd.submitRequirementsIncludingLegacy().values().stream()
+                          .allMatch(SubmitRequirementResult::fulfilled)
+                      ? "1"
+                      : "0");
+
   @VisibleForTesting
   static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
     List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
-    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c :
-        reviewers.asTable().cellSet()) {
+    for (Table.Cell<ReviewerStateInternal, Account.Id, Instant> c : reviewers.asTable().cellSet()) {
       String v = getReviewerFieldValue(c.getRowKey(), c.getColumnKey());
       r.add(v);
-      r.add(v + ',' + c.getValue().getTime());
+      r.add(v + ',' + c.getValue().toEpochMilli());
     }
     return r;
   }
@@ -431,7 +481,7 @@
   @VisibleForTesting
   static List<String> getReviewerByEmailFieldValues(ReviewerByEmailSet reviewersByEmail) {
     List<String> r = new ArrayList<>(reviewersByEmail.asTable().size() * 2);
-    for (Table.Cell<ReviewerStateInternal, Address, Timestamp> c :
+    for (Table.Cell<ReviewerStateInternal, Address, Instant> c :
         reviewersByEmail.asTable().cellSet()) {
       String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey());
       r.add(v);
@@ -440,7 +490,7 @@
         Address emailOnly = Address.create(c.getColumnKey().email());
         r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly));
       }
-      r.add(v + ',' + c.getValue().getTime());
+      r.add(v + ',' + c.getValue().toEpochMilli());
     }
     return r;
   }
@@ -450,8 +500,7 @@
   }
 
   public static ReviewerSet parseReviewerFieldValues(Change.Id changeId, Iterable<String> values) {
-    ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
-        ImmutableTable.builder();
+    ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Instant> b = ImmutableTable.builder();
     for (String v : values) {
 
       int i = v.indexOf(',');
@@ -493,7 +542,7 @@
             "Failed to parse timestamp of reviewer field from change %s: %s", changeId.get(), v);
         continue;
       }
-      Timestamp timestamp = new Timestamp(l);
+      Instant timestamp = Instant.ofEpochMilli(l);
 
       b.put(reviewerState.get(), accountId.get(), timestamp);
     }
@@ -502,7 +551,7 @@
 
   public static ReviewerByEmailSet parseReviewerByEmailFieldValues(
       Change.Id changeId, Iterable<String> values) {
-    ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b = ImmutableTable.builder();
+    ImmutableTable.Builder<ReviewerStateInternal, Address, Instant> b = ImmutableTable.builder();
     for (String v : values) {
       int i = v.indexOf(',');
       if (i < 0) {
@@ -546,7 +595,7 @@
             changeId.get(), v);
         continue;
       }
-      Timestamp timestamp = new Timestamp(l);
+      Instant timestamp = Instant.ofEpochMilli(l);
 
       b.put(reviewerState.get(), address, timestamp);
     }
@@ -612,47 +661,107 @@
   private static Iterable<String> getLabels(ChangeData cd) {
     Set<String> allApprovals = new HashSet<>();
     Set<String> distinctApprovals = new HashSet<>();
+    Table<String, Short, Integer> voteCounts = HashBasedTable.create();
     for (PatchSetApproval a : cd.currentApprovals()) {
       if (a.value() != 0 && !a.isLegacySubmit()) {
-        allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
+        increment(voteCounts, a.label(), a.value());
         Optional<LabelType> labelType = cd.getLabelTypes().byLabel(a.labelId());
-        allApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, a.accountId()));
-        if (cd.change().getOwner().equals(a.accountId())) {
-          allApprovals.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
-          allApprovals.addAll(
-              getMaxMinAnyLabels(
-                  a.label(), a.value(), labelType, ChangeQueryBuilder.OWNER_ACCOUNT_ID));
-        }
-        if (!cd.currentPatchSet().uploader().equals(a.accountId())) {
-          allApprovals.add(
-              formatLabel(a.label(), a.value(), ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
-          allApprovals.addAll(
-              getMaxMinAnyLabels(
-                  a.label(), a.value(), labelType, ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
-        }
+
+        allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
+        allApprovals.addAll(getMagicLabelFormats(a.label(), a.value(), labelType, a.accountId()));
+        allApprovals.addAll(getLabelOwnerFormats(a, cd, labelType));
+        allApprovals.addAll(getLabelNonUploaderFormats(a, cd, labelType));
         distinctApprovals.add(formatLabel(a.label(), a.value()));
-        distinctApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, null));
+        distinctApprovals.addAll(
+            getMagicLabelFormats(a.label(), a.value(), labelType, /* accountId= */ null));
       }
     }
     allApprovals.addAll(distinctApprovals);
+    allApprovals.addAll(getCountLabelFormats(voteCounts, cd));
     return allApprovals;
   }
 
-  private static List<String> getMaxMinAnyLabels(
+  private static void increment(Table<String, Short, Integer> table, String k1, short k2) {
+    if (!table.contains(k1, k2)) {
+      table.put(k1, k2, 1);
+    } else {
+      int val = table.get(k1, k2);
+      table.put(k1, k2, val + 1);
+    }
+  }
+
+  private static List<String> getCountLabelFormats(
+      Table<String, Short, Integer> voteCounts, ChangeData cd) {
+    List<String> allFormats = new ArrayList<>();
+    for (String label : voteCounts.rowMap().keySet()) {
+      Optional<LabelType> labelType = cd.getLabelTypes().byLabel(label);
+      Map<Short, Integer> row = voteCounts.row(label);
+      for (short vote : row.keySet()) {
+        int count = row.get(vote);
+        allFormats.addAll(getCountLabelFormats(labelType, label, vote, count));
+      }
+    }
+    return allFormats;
+  }
+
+  private static List<String> getCountLabelFormats(
+      Optional<LabelType> labelType, String label, short vote, int count) {
+    List<String> formats =
+        getMagicLabelFormats(label, vote, labelType, /* accountId= */ null, /* count= */ count);
+    formats.add(formatLabel(label, vote, count));
+    return formats;
+  }
+
+  /** Get magic label formats corresponding to the {MIN, MAX, ANY} label votes. */
+  private static List<String> getMagicLabelFormats(
       String label, short labelVal, Optional<LabelType> labelType, @Nullable Account.Id accountId) {
+    return getMagicLabelFormats(label, labelVal, labelType, accountId, /* count= */ null);
+  }
+
+  /** Get magic label formats corresponding to the {MIN, MAX, ANY} label votes. */
+  private static List<String> getMagicLabelFormats(
+      String label,
+      short labelVal,
+      Optional<LabelType> labelType,
+      @Nullable Account.Id accountId,
+      @Nullable Integer count) {
     List<String> labels = new ArrayList<>();
     if (labelType.isPresent()) {
       if (labelVal == labelType.get().getMaxPositive()) {
-        labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId));
+        labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId, count));
       }
       if (labelVal == labelType.get().getMaxNegative()) {
-        labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId));
+        labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId, count));
       }
     }
-    labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId));
+    labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId, count));
     return labels;
   }
 
+  private static List<String> getLabelOwnerFormats(
+      PatchSetApproval a, ChangeData cd, Optional<LabelType> labelType) {
+    List<String> allFormats = new ArrayList<>();
+    if (cd.change().getOwner().equals(a.accountId())) {
+      allFormats.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+      allFormats.addAll(
+          getMagicLabelFormats(
+              a.label(), a.value(), labelType, ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+    }
+    return allFormats;
+  }
+
+  private static List<String> getLabelNonUploaderFormats(
+      PatchSetApproval a, ChangeData cd, Optional<LabelType> labelType) {
+    List<String> allFormats = new ArrayList<>();
+    if (!cd.currentPatchSet().uploader().equals(a.accountId())) {
+      allFormats.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
+      allFormats.addAll(
+          getMagicLabelFormats(
+              a.label(), a.value(), labelType, ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
+    }
+    return allFormats;
+  }
+
   public static Set<String> getAuthorParts(ChangeData cd) {
     return SchemaUtil.getPersonParts(cd.getAuthor());
   }
@@ -727,25 +836,37 @@
                       decodeProtos(field, PatchSetApprovalProtoConverter.INSTANCE)));
 
   public static String formatLabel(String label, int value) {
-    return formatLabel(label, value, null);
+    return formatLabel(label, value, /* accountId= */ null, /* count= */ null);
+  }
+
+  public static String formatLabel(String label, int value, @Nullable Integer count) {
+    return formatLabel(label, value, /* accountId= */ null, count);
   }
 
   public static String formatLabel(String label, int value, Account.Id accountId) {
+    return formatLabel(label, value, accountId, /* count= */ null);
+  }
+
+  public static String formatLabel(
+      String label, int value, @Nullable Account.Id accountId, @Nullable Integer count) {
     return label.toLowerCase()
         + (value >= 0 ? "+" : "")
         + value
-        + (accountId != null ? "," + formatAccount(accountId) : "");
+        + (accountId != null ? "," + formatAccount(accountId) : "")
+        + (count != null ? ",count=" + count : "");
   }
 
-  public static String formatLabel(String label, String value) {
-    return formatLabel(label, value, null);
+  public static String formatLabel(String label, String value, @Nullable Integer count) {
+    return formatLabel(label, value, /* accountId= */ null, count);
   }
 
-  public static String formatLabel(String label, String value, @Nullable Account.Id accountId) {
+  public static String formatLabel(
+      String label, String value, @Nullable Account.Id accountId, @Nullable Integer count) {
     return label.toLowerCase()
         + "="
         + value
-        + (accountId != null ? "," + formatAccount(accountId) : "");
+        + (accountId != null ? "," + formatAccount(accountId) : "")
+        + (count != null ? ",count=" + count : "");
   }
 
   private static String formatAccount(Account.Id accountId) {
@@ -761,6 +882,11 @@
   public static final FieldDef<ChangeData, String> COMMIT_MESSAGE =
       fullText(ChangeQueryBuilder.FIELD_MESSAGE).build(ChangeData::commitMessage);
 
+  /** Commit message of the current patch set. */
+  public static final FieldDef<ChangeData, String> COMMIT_MESSAGE_EXACT =
+      exact(ChangeQueryBuilder.FIELD_MESSAGE_EXACT)
+          .build(cd -> truncateStringValueToMaxTermLength(cd.commitMessage()));
+
   /** Summary or inline comment. */
   public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
       fullText(ChangeQueryBuilder.FIELD_COMMENT)
@@ -1100,8 +1226,18 @@
   }
 
   public static List<String> formatSubmitRecordValues(ChangeData cd) {
-    return formatSubmitRecordValues(
-        cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner());
+    Set<String> submitRecordValues = new HashSet<>();
+    submitRecordValues.addAll(
+        formatSubmitRecordValues(
+            cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner()));
+    // Also backfill results of submit requirements such that users can query submit requirement
+    // results using the label operator, for example a query with "label:CR=NEED" will match with
+    // changes that have a submit-requirement with name="CR" and status=UNSATISFIED.
+    // Reason: We are preserving backward compatibility of the operators `label:$name=$status`
+    // which were previously working with submit records. Now admins can configure submit
+    // requirements and continue querying them with the label operator.
+    submitRecordValues.addAll(formatSubmitRequirementValues(cd.submitRequirements().values()));
+    return submitRecordValues.stream().collect(Collectors.toList());
   }
 
   @VisibleForTesting
@@ -1127,6 +1263,48 @@
     return result;
   }
 
+  /**
+   * Generate submit requirement result formats that are compatible with the legacy submit record
+   * statuses.
+   */
+  @VisibleForTesting
+  static List<String> formatSubmitRequirementValues(Collection<SubmitRequirementResult> srResults) {
+    List<String> result = new ArrayList<>();
+    for (SubmitRequirementResult srResult : srResults) {
+      switch (srResult.status()) {
+        case SATISFIED:
+        case OVERRIDDEN:
+        case FORCED:
+          result.add(
+              SubmitRecord.Label.Status.OK.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+          result.add(
+              SubmitRecord.Label.Status.MAY.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+          break;
+        case UNSATISFIED:
+          result.add(
+              SubmitRecord.Label.Status.NEED.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+          result.add(
+              SubmitRecord.Label.Status.REJECT.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+          break;
+        case NOT_APPLICABLE:
+        case ERROR:
+          result.add(
+              SubmitRecord.Label.Status.IMPOSSIBLE.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+      }
+    }
+    return result;
+  }
+
   /** Serialized submit requirements, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_REQUIREMENTS =
       storedOnly("full_submit_requirements")
@@ -1144,6 +1322,7 @@
                     SubmitRequirementProtoConverter.INSTANCE.fromProto(
                         Protos.parseUnchecked(
                             SubmitRequirementProtoConverter.INSTANCE.getParser(), f)))
+            .filter(sr -> !sr.isLegacy())
             .collect(
                 ImmutableMap.toImmutableMap(sr -> sr.submitRequirement(), Function.identity())));
   }
@@ -1227,4 +1406,46 @@
   private static AllUsersName allUsers(ChangeData cd) {
     return cd.getAllUsersNameForIndexing();
   }
+
+  private static String truncateStringValueToMaxTermLength(String str) {
+    return truncateStringValue(str, MAX_TERM_LENGTH);
+  }
+
+  @VisibleForTesting
+  static String truncateStringValue(String str, int maxBytes) {
+    if (maxBytes < 0) {
+      throw new IllegalArgumentException("maxBytes < 0 not allowed");
+    }
+
+    if (maxBytes == 0) {
+      return "";
+    }
+
+    if (str.length() > maxBytes) {
+      if (Character.isHighSurrogate(str.charAt(maxBytes - 1))) {
+        str = str.substring(0, maxBytes - 1);
+      } else {
+        str = str.substring(0, maxBytes);
+      }
+    }
+    byte[] strBytes = str.getBytes(UTF_8);
+    if (strBytes.length > maxBytes) {
+      while (maxBytes > 0 && (strBytes[maxBytes] & 0xC0) == 0x80) {
+        maxBytes -= 1;
+      }
+      if (maxBytes > 0) {
+        if (strBytes.length >= maxBytes && (strBytes[maxBytes - 1] & 0xE0) == 0xC0) {
+          maxBytes -= 1;
+        }
+        if (strBytes.length >= maxBytes && (strBytes[maxBytes - 1] & 0xF0) == 0xE0) {
+          maxBytes -= 1;
+        }
+        if (strBytes.length >= maxBytes && (strBytes[maxBytes - 1] & 0xF8) == 0xF0) {
+          maxBytes -= 1;
+        }
+      }
+      return new String(Arrays.copyOfRange(strBytes, 0, maxBytes), UTF_8);
+    }
+    return str;
+  }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 437c1c4..8b75872 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.IsSubmittablePredicate;
 import com.google.gerrit.server.query.change.OrSource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -186,6 +187,7 @@
   private Predicate<ChangeData> rewriteImpl(
       Predicate<ChangeData> in, ChangeIndex index, QueryOptions opts, MutableInteger leafTerms)
       throws QueryParseException {
+    in = IsSubmittablePredicate.rewrite(in);
     if (isIndexPredicate(in, index)) {
       if (++leafTerms.value > config.maxTerms()) {
         throw new TooManyTermsInQueryException();
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 9339d62..0a06735 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -183,9 +183,45 @@
       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.
+   */
+  @Deprecated
+  static final Schema<ChangeData> V75 =
+      new Schema.Builder<ChangeData>()
+          .add(V74)
+          .add(ChangeField.PREFIX_HASHTAG)
+          .add(ChangeField.PREFIX_TOPIC)
+          .build();
+
+  /** Added new field {@link ChangeField#FOOTER_NAME}. */
+  @Deprecated
+  static final Schema<ChangeData> V76 =
+      new Schema.Builder<ChangeData>().add(V75).add(ChangeField.FOOTER_NAME).build();
+
+  /** Added new field {@link ChangeField#COMMIT_MESSAGE_EXACT}. */
+  static final Schema<ChangeData> V77 =
+      new Schema.Builder<ChangeData>().add(V76).add(ChangeField.COMMIT_MESSAGE_EXACT).build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index 1d9bbcd..339d7bb 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -101,7 +101,7 @@
     final DataSource<ChangeData> currSource = source;
     final ResultSet<ChangeData> rs = currSource.read();
 
-    return new ResultSet<ChangeData>() {
+    return new ResultSet<>() {
       @Override
       public Iterator<ChangeData> iterator() {
         return Iterables.transform(
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/GroupField.java b/java/com/google/gerrit/server/index/group/GroupField.java
index df90c0d..af74514 100644
--- a/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -47,8 +47,9 @@
       exact("owner_uuid").build(g -> g.getOwnerGroupUUID().get());
 
   /** Timestamp indicating when this group was created. */
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
   public static final FieldDef<InternalGroup, Timestamp> CREATED_ON =
-      timestamp("created_on").build(InternalGroup::getCreatedOn);
+      timestamp("created_on").build(internalGroup -> Timestamp.from(internalGroup.getCreatedOn()));
 
   /** Group name. */
   public static final FieldDef<InternalGroup, String> NAME =
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
index ee0168c..7204c07 100644
--- a/java/com/google/gerrit/server/logging/BUILD
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -18,6 +18,5 @@
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/log:log4j",
     ],
 )
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 89b5b46..5cd0e98 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -165,6 +165,8 @@
   /** The name of a REST view. */
   public abstract Optional<String> restViewName();
 
+  public abstract Optional<String> submitRequirementName();
+
   /** The SHA1 of Git commit. */
   public abstract Optional<String> revision();
 
@@ -375,6 +377,8 @@
 
     public abstract Builder revision(@Nullable String revision);
 
+    public abstract Builder submitRequirementName(@Nullable String srName);
+
     public abstract Builder username(@Nullable String username);
 
     public abstract Metadata build();
diff --git a/java/com/google/gerrit/server/logging/RequestId.java b/java/com/google/gerrit/server/logging/RequestId.java
index 543f0a2..3ae9598 100644
--- a/java/com/google/gerrit/server/logging/RequestId.java
+++ b/java/com/google/gerrit/server/logging/RequestId.java
@@ -61,7 +61,7 @@
     h.putLong(Thread.currentThread().getId()).putUnencodedChars(MACHINE_ID);
     str =
         (resourceId != null ? resourceId + "-" : "")
-            + TimeUtil.nowTs().getTime()
+            + TimeUtil.now().toEpochMilli()
             + "-"
             + h.hash().toString().substring(0, 8);
   }
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 0710784..0fc89ba 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -301,7 +301,9 @@
               .collect(ImmutableList.toImmutableList());
       CommentValidationContext commentValidationCtx =
           CommentValidationContext.create(
-              cd.change().getChangeId(), cd.change().getProject().get());
+              cd.change().getChangeId(),
+              cd.change().getProject().get(),
+              cd.change().getDest().branch());
       ImmutableList<CommentValidationFailure> commentValidationFailures =
           PublishCommentUtil.findInvalidComments(
               commentValidationCtx, commentValidators, parsedCommentsForValidation);
@@ -311,7 +313,7 @@
       }
 
       Op o = new Op(PatchSet.id(cd.getId(), metadata.patchSet), parsedComments, message.id());
-      BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.nowTs());
+      BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.now());
       batchUpdate.addOp(cd.getId(), o);
       batchUpdate.execute();
     }
@@ -378,8 +380,7 @@
       // Get previous approvals from this user
       Map<String, Short> approvals = new HashMap<>();
       approvalsUtil
-          .byPatchSetUser(
-              notes, psId, ctx.getAccountId(), ctx.getRevWalk(), ctx.getRepoView().getConfig())
+          .byPatchSetUser(notes, psId, ctx.getAccountId())
           .forEach(a -> approvals.put(a.label(), a.value()));
       // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
       // are always the same here.
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 5c0132c..288ccf8 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -24,7 +24,9 @@
 import com.google.common.flogger.FluentLogger;
 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.ChangeSizeBucket;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -42,6 +44,7 @@
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.FilePathAdapter;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -51,15 +54,15 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.text.MessageFormat;
+import java.time.Instant;
 import java.util.Collection;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.stream.Collectors;
 import org.apache.james.mime4j.dom.field.FieldName;
@@ -72,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(
@@ -86,7 +90,7 @@
   protected PatchSet patchSet;
   protected PatchSetInfo patchSetInfo;
   protected String changeMessage;
-  protected Timestamp timestamp;
+  protected Instant timestamp;
 
   protected ProjectState projectState;
   protected Set<Account.Id> authors;
@@ -96,17 +100,17 @@
   protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
     super(args, messageClass, changeData.change().getDest());
     this.changeData = changeData;
-    this.change = changeData.change();
-    this.emailOnlyAuthors = false;
-    this.emailOnlyAttentionSetIfEnabled = true;
-    this.currentAttentionSet = getAttentionSet();
+    change = changeData.change();
+    emailOnlyAuthors = false;
+    emailOnlyAttentionSetIfEnabled = true;
+    currentAttentionSet = getAttentionSet();
   }
 
   @Override
   public void setFrom(Account.Id id) {
     super.setFrom(id);
 
-    /** Is the from user in an email squelching group? */
+    // Is the from user in an email squelching group?
     try {
       args.permissionBackend.absentUser(id).check(GlobalPermission.EMAIL_REVIEWERS);
     } catch (AuthException | PermissionBackendException e) {
@@ -123,7 +127,7 @@
     patchSetInfo = psi;
   }
 
-  public void setChangeMessage(String cm, Timestamp t) {
+  public void setChangeMessage(String cm, Instant t) {
     changeMessage = cm;
     timestamp = t;
   }
@@ -190,7 +194,7 @@
 
     super.init();
     if (timestamp != null) {
-      setHeader(FieldName.DATE, new Date(timestamp.getTime()));
+      setHeader(FieldName.DATE, timestamp);
     }
     setChangeSubjectHeader();
     setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
@@ -229,6 +233,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() {
@@ -240,11 +256,11 @@
 
   public String getChangeMessageThreadId() {
     return "<gerrit."
-        + change.getCreatedOn().getTime()
+        + change.getCreatedOn().toEpochMilli()
         + "."
         + change.getKey().get()
         + "@"
-        + this.getGerritHost()
+        + getGerritHost()
         + ">";
   }
 
@@ -269,7 +285,8 @@
 
       if (patchSet != null) {
         detail.append("---\n");
-        Map<String, FileDiffOutput> modifiedFiles = listModifiedFiles();
+        // Sort files by name.
+        TreeMap<String, FileDiffOutput> modifiedFiles = new TreeMap<>(listModifiedFiles());
         for (FileDiffOutput fileDiff : modifiedFiles.values()) {
           if (fileDiff.newPath().isPresent() && Patch.isMagic(fileDiff.newPath().get())) {
             continue;
@@ -282,10 +299,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(
                 "" //
@@ -294,8 +307,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();
@@ -306,29 +319,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);
   }
 
   /** 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);
+      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. */
@@ -504,6 +523,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<>();
@@ -517,7 +539,7 @@
     soyContext.put("patchSetInfo", patchSetInfoData);
 
     footers.add(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
-    footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + Integer.toString(change.getChangeId()));
+    footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
     footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
     footers.add(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
     if (change.getAssignee() != null) {
@@ -569,7 +591,7 @@
     try {
       attentionSet =
           additionsOnly(changeData.attentionSet()).stream()
-              .map(a -> a.account())
+              .map(AttentionSetUpdate::account)
               .collect(Collectors.toSet());
     } catch (StorageException e) {
       logger.atWarning().withCause(e).log("Cannot get change attention set");
@@ -586,16 +608,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;
@@ -605,6 +622,11 @@
         try {
           ObjectId oldId = modifiedFiles.values().iterator().next().oldCommitId();
           ObjectId newId = modifiedFiles.values().iterator().next().newCommitId();
+          if (oldId.equals(ObjectId.zeroId())) {
+            // DiffOperations returns ObjectId.zeroId if newCommit is a root commit, i.e. has no
+            // parents.
+            oldId = null;
+          }
           fmt.setRepository(git);
           fmt.setDetectRenames(true);
           fmt.format(oldId, newId);
@@ -631,7 +653,8 @@
    * @param sourceDiff the unified diff that we're converting to the map.
    * @return map of 'type' to a line's content.
    */
-  protected ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(String sourceDiff) {
+  protected static ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(
+      String sourceDiff) {
     ImmutableList.Builder<ImmutableMap<String, String>> result = ImmutableList.builder();
     Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
     for (String diffLine : lineSplitter.split(sourceDiff)) {
diff --git a/java/com/google/gerrit/server/mail/send/CommentFormatter.java b/java/com/google/gerrit/server/mail/send/CommentFormatter.java
index 2590505..71f4a90 100644
--- a/java/com/google/gerrit/server/mail/send/CommentFormatter.java
+++ b/java/com/google/gerrit/server/mail/send/CommentFormatter.java
@@ -17,9 +17,9 @@
 import static com.google.common.base.Strings.isNullOrEmpty;
 
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 
 public class CommentFormatter {
@@ -47,12 +47,12 @@
    * @param source The raw, unescaped comment in the Gerrit wiki-like format.
    * @return List of block objects, each with unescaped comment content.
    */
-  public static List<Block> parse(@Nullable String source) {
+  public static ImmutableList<Block> parse(@Nullable String source) {
     if (isNullOrEmpty(source)) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
-    List<Block> result = new ArrayList<>();
+    ImmutableList.Builder<Block> result = ImmutableList.builder();
     for (String p : Splitter.on("\n\n").split(source)) {
       if (isQuote(p)) {
         result.add(makeQuote(p));
@@ -64,7 +64,7 @@
         result.add(makeParagraph(p));
       }
     }
-    return result;
+    return result.build();
   }
 
   /**
@@ -91,7 +91,7 @@
    * @param p The block containing the list (as well as potential paragraphs).
    * @param out The list of blocks to append to.
    */
-  private static void makeList(String p, List<Block> out) {
+  private static void makeList(String p, ImmutableList.Builder<Block> out) {
     Block block = null;
     StringBuilder textBuilder = null;
     boolean inList = false;
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 5a7352a..ce5438b 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -37,7 +37,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;
@@ -60,13 +59,16 @@
 
 /** 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);
   }
 
   private class FileCommentGroup {
+
     public String filename;
     public int patchSetId;
     public PatchFile fileData;
@@ -199,12 +201,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()) {
@@ -555,6 +552,6 @@
   private String getCommentTimestamp() {
     // Grouping is currently done by timestamp.
     return MailProcessingUtil.rfcDateformatter.format(
-        ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.of("UTC")));
+        ZonedDateTime.ofInstant(timestamp, ZoneId.of("UTC")));
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
index 2ab73a8..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 -> addWatcher(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/MailSoySauceProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
similarity index 88%
rename from java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
rename to java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index aade30f..8ee8fc2 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
 import com.google.common.io.CharStreams;
-import com.google.common.io.Resources;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
 import com.google.template.soy.SoyFileSet;
@@ -27,13 +27,18 @@
 import com.google.template.soy.shared.SoyAstCache;
 import java.io.IOException;
 import java.io.Reader;
+import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 
-/** Configures Soy Sauce object for rendering email templates. */
+/**
+ * Configures and loads Soy Sauce object for rendering email templates.
+ *
+ * <p>It reloads templates each time when {@link #load()} is called.
+ */
 @Singleton
-public class MailSoySauceProvider implements Provider<SoySauce> {
+class MailSoySauceLoader {
 
   // Note: will fail to construct the tofu object if this array is empty.
   private static final String[] TEMPLATES = {
@@ -90,7 +95,7 @@
   private final PluginSetContext<MailSoyTemplateProvider> templateProviders;
 
   @Inject
-  MailSoySauceProvider(
+  MailSoySauceLoader(
       SitePaths site,
       SoyAstCache cache,
       PluginSetContext<MailSoyTemplateProvider> templateProviders) {
@@ -99,8 +104,7 @@
     this.templateProviders = templateProviders;
   }
 
-  @Override
-  public SoySauce get() throws ProvisionException {
+  public SoySauce load() {
     SoyFileSet.Builder builder = SoyFileSet.builder();
     builder.setSoyAstCache(cache);
     for (String name : TEMPLATES) {
@@ -135,6 +139,8 @@
     }
 
     // Otherwise load the template as a resource.
-    builder.add(Resources.getResource(logicalPath), logicalPath);
+    URL resource = this.getClass().getClassLoader().getResource(logicalPath);
+    checkArgument(resource != null, "resource %s not found.", logicalPath);
+    builder.add(resource, logicalPath);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
new file mode 100644
index 0000000..25b2ebd
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
@@ -0,0 +1,103 @@
+// 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;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.server.CacheRefreshExecutor;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import com.google.template.soy.jbcsrc.api.SoySauce;
+import java.time.Duration;
+import java.util.concurrent.ExecutionException;
+import javax.inject.Provider;
+
+/**
+ * Provides support for soy templates
+ *
+ * <p>Module loads templates with {@link MailSoySauceLoader} and caches compiled templates. The
+ * cache refreshes automatically, so Gerrit does not need to be restarted if templates are changed.
+ */
+public class MailSoySauceModule extends CacheModule {
+  static final String CACHE_NAME = "soy_sauce_compiled_templates";
+  private static final String SOY_LOADING_CACHE_KEY = "KEY";
+
+  @Override
+  protected void configure() {
+    // Cache stores only a single key-value pair (key is SOY_LOADING_CACHE_KEY). We are using
+    // cache only for it refresh/expire logic.
+    cache(CACHE_NAME, String.class, SoySauce.class)
+        // Cache refreshes a value only on the access (if refreshAfterWrite interval is
+        // passed). While the value is refreshed, cache returns old value.
+        // Adding expireAfterWrite interval prevents cache from returning very old template.
+        .refreshAfterWrite(Duration.ofSeconds(5))
+        .expireAfterWrite(Duration.ofMinutes(1))
+        .loader(SoySauceCacheLoader.class);
+    bind(SoySauce.class).annotatedWith(MailTemplates.class).toProvider(SoySauceProvider.class);
+  }
+
+  @Singleton
+  static class SoySauceProvider implements Provider<SoySauce> {
+    private final LoadingCache<String, SoySauce> templateCache;
+
+    @Inject
+    SoySauceProvider(@Named(CACHE_NAME) LoadingCache<String, SoySauce> templateCache) {
+      this.templateCache = templateCache;
+    }
+
+    @Override
+    public SoySauce get() {
+      try {
+        return templateCache.get(SOY_LOADING_CACHE_KEY);
+      } catch (ExecutionException e) {
+        throw new ProvisionException("Can't get SoySauce from the cache", e);
+      }
+    }
+  }
+
+  @Singleton
+  static class SoySauceCacheLoader extends CacheLoader<String, SoySauce> {
+    private final ListeningExecutorService executor;
+    private final MailSoySauceLoader loader;
+
+    @Inject
+    SoySauceCacheLoader(
+        @CacheRefreshExecutor ListeningExecutorService executor, MailSoySauceLoader loader) {
+      this.executor = executor;
+      this.loader = loader;
+    }
+
+    @Override
+    public SoySauce load(String key) throws Exception {
+      checkArgument(
+          SOY_LOADING_CACHE_KEY.equals(key),
+          "Cache can have only one element with a key '%s'",
+          SOY_LOADING_CACHE_KEY);
+      return loader.load();
+    }
+
+    @Override
+    public ListenableFuture<SoySauce> reload(String key, SoySauce soySauce) {
+      return executor.submit(() -> loader.load());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index 91310ce..5b209ce 100644
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -80,11 +81,11 @@
   protected abstract Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig);
 
   /** Add users or email addresses to the TO, CC, or BCC list. */
-  protected void add(RecipientType type, Watchers.List list) {
-    for (Account.Id user : list.accounts) {
+  protected void add(RecipientType type, WatcherList watcherList) {
+    for (Account.Id user : watcherList.accounts) {
       addWatcher(type, user);
     }
-    for (Address addr : list.emails) {
+    for (Address addr : watcherList.emails) {
       add(type, addr);
     }
   }
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 286f0c7..bfc1f5b 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -42,9 +42,9 @@
 import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.net.MalformedURLException;
 import java.net.URL;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -67,6 +67,7 @@
   private final Set<Account.Id> rcptTo = new HashSet<>();
   private final Map<String, EmailHeader> headers;
   private final Set<Address> smtpRcptTo = new HashSet<>();
+  private final Set<Address> smtpBccRcptTo = new HashSet<>();
   private Address smtpFromAddress;
   private StringBuilder textBody;
   private StringBuilder htmlBody;
@@ -228,8 +229,13 @@
             j.add(address.email());
           }
         }
-        smtpRcptTo.stream().forEach(a -> j.add(a.email()));
-        smtpRcptToPlaintextOnly.stream().forEach(a -> j.add(a.email()));
+        // For users who prefer plaintext, this comes at the cost of not being
+        // listed in the multipart To and Cc headers. We work around this by adding
+        // all users to the Reply-To address in both the plaintext and multipart
+        // email. We should exclude any BCC addresses from reply-to, because they should be
+        // invisible to other recipients.
+        Sets.difference(Sets.union(smtpRcptTo, smtpRcptToPlaintextOnly), smtpBccRcptTo).stream()
+            .forEach(a -> j.add(a.email()));
         setHeader(FieldName.REPLY_TO, j.toString());
       }
 
@@ -318,7 +324,7 @@
     setupSoyContext();
 
     smtpFromAddress = args.fromAddressGenerator.get().from(fromId);
-    setHeader(FieldName.DATE, new Date());
+    setHeader(FieldName.DATE, Instant.now());
     headers.put(FieldName.FROM, new EmailHeader.AddressList(smtpFromAddress));
     headers.put(FieldName.TO, new EmailHeader.AddressList());
     headers.put(FieldName.CC, new EmailHeader.AddressList());
@@ -392,7 +398,7 @@
     headers.remove(name);
   }
 
-  protected void setHeader(String name, Date date) {
+  protected void setHeader(String name, Instant date) {
     headers.put(name, new EmailHeader.Date(date));
   }
 
@@ -548,6 +554,8 @@
    * Returns whether this email is visible to the given account
    *
    * @param to account.
+   * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
+   *     permission backend
    */
   protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
     return true;
@@ -569,6 +577,7 @@
           }
           ((EmailHeader.AddressList) headers.get(FieldName.TO)).remove(addr.email());
           ((EmailHeader.AddressList) headers.get(FieldName.CC)).remove(addr.email());
+          smtpBccRcptTo.remove(addr);
         }
         switch (rt) {
           case TO:
@@ -578,6 +587,7 @@
             ((EmailHeader.AddressList) headers.get(FieldName.CC)).add(addr);
             break;
           case BCC:
+            smtpBccRcptTo.add(addr);
             break;
         }
       }
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 173b121..9299d74 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -111,13 +112,13 @@
   }
 
   public static class Watchers {
-    static class List {
+    static class WatcherList {
       protected final Set<Account.Id> accounts = new HashSet<>();
       protected final Set<Address> emails = new HashSet<>();
 
-      private static List union(List... others) {
-        List union = new List();
-        for (List other : others) {
+      private static WatcherList union(WatcherList... others) {
+        WatcherList union = new WatcherList();
+        for (WatcherList other : others) {
           union.accounts.addAll(other.accounts);
           union.emails.addAll(other.emails);
         }
@@ -125,15 +126,15 @@
       }
     }
 
-    protected final List to = new List();
-    protected final List cc = new List();
-    protected final List bcc = new List();
+    protected final WatcherList to = new WatcherList();
+    protected final WatcherList cc = new WatcherList();
+    protected final WatcherList bcc = new WatcherList();
 
-    List all() {
-      return List.union(to, cc, bcc);
+    WatcherList all() {
+      return WatcherList.union(to, cc, bcc);
     }
 
-    List list(NotifyConfig.Header header) {
+    WatcherList list(NotifyConfig.Header header) {
       switch (header) {
         case TO:
           return to;
@@ -171,7 +172,7 @@
     }
   }
 
-  private void deliverToMembers(Watchers.List matching, AccountGroup.UUID startUUID) {
+  private void deliverToMembers(WatcherList matching, AccountGroup.UUID startUUID) {
     Set<AccountGroup.UUID> seen = new HashSet<>();
     List<AccountGroup.UUID> q = new ArrayList<>();
 
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 2c65789..c06cc1e 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -36,10 +36,11 @@
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.Writer;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Date;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -281,9 +282,11 @@
       setMissingHeader(hdrs, "Importance", importance);
     }
     if (expiryDays > 0) {
-      Date expiry = new Date(TimeUtil.nowMs() + expiryDays * 24 * 60 * 60 * 1000L);
-      setMissingHeader(
-          hdrs, "Expiry-Date", new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z").format(expiry));
+      Instant expiry = Instant.ofEpochMilli(TimeUtil.nowMs() + expiryDays * 24 * 60 * 60 * 1000L);
+      DateTimeFormatter fmt =
+          DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z")
+              .withZone(ZoneId.systemDefault());
+      setMissingHeader(hdrs, "Expiry-Date", fmt.format(expiry));
     }
 
     String encodedBody;
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 6677490..e6f1622 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import java.io.IOException;
-import java.util.Date;
+import java.time.Instant;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -45,7 +45,7 @@
   protected final Account.Id accountId;
   protected final Account.Id realAccountId;
   protected final PersonIdent authorIdent;
-  protected final Date when;
+  protected final Instant when;
 
   @Nullable private final ChangeNotes notes;
   private final Change change;
@@ -60,7 +60,7 @@
       CurrentUser user,
       PersonIdent serverIdent,
       ChangeNoteUtil noteUtil,
-      Date when) {
+      Instant when) {
     this.noteUtil = noteUtil;
     this.serverIdent = new PersonIdent(serverIdent, when);
     this.notes = notes;
@@ -80,7 +80,7 @@
       Account.Id accountId,
       Account.Id realAccountId,
       PersonIdent authorIdent,
-      Date when) {
+      Instant when) {
     checkArgument(
         (notes != null && change == null) || (notes == null && change != null),
         "exactly one of notes or change required");
@@ -107,7 +107,7 @@
   }
 
   private static PersonIdent ident(
-      ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Date when) {
+      ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Instant when) {
     checkUserType(u);
     if (u instanceof IdentifiedUser) {
       return noteUtil.newAccountIdIdent(u.asIdentifiedUser().getAccount().id(), when, serverIdent);
@@ -137,7 +137,7 @@
     return change;
   }
 
-  public Date getWhen() {
+  public Instant getWhen() {
     return when;
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 95298d2..73161d7 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -31,9 +31,9 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -60,14 +60,14 @@
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
         PersonIdent authorIdent,
-        Date when);
+        Instant when);
 
     ChangeDraftUpdate create(
         Change change,
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
         PersonIdent authorIdent,
-        Date when);
+        Instant when);
   }
 
   @AutoValue
@@ -102,7 +102,7 @@
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
+      @Assisted Instant when) {
     super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
   }
@@ -116,7 +116,7 @@
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
+      @Assisted Instant when) {
     super(noteUtil, serverIdent, null, change, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 4c41a12..0f1d362c 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -17,8 +17,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.EntitiesAdapterFactory;
 import com.google.gerrit.json.EnumTypeAdapterFactory;
+import com.google.gerrit.json.OptionalSubmitRequirementExpressionResultAdapterFactory;
+import com.google.gerrit.json.OptionalTypeAdapter;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
 import com.google.gson.TypeAdapter;
 import com.google.gson.stream.JsonReader;
 import com.google.gson.stream.JsonWriter;
@@ -26,19 +31,40 @@
 import com.google.inject.TypeLiteral;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
+/**
+ * Provides {@link Gson} to parse {@link ChangeRevisionNote}, attached to the change update.
+ *
+ * <p>Apart from the adapters for the custom JSON format, this class also registers adapters that
+ * support forward/backward compatibility when modifying {@link ChangeNotes} storage format.
+ *
+ * <p>NOTE: All changes to the storage format must be both forward and backward compatible, see
+ * comment on {@link ChangeNotesParser}.
+ *
+ * <p>For JSON, such changes include e.g. modifications to the serialized {@code AutoValue} classes.
+ */
 @Singleton
 public class ChangeNoteJson {
   private final Gson gson = newGson();
 
   static Gson newGson() {
     return new GsonBuilder()
+        .registerTypeAdapter(Optional.class, new OptionalTypeAdapter())
         .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe())
         .registerTypeAdapterFactory(new EnumTypeAdapterFactory())
         .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
         .registerTypeAdapter(
             new TypeLiteral<ImmutableList<String>>() {}.getType(),
             new ImmutableListAdapter().nullSafe())
+        .registerTypeAdapter(
+            new TypeLiteral<Optional<Boolean>>() {}.getType(),
+            new OptionalBooleanAdapter().nullSafe())
+        .registerTypeAdapterFactory(new OptionalSubmitRequirementExpressionResultAdapterFactory())
+        .registerTypeAdapter(ObjectId.class, new ObjectIdAdapter())
         .setPrettyPrinting()
         .create();
   }
@@ -47,6 +73,69 @@
     return gson;
   }
 
+  static class OptionalBooleanAdapter extends TypeAdapter<Optional<Boolean>> {
+    @Override
+    public void write(JsonWriter out, Optional<Boolean> value) throws IOException {
+      // Serialize the field using the same format used by the AutoValue's default Gson serializer.
+      out.beginObject();
+      out.name("value");
+      if (value.isPresent()) {
+        out.value(value.get());
+      } else {
+        out.nullValue();
+      }
+      out.endObject();
+    }
+
+    @Override
+    public Optional<Boolean> read(JsonReader in) throws IOException {
+      JsonElement parsed = JsonParser.parseReader(in);
+      if (parsed == null) {
+        return Optional.empty();
+      }
+      if (parsed.isJsonObject()) {
+        // If it's not a JSON object, then the boolean value is available directly in the Json
+        // element.
+        parsed = parsed.getAsJsonObject().get("value");
+      }
+      if (parsed == null || parsed.isJsonNull()) {
+        return Optional.empty();
+      }
+      return Optional.of(parsed.getAsBoolean());
+    }
+  }
+
+  /** Json serializer for the {@link ObjectId} class. */
+  static class ObjectIdAdapter extends TypeAdapter<ObjectId> {
+    private static final List<String> legacyFields = Arrays.asList("w1", "w2", "w3", "w4", "w5");
+
+    @Override
+    public void write(JsonWriter out, ObjectId value) throws IOException {
+      out.value(value.name());
+    }
+
+    @Override
+    public ObjectId read(JsonReader in) throws IOException {
+      JsonElement parsed = JsonParser.parseReader(in);
+      if (parsed.isJsonObject() && isJGitFormat(parsed)) {
+        // Some object IDs may have been serialized using the JGit format using the five integers
+        // w1, w2, w3, w4, w5. Detect this case so that we can deserialize properly.
+        int[] raw =
+            legacyFields.stream()
+                .mapToInt(field -> parsed.getAsJsonObject().get(field).getAsInt())
+                .toArray();
+        return ObjectId.fromRaw(raw);
+      }
+      return ObjectId.fromString(parsed.getAsString());
+    }
+
+    /** Return true if the json element contains the JGit serialized format of the Object ID. */
+    private boolean isJGitFormat(JsonElement elem) {
+      JsonObject asObj = elem.getAsJsonObject();
+      return legacyFields.stream().allMatch(field -> asObj.has(field));
+    }
+  }
+
   static class ImmutableListAdapter extends TypeAdapter<ImmutableList<String>> {
 
     @Override
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 28ab711..6d04791 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.json.OutputFormat;
@@ -22,8 +24,10 @@
 import com.google.gson.Gson;
 import com.google.inject.Inject;
 import java.time.Instant;
-import java.util.Date;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -31,33 +35,35 @@
 
 public class ChangeNoteUtil {
 
-  static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention");
-  static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
-  static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
-  static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
-  static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
-  static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
-  static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
-  static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
-  static final FooterKey FOOTER_LABEL = new FooterKey("Label");
-  static final FooterKey FOOTER_COPIED_LABEL = new FooterKey("Copied-Label");
-  static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
-  static final FooterKey FOOTER_PATCH_SET_DESCRIPTION = new FooterKey("Patch-set-description");
-  static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
-  static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
-  static final FooterKey FOOTER_STATUS = new FooterKey("Status");
-  static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
-  static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
-  static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
-  static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
-  static final FooterKey FOOTER_TAG = new FooterKey("Tag");
-  static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
-  static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
-  static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
+  public static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention");
+  public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
+  public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
+  public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
+  public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
+  public static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
+  public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
+  public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
+  public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
+  public static final FooterKey FOOTER_COPIED_LABEL = new FooterKey("Copied-Label");
+  public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
+  public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
+      new FooterKey("Patch-set-description");
+  public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
+  public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
+  public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
+  public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
+  public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
+  public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
+  public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
+  public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
+  public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
+  public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
+  public static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
 
   static final String GERRIT_USER_TEMPLATE = "Gerrit User %d";
 
   private static final Gson gson = OutputFormat.JSON_COMPACT.newGson();
+  private static final String LABEL_VOTE_UUID_SEPARATOR = ", ";
 
   private final ChangeNoteJson changeNoteJson;
   private final String serverId;
@@ -95,12 +101,13 @@
    * Returns a {@link PersonIdent} that contains the account ID, but not the user's name or email
    * address.
    */
-  public PersonIdent newAccountIdIdent(Account.Id accountId, Date when, PersonIdent serverIdent) {
+  public PersonIdent newAccountIdIdent(
+      Account.Id accountId, Instant when, PersonIdent serverIdent) {
     return new PersonIdent(
         getAccountIdAsUsername(accountId),
         getAccountIdAsEmailAddress(accountId),
         when,
-        serverIdent.getTimeZone());
+        serverIdent.getZoneId());
   }
 
   /** Returns the string {@code "Gerrit User " + accountId}, to pseudonymize user names. */
@@ -245,4 +252,294 @@
         new AttentionStatusInNoteDb(
             stringBuilder.toString(), attentionSetUpdate.operation(), attentionSetUpdate.reason()));
   }
+
+  /**
+   * {@link com.google.gerrit.entities.PatchSetApproval}, parsed from {@link #FOOTER_LABEL} or
+   * {@link #FOOTER_COPIED_LABEL}.
+   *
+   * <p>In comparison to {@link com.google.gerrit.entities.PatchSetApproval}, this entity represent
+   * the raw fields, parsed from the NoteDB footer line, without any interpretation of the parsed
+   * values. See {@link #parseApproval} and {@link #parseCopiedApproval} for the valid {@link
+   * #footerLine} values.
+   */
+  @AutoValue
+  public abstract static class ParsedPatchSetApproval {
+
+    /** The original footer value, that this entity was parsed from. */
+    public abstract String footerLine();
+
+    public abstract boolean isRemoval();
+
+    /** Either <LABEL>=VOTE or <LABEL> for {@link #isRemoval}. */
+    public abstract String labelVote();
+
+    public abstract Optional<String> uuid();
+
+    public abstract Optional<String> accountIdent();
+
+    public abstract Optional<String> realAccountIdent();
+
+    public abstract Optional<String> tag();
+
+    public static Builder builder() {
+      return new AutoValue_ChangeNoteUtil_ParsedPatchSetApproval.Builder();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+      abstract Builder footerLine(String labelLine);
+
+      abstract Builder isRemoval(boolean isRemoval);
+
+      abstract Builder labelVote(String labelVote);
+
+      abstract Builder uuid(Optional<String> uuid);
+
+      abstract Builder accountIdent(Optional<String> accountIdent);
+
+      abstract Builder realAccountIdent(Optional<String> realAccountIdent);
+
+      abstract Builder tag(Optional<String> tag);
+
+      abstract ParsedPatchSetApproval build();
+    }
+  }
+
+  /**
+   * Delegates parsing of {@link ParsedPatchSetApproval} from {@link #FOOTER_LABEL} line to
+   * dedicated methods: {@link #parseAddedApproval} and {@link #parseRemovedApproval}
+   * correspondingly.
+   */
+  public static ParsedPatchSetApproval parseApproval(String footerLine)
+      throws ConfigInvalidException {
+    try {
+      return footerLine.startsWith("-")
+          ? parseRemovedApproval(footerLine)
+          : parseAddedApproval(footerLine);
+    } catch (StringIndexOutOfBoundsException ex) {
+      throw parseException(FOOTER_LABEL, footerLine, ex);
+    }
+  }
+
+  /**
+   * Parses added {@link ParsedPatchSetApproval} from {@link #FOOTER_LABEL} line.
+   *
+   * <p>Valid added approval footer examples:
+   *
+   * <ul>
+   *   <li>Label: &lt;LABEL&gt;=VOTE
+   *   <li>Label: &lt;LABEL&gt;=VOTE &lt;Gerrit Account&gt;
+   *   <li>Label: &lt;LABEL&gt;=VOTE, &lt;UUID&gt;
+   *   <li>Label: &lt;LABEL&gt;=VOTE, &lt;UUID&gt; &lt;Gerrit Account&gt;
+   * </ul>
+   *
+   * <p>&lt;UUID&gt; is optional, since the approval might have been granted before {@link
+   * com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
+   *
+   * <p><Gerrit Account> is only persisted in cases, when the account, that granted the vote does
+   * not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit).
+   */
+  private static ParsedPatchSetApproval parseAddedApproval(String footerLine)
+      throws ConfigInvalidException {
+    ParsedPatchSetApproval.Builder rawPatchSetApproval =
+        ParsedPatchSetApproval.builder().footerLine(footerLine);
+    rawPatchSetApproval.isRemoval(false);
+    // We need some additional logic to differentiate between labels that have a UUID and those that
+    // have a user with a comma. This allows us to separate the following cases (note that the
+    // leading `Label: ` has been elided at this point):
+    //   Label: <LABEL>=VOTE, <UUID> <Gerrit Account>
+    //   Label: <LABEL>=VOTE <Gerrit, Account>
+    int reviewerStartOffset = 0;
+    int scoreStart = footerLine.indexOf('=') + 1;
+    StringBuilder labelNameScore = new StringBuilder(footerLine.substring(0, scoreStart));
+    for (int i = scoreStart; i < footerLine.length(); i++) {
+      char currentChar = footerLine.charAt(i);
+
+      // If we hit ',' before ' ' we have a UUID
+      if (currentChar == ',') {
+        labelNameScore.append(footerLine, scoreStart, i);
+        int uuidStart = i + LABEL_VOTE_UUID_SEPARATOR.length();
+        int uuidEnd = footerLine.indexOf(' ', uuidStart);
+        String uuid = footerLine.substring(uuidStart, uuidEnd > 0 ? uuidEnd : footerLine.length());
+        checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_LABEL, footerLine);
+        rawPatchSetApproval.uuid(Optional.of(uuid));
+        reviewerStartOffset = uuidStart + uuid.length();
+        break;
+      }
+
+      // Otherwise we don't
+      if (currentChar == ' ') {
+        labelNameScore.append(footerLine, scoreStart, i);
+        break;
+      }
+
+      // If we hit neither we're defensive assign the whole line
+      if (i == footerLine.length() - 1) {
+        labelNameScore = new StringBuilder(footerLine);
+        break;
+      }
+    }
+
+    rawPatchSetApproval.labelVote(labelNameScore.toString());
+
+    int reviewerStart = footerLine.indexOf(' ', reviewerStartOffset);
+    if (reviewerStart > 0) {
+      String ident = footerLine.substring(reviewerStart + 1);
+      rawPatchSetApproval.accountIdent(Optional.of(ident));
+    }
+    return rawPatchSetApproval.build();
+  }
+
+  /**
+   * Parses removed {@link ParsedPatchSetApproval} from {@link #FOOTER_LABEL} line.
+   *
+   * <p>Valid removed approval footer examples:
+   *
+   * <ul>
+   *   <li>-&lt;LABEL&gt;
+   *   <li>-&lt;LABEL&gt; &lt;Gerrit Account&gt;
+   * </ul>
+   *
+   * <p>&lt;Gerrit Account&gt; is only persisted in cases, when the account, that granted the vote
+   * does not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit).
+   */
+  private static ParsedPatchSetApproval parseRemovedApproval(String footerLine) {
+    ParsedPatchSetApproval.Builder rawPatchSetApproval =
+        ParsedPatchSetApproval.builder().footerLine(footerLine);
+    rawPatchSetApproval.isRemoval(true);
+    int labelStart = 1;
+    int reviewerStart = footerLine.indexOf(' ', labelStart);
+
+    rawPatchSetApproval.labelVote(
+        reviewerStart != -1
+            ? footerLine.substring(labelStart, reviewerStart)
+            : footerLine.substring(labelStart));
+
+    if (reviewerStart > 0) {
+      String ident = footerLine.substring(reviewerStart + 1);
+      rawPatchSetApproval.accountIdent(Optional.of(ident));
+    }
+    return rawPatchSetApproval.build();
+  }
+
+  /**
+   * Parses copied {@link ParsedPatchSetApproval} from {@link #FOOTER_COPIED_LABEL} line.
+   *
+   * <p>Footer example: Copied-Label: <LABEL>=VOTE, <UUID> <Gerrit Account>,<Gerrit Real Account>
+   * :"<TAG>"
+   *
+   * <ul>
+   *   <li>":<"TAG>"" is optional.
+   *   <li><Gerrit Real Account> is also optional, if it was not set.
+   *   <li><UUID> is optional, since the approval might have been granted before {@link
+   *       com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
+   *   <li>The label, vote, and the Gerrit account are mandatory (unlike FOOTER_LABEL where Gerrit
+   *       Account is also optional since by default it's the committer).
+   * </ul>
+   */
+  public static ParsedPatchSetApproval parseCopiedApproval(String labelLine)
+      throws ConfigInvalidException {
+    try {
+      // Copied approvals can't be explicitly removed. They are removed the same way as non-copied
+      // approvals.
+      checkFooter(!labelLine.startsWith("-"), FOOTER_COPIED_LABEL, labelLine);
+      ParsedPatchSetApproval.Builder rawPatchSetApproval =
+          ParsedPatchSetApproval.builder().footerLine(labelLine).isRemoval(false);
+
+      int tagStart = labelLine.indexOf(":\"");
+      int uuidStart = parseCopiedApprovalUuidStart(labelLine, tagStart);
+
+      // Weird tag that contains uuid delimiter. The uuid is actually not present.
+      if (tagStart != -1 && uuidStart > tagStart) {
+        uuidStart = -1;
+      }
+
+      int identitiesStart =
+          labelLine.indexOf(
+              ' ', uuidStart != -1 ? uuidStart + LABEL_VOTE_UUID_SEPARATOR.length() : 0);
+      checkFooter(
+          identitiesStart != -1 && identitiesStart < labelLine.length(),
+          FOOTER_COPIED_LABEL,
+          labelLine);
+
+      String labelVoteStr = labelLine.substring(0, uuidStart != -1 ? uuidStart : identitiesStart);
+      rawPatchSetApproval.labelVote(labelVoteStr);
+      if (uuidStart != -1) {
+        String uuid = labelLine.substring(uuidStart + 2, identitiesStart);
+        checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_COPIED_LABEL, labelLine);
+        rawPatchSetApproval.uuid(Optional.of(uuid));
+      }
+      // The first account is the accountId, and second (if applicable) is the realAccountId.
+      List<String> identities =
+          parseIdentities(
+              labelLine.substring(
+                  identitiesStart + 1, tagStart == -1 ? labelLine.length() : tagStart));
+      checkFooter(identities.size() >= 1, FOOTER_COPIED_LABEL, labelLine);
+
+      rawPatchSetApproval.accountIdent(Optional.of(identities.get(0)));
+
+      if (identities.size() > 1) {
+        rawPatchSetApproval.realAccountIdent(Optional.of(identities.get(1)));
+      }
+
+      if (tagStart != -1) {
+        // tagStart+2 skips ":\"" to parse the actual tag. Tags are in brackets.
+        // line.length()-1 skips the last ".
+        String tag = labelLine.substring(tagStart + 2, labelLine.length() - 1);
+        rawPatchSetApproval.tag(Optional.of(tag));
+      }
+      return rawPatchSetApproval.build();
+    } catch (StringIndexOutOfBoundsException ex) {
+      throw parseException(FOOTER_COPIED_LABEL, labelLine, ex);
+    }
+  }
+
+  // Return the UUID start index or -1 if no UUID is present
+  private static int parseCopiedApprovalUuidStart(String line, int tagStart) {
+    int separatorIndex = line.indexOf(LABEL_VOTE_UUID_SEPARATOR);
+
+    // The first part of the condition checks whether the footer has the following format:
+    //   Copied-Label: <LABEL>=VOTE <Gerrit Account>,<Gerrit Real Account> :"<TAG>"
+    //   Weird tag that contains uuid delimiter. The uuid is actually not present.
+    if ((tagStart != -1 && separatorIndex > tagStart)
+        ||
+
+        // The second part of the condition allows us to distinguish the following two lines:
+        //   Label2=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>
+        //   Label2=+1 User Name (company_name, department) <2@gerrit>
+        (line.indexOf(' ') < separatorIndex)) {
+      return -1;
+    }
+    return separatorIndex;
+  }
+
+  // Splitting on "," breaks for identities containing commas. The below re-implements splitting on
+  // "(?<=>),", but it's 3-5x faster, as performance matters here.
+  private static List<String> parseIdentities(String line) {
+    List<String> idents = Splitter.on(',').splitToList(line);
+    List<String> identitiesList = new ArrayList<>();
+    for (int i = 0; i < idents.size(); i++) {
+      if (i == 0 || idents.get(i - 1).endsWith(">")) {
+        identitiesList.add(idents.get(i));
+      } else {
+        int lastIndex = identitiesList.size() - 1;
+        identitiesList.set(lastIndex, identitiesList.get(lastIndex) + "," + idents.get(i));
+      }
+    }
+    return identitiesList;
+  }
+
+  private static void checkFooter(boolean expr, FooterKey footer, String actual)
+      throws ConfigInvalidException {
+    if (!expr) {
+      throw parseException(footer, actual, /*cause=*/ null);
+    }
+  }
+
+  private static ConfigInvalidException parseException(
+      FooterKey footer, String actual, Throwable cause) {
+    return new ConfigInvalidException(
+        String.format("invalid %s: %s", footer.getName(), actual), cause);
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 0b1bf7f..b7abb93 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -32,7 +32,6 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
 import com.google.common.flogger.FluentLogger;
@@ -65,7 +64,7 @@
 import com.google.inject.Provider;
 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;
@@ -272,8 +271,8 @@
 
     public ListMultimap<Project.NameKey, ChangeNotes> create(Predicate<ChangeNotes> predicate)
         throws IOException {
-      ListMultimap<Project.NameKey, ChangeNotes> m =
-          MultimapBuilder.hashKeys().arrayListValues().build();
+      ImmutableListMultimap.Builder<Project.NameKey, ChangeNotes> m =
+          ImmutableListMultimap.builder();
       for (Project.NameKey project : projectCache.all()) {
         try (Repository repo = args.repoManager.openRepository(project)) {
           scan(repo, project)
@@ -283,7 +282,7 @@
               .forEach(n -> m.put(n.getProjectName(), n));
         }
       }
-      return ImmutableListMultimap.copyOf(m);
+      return m.build();
     }
 
     public Stream<ChangeNotesResult> scan(Repository repo, Project.NameKey project)
@@ -439,13 +438,7 @@
     return approvals;
   }
 
-  /**
-   * This method is currently used only in tests. TODO(paiking): Use this method to fetch approvals
-   * (including copied approvals) instead of computing copied approvals on demand. This will be used
-   * by {@code ApprovalCache}.
-   *
-   * @return all approvals, including copied approvals.
-   */
+  /** Gets all approvals, including copied approvals. */
   public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovalsWithCopied() {
     if (approvalsWithCopied == null) {
       approvalsWithCopied = ImmutableListMultimap.copyOf(state.approvals());
@@ -488,11 +481,19 @@
 
   /**
    * Returns the evaluated submit requirements for the change. We only intend to store submit
-   * requirements in NoteDb for closed changes, hence the result will be an empty list for active
-   * changes, or a list of submit requirements results otherwise. For closed changes, the results
-   * represent the state of evaluating submit requirements for this change when it was merged.
+   * requirements in NoteDb for closed changes. For closed changes, the results represent the state
+   * of evaluating submit requirements for this change when it was merged or abandoned.
+   *
+   * @throws UnsupportedOperationException if submit requirements are requested for an open change.
    */
   public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
+    if (state.columns().status().isOpen()) {
+      throw new UnsupportedOperationException(
+          String.format(
+              "Cannot request stored submit requirements"
+                  + " for an open change: project = %s, change ID = %d",
+              getProjectName(), state.changeId().get()));
+    }
     return state.submitRequirementsResult();
   }
 
@@ -560,7 +561,7 @@
   }
 
   /** Returns {@link Optional} value of time when the change was merged. */
-  public Optional<Timestamp> getMergedOn() {
+  public Optional<Instant> getMergedOn() {
     return Optional.ofNullable(state.mergedOn());
   }
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index c554ca5..40bf6e5 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -61,7 +61,7 @@
             .weigher(Weigher.class)
             .maximumWeight(10 << 20)
             .diskLimit(-1)
-            .version(2)
+            .version(4)
             .keySerializer(Key.Serializer.INSTANCE)
             .valueSerializer(ChangeNotesState.Serializer.INSTANCE);
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 0fc6324..6700f25 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -40,11 +40,13 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
 import static java.util.Comparator.comparing;
+import static java.util.Comparator.comparingInt;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
 import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.ListMultimap;
@@ -70,12 +72,14 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Label.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.notedb.ChangeNoteUtil.ParsedPatchSetApproval;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.LabelVote;
 import java.io.IOException;
@@ -83,6 +87,7 @@
 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;
@@ -95,6 +100,7 @@
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -104,10 +110,36 @@
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.util.RawParseUtils;
 
+/**
+ * Parses {@link ChangeNotesState} out of the change meta ref.
+ *
+ * <p>NOTE: all changes to the change notes storage format must be both forward and backward
+ * compatible, i.e.:
+ *
+ * <ul>
+ *   <li>The server, running the new binary version must be able to parse the data, written by the
+ *       previous binary version.
+ *   <li>The server, running the old binary version must be able to parse the data, written by the
+ *       new binary version.
+ * </ul>
+ *
+ * <p>Thus, when introducing storage format update, the following procedure must be used:
+ *
+ * <ol>
+ *   <li>The read path ({@link ChangeNotesParser}) needs to be updated to handle both the old and
+ *       the new data format.
+ *   <li>In a separate change, the write path (e.g. {@link ChangeUpdate}, {@link ChangeNoteJson}) is
+ *       updated to write the new format, guarded by {@link
+ *       com.google.gerrit.server.experiments.ExperimentFeatures} flag, if possible.
+ *   <li>Once the 'read' change is roll out and is roll back safe, the 'write' change can be
+ *       submitted/the experiment flag can be flipped.
+ * </ol>
+ */
 class ChangeNotesParser {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final String LABEL_VOTE_UUID_SEPARATOR = ", ";
+  private static final Splitter RULE_SPLITTER = Splitter.on(": ");
+  private static final Splitter HASHTAG_SPLITTER = Splitter.on(",");
 
   // Private final members initialized in the constructor.
   private final ChangeNoteJson changeNoteJson;
@@ -118,8 +150,8 @@
 
   // Private final but mutable members initialized in the constructor and filled
   // in during the parsing process.
-  private final Table<Account.Id, ReviewerStateInternal, Timestamp> reviewers;
-  private final Table<Address, ReviewerStateInternal, Timestamp> reviewersByEmail;
+  private final Table<Account.Id, ReviewerStateInternal, Instant> reviewers;
+  private final Table<Address, ReviewerStateInternal, Instant> reviewersByEmail;
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   /** Holds only the most recent update per user. Older updates are discarded. */
@@ -144,8 +176,8 @@
   private Change.Status status;
   private String topic;
   private Set<String> hashtags;
-  private Timestamp createdOn;
-  private Timestamp lastUpdatedOn;
+  private Instant createdOn;
+  private Instant lastUpdatedOn;
   private Account.Id ownerId;
   private String serverId;
   private String changeId;
@@ -166,7 +198,7 @@
   // We only set the value once, based on the latest update (the actual value or Optional.empty() if
   // the latest record unsets the field).
   private Optional<PatchSet.Id> cherryPickOf;
-  private Timestamp mergedOn;
+  private Instant mergedOn;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -314,10 +346,81 @@
       }
       result.put(a.key().patchSetId(), a.build());
     }
+    if (status != null && status.isClosed() && !isAnyApprovalCopied(result)) {
+      // If the change is closed, check if there are "submit records" with approvals that do not
+      // exist on the latest patch-set and copy them to the latest patch-set.
+      // We do not invoke this logic if any approval is copied. This is because prior to change
+      // https://gerrit-review.googlesource.com/c/gerrit/+/318135 we used to copy approvals
+      // dynamically (e.g. when requesting the change page). After that change, we started
+      // persisting copied votes in NoteDb, so we don't need to do this back-filling.
+      // Prior to that change (318135), we could've had changes with dynamically copied approvals
+      // that were merged in NoteDb but these approvals do not exist on the latest patch-set, so
+      // we need to back-fill these approvals.
+      PatchSet.Id latestPs = buildCurrentPatchSetId();
+      backFillMissingCopiedApprovalsFromSubmitRecords(result, latestPs).stream()
+          .forEach(a -> result.put(latestPs, a));
+    }
     result.keySet().forEach(k -> result.get(k).sort(ChangeNotes.PSA_BY_TIME));
     return result;
   }
 
+  /**
+   * Returns patch-set approvals that do not exist on the latest patch-set but for which a submit
+   * record exists in NoteDb when the change was merged.
+   */
+  private List<PatchSetApproval> backFillMissingCopiedApprovalsFromSubmitRecords(
+      ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals, @Nullable PatchSet.Id latestPs) {
+    List<PatchSetApproval> copiedApprovals = new ArrayList<>();
+    if (latestPs == null) {
+      return copiedApprovals;
+    }
+    List<PatchSetApproval> approvalsOnLatestPs = allApprovals.get(latestPs);
+    ListMultimap<Account.Id, PatchSetApproval> approvalsByUser = getApprovalsByUser(allApprovals);
+    List<SubmitRecord.Label> submitRecordLabels =
+        submitRecords.stream()
+            .filter(r -> r.labels != null)
+            .flatMap(r -> r.labels.stream())
+            .filter(label -> Status.OK.equals(label.status) || Status.MAY.equals(label.status))
+            .collect(Collectors.toList());
+    for (SubmitRecord.Label recordLabel : submitRecordLabels) {
+      String labelName = recordLabel.label;
+      Account.Id appliedBy = recordLabel.appliedBy;
+      if (appliedBy == null || labelName == null) {
+        continue;
+      }
+      boolean existsAtLatestPs =
+          approvalsOnLatestPs.stream()
+              .anyMatch(a -> a.accountId().equals(appliedBy) && a.label().equals(labelName));
+      if (existsAtLatestPs) {
+        continue;
+      }
+      // Search for an approval for this label on the max previous patch-set and copy the approval.
+      Collection<PatchSetApproval> userApprovals =
+          approvalsByUser.get(appliedBy).stream()
+              .filter(approval -> approval.label().equals(labelName))
+              .collect(Collectors.toList());
+      if (userApprovals.isEmpty()) {
+        continue;
+      }
+      PatchSetApproval lastApproved =
+          Collections.max(userApprovals, comparingInt(a -> a.patchSetId().get()));
+      copiedApprovals.add(lastApproved.copyWithPatchSet(latestPs));
+    }
+    return copiedApprovals;
+  }
+
+  private boolean isAnyApprovalCopied(ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
+    return allApprovals.values().stream().anyMatch(approval -> approval.copied());
+  }
+
+  private ListMultimap<Account.Id, PatchSetApproval> getApprovalsByUser(
+      ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
+    return allApprovals.values().stream()
+        .collect(
+            ImmutableListMultimap.toImmutableListMultimap(
+                PatchSetApproval::accountId, Function.identity()));
+  }
+
   private List<ReviewerStatusUpdate> buildReviewerUpdates() {
     List<ReviewerStatusUpdate> result = new ArrayList<>();
     HashMap<Account.Id, ReviewerStateInternal> lastState = new HashMap<>();
@@ -335,7 +438,7 @@
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
-    Timestamp commitTimestamp = getCommitTimestamp(commit);
+    Instant commitTimestamp = getCommitTimestamp(commit);
 
     createdOn = commitTimestamp;
     parseTag(commit);
@@ -389,7 +492,7 @@
 
     parseSubmission(commit, commitTimestamp);
 
-    if (lastUpdatedOn == null || commitTimestamp.after(lastUpdatedOn)) {
+    if (lastUpdatedOn == null || commitTimestamp.isAfter(lastUpdatedOn)) {
       lastUpdatedOn = commitTimestamp;
     }
 
@@ -451,7 +554,7 @@
     }
   }
 
-  private void parseSubmission(ChangeNotesCommit commit, Timestamp commitTimestamp)
+  private void parseSubmission(ChangeNotesCommit commit, Instant commitTimestamp)
       throws ConfigInvalidException {
     // Only parse the most recent sumbit commit (there should be exactly one).
     if (submissionId == null) {
@@ -534,7 +637,7 @@
     }
   }
 
-  private void parsePatchSet(PatchSet.Id psId, ObjectId rev, Account.Id accountId, Timestamp ts)
+  private void parsePatchSet(PatchSet.Id psId, ObjectId rev, Account.Id accountId, Instant ts)
       throws ConfigInvalidException {
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
@@ -607,7 +710,7 @@
     } else if (hashtagsLines.get(0).isEmpty()) {
       hashtags = ImmutableSet.of();
     } else {
-      hashtags = Sets.newHashSet(Splitter.on(',').split(hashtagsLines.get(0)));
+      hashtags = Sets.newHashSet(HASHTAG_SPLITTER.split(hashtagsLines.get(0)));
     }
   }
 
@@ -629,7 +732,7 @@
     }
   }
 
-  private void parseAssigneeUpdates(Timestamp ts, ChangeNotesCommit commit)
+  private void parseAssigneeUpdates(Instant ts, ChangeNotesCommit commit)
       throws ConfigInvalidException {
     String assigneeValue = parseOneFooter(commit, FOOTER_ASSIGNEE);
     if (assigneeValue != null) {
@@ -739,7 +842,7 @@
       Account.Id accountId,
       Account.Id realAccountId,
       ChangeNotesCommit commit,
-      Timestamp ts) {
+      Instant ts) {
     Optional<String> changeMsgString = getChangeMessageString(commit);
     if (!changeMsgString.isPresent()) {
       return false;
@@ -785,8 +888,28 @@
       for (HumanComment c : e.getValue().getEntities()) {
         humanComments.put(e.getKey(), c);
       }
-      for (SubmitRequirementResult sr : e.getValue().getSubmitRequirementsResult()) {
-        submitRequirementResults.add(sr);
+    }
+
+    // Lookup submit requirement results from the revision notes of the last PS that has stored
+    // submit requirements. This is important for cases where the change was abandoned/un-abandoned
+    // multiple times. With each abandon, we store submit requirement results in NoteDb, so we can
+    // end up having stored SRs in many revision notes. We should only return SRs from the last
+    // PS of them.
+    for (PatchSet.Builder ps :
+        patchSets.values().stream()
+            .sorted(comparingInt((PatchSet.Builder p) -> p.id().get()).reversed())
+            .collect(Collectors.toList())) {
+      Optional<ObjectId> maybePsCommitId = ps.commitId();
+      if (!maybePsCommitId.isPresent()) {
+        continue;
+      }
+      ObjectId psCommitId = maybePsCommitId.get();
+      if (rns.containsKey(psCommitId)
+          && rns.get(psCommitId).getSubmitRequirementsResult() != null) {
+        rns.get(psCommitId)
+            .getSubmitRequirementsResult()
+            .forEach(sr -> submitRequirementResults.add(sr));
+        break;
       }
     }
 
@@ -803,101 +926,46 @@
     }
   }
 
-  // Return the UUID start index or -1 if no UUID is present
-  private int parseCopiedApprovalUuidStart(String line, int tagStart) {
-    int separatorIndex = line.indexOf(LABEL_VOTE_UUID_SEPARATOR);
-
-    // The first part of the condition checks whether the footer has the following format:
-    //   Copied-Label: <LABEL>=VOTE <Gerrit Account>,<Gerrit Real Account> :"<TAG>"
-    //   Weird tag that contains uuid delimiter. The uuid is actually not present.
-    if ((tagStart != -1 && separatorIndex > tagStart)
-        ||
-
-        // The second part of the condition allows us to distinguish the following two lines:
-        //   Label2=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>
-        //   Label2=+1 User Name (company_name, department) <2@gerrit>
-        (line.indexOf(' ') < separatorIndex)) {
-      return -1;
-    }
-    return separatorIndex;
-  }
-
-  // Splitting on "," breaks for identities containing commas. The below re-implements splitting on
-  // "(?<=>),", but it's 3-5x faster, as performance matters here.
-  private String[] parseIdentities(String line) {
-    String[] idents = line.split(",");
-    List<String> identitiesList = new ArrayList<>();
-    for (int i = 0; i < idents.length; i++) {
-      if (i == 0 || idents[i - 1].endsWith(">")) {
-        identitiesList.add(idents[i]);
-      } else {
-        int lastIndex = identitiesList.size() - 1;
-        identitiesList.set(lastIndex, identitiesList.get(lastIndex) + "," + idents[i]);
-      }
-    }
-    return identitiesList.toArray(new String[0]);
-  }
-
-  // Footer example: Copied-Label: <LABEL>=VOTE <Gerrit Account>,<Gerrit Real Account> :"<TAG>"
-  // ":<"TAG>"" is optional. <Gerrit Real Account> is also optional, if it was not set.
-  // The label, vote, and the Gerrit account are mandatory (unlike FOOTER_LABEL where Gerrit
-  // Account is also optional since by default it's the committer).
-  private void parseCopiedApproval(PatchSet.Id psId, Timestamp ts, String line)
+  /** Parses copied {@link PatchSetApproval}. */
+  private void parseCopiedApproval(PatchSet.Id psId, Instant ts, String line)
       throws ConfigInvalidException {
-    // Copied approvals can't be explicitly removed. They are removed the same way as non-copied
-    // approvals.
-    checkFooter(!line.startsWith("-"), FOOTER_COPIED_LABEL, line);
+    ParsedPatchSetApproval parsedPatchSetApproval = ChangeNoteUtil.parseCopiedApproval(line);
+    checkFooter(
+        parsedPatchSetApproval.accountIdent().isPresent(),
+        FOOTER_COPIED_LABEL,
+        parsedPatchSetApproval.footerLine());
+    PersonIdent accountIdent =
+        RawParseUtils.parsePersonIdent(parsedPatchSetApproval.accountIdent().get());
 
-    Account.Id accountId, realAccountId = null;
-    String labelVoteStr;
-    String tag = null;
-    int tagStart = line.indexOf(":\"");
-    // UUID introduced in https://gerrit-review.googlesource.com/c/gerrit/+/324937
-    // Only parsed for backward compatibility
-    int uuidStart = parseCopiedApprovalUuidStart(line, tagStart);
-    int identitiesStart =
-        line.indexOf(' ', uuidStart != -1 ? uuidStart + LABEL_VOTE_UUID_SEPARATOR.length() : 0);
-    // The first account is the accountId, and second (if applicable) is the realAccountId.
-    try {
-      labelVoteStr = line.substring(0, uuidStart != -1 ? uuidStart : identitiesStart);
-    } catch (StringIndexOutOfBoundsException ex) {
-      throw new ConfigInvalidException(ex.getMessage(), ex);
-    }
-    String[] identities =
-        parseIdentities(
-            line.substring(identitiesStart + 1, tagStart == -1 ? line.length() : tagStart));
+    checkFooter(accountIdent != null, FOOTER_COPIED_LABEL, parsedPatchSetApproval.footerLine());
+    Account.Id accountId = parseIdent(accountIdent);
 
-    PersonIdent ident = RawParseUtils.parsePersonIdent(identities[0]);
-    checkFooter(ident != null, FOOTER_COPIED_LABEL, line);
-    accountId = parseIdent(ident);
-
-    if (identities.length > 1) {
-      PersonIdent realIdent = RawParseUtils.parsePersonIdent(identities[1]);
-      checkFooter(realIdent != null, FOOTER_COPIED_LABEL, line);
+    Account.Id realAccountId = null;
+    if (parsedPatchSetApproval.realAccountIdent().isPresent()) {
+      PersonIdent realIdent =
+          RawParseUtils.parsePersonIdent(parsedPatchSetApproval.realAccountIdent().get());
+      checkFooter(realIdent != null, FOOTER_COPIED_LABEL, parsedPatchSetApproval.footerLine());
       realAccountId = parseIdent(realIdent);
     }
 
     LabelVote l;
     try {
-      l = LabelVote.parseWithEquals(labelVoteStr);
+      l = LabelVote.parseWithEquals(parsedPatchSetApproval.labelVote());
     } catch (IllegalArgumentException e) {
-      ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_COPIED_LABEL, line);
+      ConfigInvalidException pe =
+          parseException(
+              "invalid %s: %s", FOOTER_COPIED_LABEL, parsedPatchSetApproval.footerLine());
       pe.initCause(e);
       throw pe;
     }
 
-    if (tagStart != -1) {
-      // tagStart+2 skips ":\"" to parse the actual tag. Tags are in brackets.
-      // line.length()-1 skips the last ".
-      tag = line.substring(tagStart + 2, line.length() - 1);
-    }
-
     PatchSetApproval.Builder psa =
         PatchSetApproval.builder()
             .key(PatchSetApproval.key(psId, accountId, LabelId.create(l.label())))
+            .uuid(parsedPatchSetApproval.uuid().map(PatchSetApproval::uuid))
             .value(l.value())
             .granted(ts)
-            .tag(Optional.ofNullable(tag))
+            .tag(parsedPatchSetApproval.tag())
             .copied(true);
     if (realAccountId != null) {
       psa.realAccountId(realAccountId);
@@ -907,87 +975,46 @@
   }
 
   private void parseApproval(
-      PatchSet.Id psId, Account.Id accountId, Account.Id realAccountId, Timestamp ts, String line)
+      PatchSet.Id psId, Account.Id accountId, Account.Id realAccountId, Instant ts, String line)
       throws ConfigInvalidException {
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
     }
     PatchSetApproval.Builder psa;
+    ParsedPatchSetApproval parsedPatchSetApproval = ChangeNoteUtil.parseApproval(line);
     if (line.startsWith("-")) {
-      psa = parseRemoveApproval(psId, accountId, realAccountId, ts, line);
+      psa = parseRemoveApproval(psId, accountId, realAccountId, ts, parsedPatchSetApproval);
     } else {
-      psa = parseAddApproval(psId, accountId, realAccountId, ts, line);
+      psa = parseAddApproval(psId, accountId, realAccountId, ts, parsedPatchSetApproval);
     }
     bufferedApprovals.add(psa);
   }
 
+  /** Parses {@link PatchSetApproval} out of the {@link ChangeNoteUtil#FOOTER_LABEL} value. */
   private PatchSetApproval.Builder parseAddApproval(
-      PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId, Timestamp ts, String line)
+      PatchSet.Id psId,
+      Account.Id committerId,
+      Account.Id realAccountId,
+      Instant ts,
+      ParsedPatchSetApproval parsedPatchSetApproval)
       throws ConfigInvalidException {
-    // There are potentially 3 accounts involved here:
-    //  1. The account from the commit, which is the effective IdentifiedUser
-    //     that produced the update.
-    //  2. The account in the label footer itself, which is used during submit
-    //     to copy other users' labels to a new patch set.
-    //  3. The account in the Real-user footer, indicating that the whole
-    //     update operation was executed by this user on behalf of the effective
-    //     user.
-    Account.Id effectiveAccountId;
-    // UUID introduced in https://gerrit-review.googlesource.com/c/gerrit/+/324937
-    // Only parsed for backward compatibility
-    int voteUuidSeparatorIndex = line.indexOf(LABEL_VOTE_UUID_SEPARATOR);
-    // We need some additional logic to differentiate between labels that have a UUID and those that
-    // have a user with a comma. This allows us to separate the following cases (note that the
-    // leading `Label: ` has been elided at this point):
-    //   Label: <LABEL>=VOTE, <UUID> <Gerrit Account>
-    //   Label: <LABEL>=VOTE <Gerrit, Account>
-    int reviewerStartOffset = 0;
-    int scoreStart = line.indexOf('=') + 1;
-    StringBuilder labelNameScore = new StringBuilder(line.substring(0, scoreStart));
-    for (int i = scoreStart; i < line.length(); i++) {
-      char currentChar = line.charAt(i);
-      // If we hit ',' before ' ' we have a UUID
-      if (currentChar == ',') {
-        labelNameScore.append(line, scoreStart, i);
-        reviewerStartOffset = voteUuidSeparatorIndex + LABEL_VOTE_UUID_SEPARATOR.length();
-        break;
-      }
-      // Otherwise we don't
-      if (currentChar == ' ') {
-        labelNameScore.append(line, scoreStart, i);
-        break;
-      }
-      // If we hit neither we're defensive assign the whole line
-      if (i == line.length() - 1) {
-        labelNameScore = new StringBuilder(line);
-        break;
-      }
-    }
-    int reviewerStart = line.indexOf(' ', reviewerStartOffset);
-    if (reviewerStart > 0) {
-      // Account in the label line (2) becomes the effective ID of the
-      // approval. If there is a real user (3) different from the commit user
-      // (2), we actually don't store that anywhere in this case; it's more
-      // important to record that the real user (3) actually initiated submit.
-      PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(reviewerStart + 1));
-      checkFooter(ident != null, FOOTER_LABEL, line);
-      effectiveAccountId = parseIdent(ident);
-    } else {
-      effectiveAccountId = committerId;
-    }
+
+    Account.Id approverId = parseApprover(committerId, parsedPatchSetApproval);
 
     LabelVote l;
     try {
-      l = LabelVote.parseWithEquals(labelNameScore.toString());
+      l = LabelVote.parseWithEquals(parsedPatchSetApproval.labelVote());
     } catch (IllegalArgumentException e) {
-      ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_LABEL, line);
+      ConfigInvalidException pe =
+          parseException("invalid %s: %s", FOOTER_LABEL, parsedPatchSetApproval.footerLine());
       pe.initCause(e);
       throw pe;
     }
 
     PatchSetApproval.Builder psa =
         PatchSetApproval.builder()
-            .key(PatchSetApproval.key(psId, effectiveAccountId, LabelId.create(l.label())))
+            .key(PatchSetApproval.key(psId, approverId, LabelId.create(l.label())))
+            .uuid(parsedPatchSetApproval.uuid().map(PatchSetApproval::uuid))
             .value(l.value())
             .granted(ts)
             .tag(Optional.ofNullable(tag));
@@ -999,26 +1026,24 @@
   }
 
   private PatchSetApproval.Builder parseRemoveApproval(
-      PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId, Timestamp ts, String line)
+      PatchSet.Id psId,
+      Account.Id committerId,
+      Account.Id realAccountId,
+      Instant ts,
+      ParsedPatchSetApproval parsedPatchSetApproval)
       throws ConfigInvalidException {
-    // See comments in parseAddApproval about the various users involved.
-    Account.Id effectiveAccountId;
-    String label;
-    int s = line.indexOf(' ');
-    if (s > 0) {
-      label = line.substring(1, s);
-      PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
-      checkFooter(ident != null, FOOTER_LABEL, line);
-      effectiveAccountId = parseIdent(ident);
-    } else {
-      label = line.substring(1);
-      effectiveAccountId = committerId;
-    }
+
+    checkFooter(
+        parsedPatchSetApproval.footerLine().startsWith("-"),
+        FOOTER_LABEL,
+        parsedPatchSetApproval.footerLine());
+    Account.Id approverId = parseApprover(committerId, parsedPatchSetApproval);
 
     try {
-      LabelType.checkNameInternal(label);
+      LabelType.checkNameInternal(parsedPatchSetApproval.labelVote());
     } catch (IllegalArgumentException e) {
-      ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_LABEL, line);
+      ConfigInvalidException pe =
+          parseException("invalid %s: %s", FOOTER_LABEL, parsedPatchSetApproval.footerLine());
       pe.initCause(e);
       throw pe;
     }
@@ -1027,7 +1052,9 @@
     // needs an actual approval in order to block copying an earlier approval over a later delete.
     PatchSetApproval.Builder remove =
         PatchSetApproval.builder()
-            .key(PatchSetApproval.key(psId, effectiveAccountId, LabelId.create(label)))
+            .key(
+                PatchSetApproval.key(
+                    psId, approverId, LabelId.create(parsedPatchSetApproval.labelVote())))
             .value(0)
             .granted(ts);
     if (!Objects.equals(realAccountId, committerId)) {
@@ -1037,6 +1064,30 @@
     return remove;
   }
 
+  /**
+   * Identifies the {@link com.google.gerrit.entities.Account.Id} that issued the vote.
+   *
+   * <p>There are potentially 3 accounts involved here: 1. The account from the commit, which is the
+   * effective IdentifiedUser that produced the update. 2. The account in the label footer itself,
+   * which is used during submit to copy other users' labels to a new patch set. 3. The account in
+   * the Real-user footer, indicating that the whole update operation was executed by this user on
+   * behalf of the effective user.
+   */
+  private Account.Id parseApprover(
+      Account.Id committerId, ParsedPatchSetApproval parsedPatchSetApproval)
+      throws ConfigInvalidException {
+    Account.Id effectiveAccountId;
+    if (parsedPatchSetApproval.accountIdent().isPresent()) {
+      PersonIdent ident =
+          RawParseUtils.parsePersonIdent(parsedPatchSetApproval.accountIdent().get());
+      checkFooter(ident != null, FOOTER_LABEL, parsedPatchSetApproval.footerLine());
+      effectiveAccountId = parseIdent(ident);
+    } else {
+      effectiveAccountId = committerId;
+    }
+    return effectiveAccountId;
+  }
+
   private void parseSubmitRecords(List<String> lines) throws ConfigInvalidException {
     SubmitRecord rec = null;
 
@@ -1055,7 +1106,7 @@
       } else {
         checkFooter(rec != null, FOOTER_SUBMITTED_WITH, line);
         if (line.startsWith("Rule-Name: ")) {
-          String ruleName = line.split(": ")[1];
+          String ruleName = RULE_SPLITTER.splitToList(line).get(1);
           rec.ruleName = ruleName;
           continue;
         }
@@ -1092,7 +1143,7 @@
     return parseIdent(a);
   }
 
-  private void parseReviewer(Timestamp ts, ReviewerStateInternal state, String line)
+  private void parseReviewer(Instant ts, ReviewerStateInternal state, String line)
       throws ConfigInvalidException {
     PersonIdent ident = RawParseUtils.parsePersonIdent(line);
     if (ident == null) {
@@ -1105,7 +1156,7 @@
     }
   }
 
-  private void parseReviewerByEmail(Timestamp ts, ReviewerStateInternal state, String line)
+  private void parseReviewerByEmail(Instant ts, ReviewerStateInternal state, String line)
       throws ConfigInvalidException {
     Address adr;
     try {
@@ -1219,15 +1270,15 @@
    * @param commit the commit to return commit time.
    * @return the timestamp when the commit was applied.
    */
-  private Timestamp getCommitTimestamp(ChangeNotesCommit commit) {
-    return new Timestamp(commit.getCommitterIdent().getWhen().getTime());
+  private Instant getCommitTimestamp(ChangeNotesCommit commit) {
+    return commit.getCommitterIdent().getWhenAsInstant();
   }
 
   private void pruneReviewers() {
-    Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit =
+    Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Instant>> rit =
         reviewers.cellSet().iterator();
     while (rit.hasNext()) {
-      Table.Cell<Account.Id, ReviewerStateInternal, Timestamp> e = rit.next();
+      Table.Cell<Account.Id, ReviewerStateInternal, Instant> e = rit.next();
       if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
         rit.remove();
       }
@@ -1235,10 +1286,10 @@
   }
 
   private void pruneReviewersByEmail() {
-    Iterator<Table.Cell<Address, ReviewerStateInternal, Timestamp>> rit =
+    Iterator<Table.Cell<Address, ReviewerStateInternal, Instant>> rit =
         reviewersByEmail.cellSet().iterator();
     while (rit.hasNext()) {
-      Table.Cell<Address, ReviewerStateInternal, Timestamp> e = rit.next();
+      Table.Cell<Address, ReviewerStateInternal, Instant> e = rit.next();
       if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
         rit.remove();
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 4d6b9cf..b0079d7 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -65,7 +65,6 @@
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gson.Gson;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.List;
 import java.util.Map;
@@ -103,8 +102,8 @@
       ObjectId metaId,
       Change.Id changeId,
       Change.Key changeKey,
-      Timestamp createdOn,
-      Timestamp lastUpdatedOn,
+      Instant createdOn,
+      Instant lastUpdatedOn,
       Account.Id owner,
       String serverId,
       String branch,
@@ -136,7 +135,7 @@
       @Nullable Change.Id revertOf,
       @Nullable PatchSet.Id cherryPickOf,
       int updateCount,
-      @Nullable Timestamp mergedOn) {
+      @Nullable Instant mergedOn) {
     requireNonNull(
         metaId,
         () ->
@@ -203,9 +202,9 @@
 
     abstract Change.Key changeKey();
 
-    abstract Timestamp createdOn();
+    abstract Instant createdOn();
 
-    abstract Timestamp lastUpdatedOn();
+    abstract Instant lastUpdatedOn();
 
     abstract Account.Id owner();
 
@@ -249,9 +248,9 @@
 
       abstract Builder changeKey(Change.Key changeKey);
 
-      abstract Builder createdOn(Timestamp createdOn);
+      abstract Builder createdOn(Instant createdOn);
 
-      abstract Builder lastUpdatedOn(Timestamp lastUpdatedOn);
+      abstract Builder lastUpdatedOn(Instant lastUpdatedOn);
 
       abstract Builder owner(Account.Id owner);
 
@@ -334,7 +333,7 @@
   abstract int updateCount();
 
   @Nullable
-  abstract Timestamp mergedOn();
+  abstract Instant mergedOn();
 
   Change newChange(Project.NameKey project) {
     ChangeColumns c = requireNonNull(columns(), "columns are required");
@@ -456,7 +455,7 @@
 
     abstract Builder updateCount(int updateCount);
 
-    abstract Builder mergedOn(Timestamp mergedOn);
+    abstract Builder mergedOn(Instant mergedOn);
 
     abstract ChangeNotesState build();
   }
@@ -536,7 +535,7 @@
                       SubmitRequirementProtoConverter.INSTANCE.toProto(sr)));
       b.setUpdateCount(object.updateCount());
       if (object.mergedOn() != null) {
-        b.setMergedOnMillis(object.mergedOn().getTime());
+        b.setMergedOnMillis(object.mergedOn().toEpochMilli());
         b.setHasMergedOn(true);
       }
 
@@ -547,8 +546,8 @@
       ChangeColumnsProto.Builder b =
           ChangeColumnsProto.newBuilder()
               .setChangeKey(cols.changeKey().get())
-              .setCreatedOnMillis(cols.createdOn().getTime())
-              .setLastUpdatedOnMillis(cols.lastUpdatedOn().getTime())
+              .setCreatedOnMillis(cols.createdOn().toEpochMilli())
+              .setLastUpdatedOnMillis(cols.lastUpdatedOn().toEpochMilli())
               .setOwner(cols.owner().get())
               .setBranch(cols.branch());
       if (cols.currentPatchSetId() != null) {
@@ -581,26 +580,26 @@
     }
 
     private static ReviewerSetEntryProto toReviewerSetEntry(
-        Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c) {
+        Table.Cell<ReviewerStateInternal, Account.Id, Instant> c) {
       return ReviewerSetEntryProto.newBuilder()
           .setState(REVIEWER_STATE_CONVERTER.reverse().convert(c.getRowKey()))
           .setAccountId(c.getColumnKey().get())
-          .setTimestampMillis(c.getValue().getTime())
+          .setTimestampMillis(c.getValue().toEpochMilli())
           .build();
     }
 
     private static ReviewerByEmailSetEntryProto toReviewerByEmailSetEntry(
-        Table.Cell<ReviewerStateInternal, Address, Timestamp> c) {
+        Table.Cell<ReviewerStateInternal, Address, Instant> c) {
       return ReviewerByEmailSetEntryProto.newBuilder()
           .setState(REVIEWER_STATE_CONVERTER.reverse().convert(c.getRowKey()))
           .setAddress(c.getColumnKey().toHeaderString())
-          .setTimestampMillis(c.getValue().getTime())
+          .setTimestampMillis(c.getValue().toEpochMilli())
           .build();
     }
 
     private static ReviewerStatusUpdateProto toReviewerStatusUpdateProto(ReviewerStatusUpdate u) {
       return ReviewerStatusUpdateProto.newBuilder()
-          .setTimestampMillis(u.date().getTime())
+          .setTimestampMillis(u.date().toEpochMilli())
           .setUpdatedBy(u.updatedBy().get())
           .setReviewer(u.reviewer().get())
           .setState(REVIEWER_STATE_CONVERTER.reverse().convert(u.state()))
@@ -620,7 +619,7 @@
     private static AssigneeStatusUpdateProto toAssigneeStatusUpdateProto(AssigneeStatusUpdate u) {
       AssigneeStatusUpdateProto.Builder builder =
           AssigneeStatusUpdateProto.newBuilder()
-              .setTimestampMillis(u.date().getTime())
+              .setTimestampMillis(u.date().toEpochMilli())
               .setUpdatedBy(u.updatedBy().get())
               .setHasCurrentAssignee(u.currentAssignee().isPresent());
 
@@ -678,7 +677,8 @@
                       .map(sr -> SubmitRequirementProtoConverter.INSTANCE.fromProto(sr))
                       .collect(toImmutableList()))
               .updateCount(proto.getUpdateCount())
-              .mergedOn(proto.getHasMergedOn() ? new Timestamp(proto.getMergedOnMillis()) : null);
+              .mergedOn(
+                  proto.getHasMergedOn() ? Instant.ofEpochMilli(proto.getMergedOnMillis()) : null);
       return b.build();
     }
 
@@ -686,8 +686,8 @@
       ChangeColumns.Builder b =
           ChangeColumns.builder()
               .changeKey(Change.key(proto.getChangeKey()))
-              .createdOn(new Timestamp(proto.getCreatedOnMillis()))
-              .lastUpdatedOn(new Timestamp(proto.getLastUpdatedOnMillis()))
+              .createdOn(Instant.ofEpochMilli(proto.getCreatedOnMillis()))
+              .lastUpdatedOn(Instant.ofEpochMilli(proto.getLastUpdatedOnMillis()))
               .owner(Account.id(proto.getOwner()))
               .branch(proto.getBranch());
       if (proto.getHasCurrentPatchSetId()) {
@@ -719,26 +719,25 @@
     }
 
     private static ReviewerSet toReviewerSet(List<ReviewerSetEntryProto> protos) {
-      ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
+      ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Instant> b =
           ImmutableTable.builder();
       for (ReviewerSetEntryProto e : protos) {
         b.put(
             REVIEWER_STATE_CONVERTER.convert(e.getState()),
             Account.id(e.getAccountId()),
-            new Timestamp(e.getTimestampMillis()));
+            Instant.ofEpochMilli(e.getTimestampMillis()));
       }
       return ReviewerSet.fromTable(b.build());
     }
 
     private static ReviewerByEmailSet toReviewerByEmailSet(
         List<ReviewerByEmailSetEntryProto> protos) {
-      ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b =
-          ImmutableTable.builder();
+      ImmutableTable.Builder<ReviewerStateInternal, Address, Instant> b = ImmutableTable.builder();
       for (ReviewerByEmailSetEntryProto e : protos) {
         b.put(
             REVIEWER_STATE_CONVERTER.convert(e.getState()),
             Address.parse(e.getAddress()),
-            new Timestamp(e.getTimestampMillis()));
+            Instant.ofEpochMilli(e.getTimestampMillis()));
       }
       return ReviewerByEmailSet.fromTable(b.build());
     }
@@ -749,7 +748,7 @@
       for (ReviewerStatusUpdateProto proto : protos) {
         b.add(
             ReviewerStatusUpdate.create(
-                new Timestamp(proto.getTimestampMillis()),
+                Instant.ofEpochMilli(proto.getTimestampMillis()),
                 Account.id(proto.getUpdatedBy()),
                 Account.id(proto.getReviewer()),
                 REVIEWER_STATE_CONVERTER.convert(proto.getState())));
@@ -791,7 +790,7 @@
       for (AssigneeStatusUpdateProto proto : protos) {
         b.add(
             AssigneeStatusUpdate.create(
-                new Timestamp(proto.getTimestampMillis()),
+                Instant.ofEpochMilli(proto.getTimestampMillis()),
                 Account.id(proto.getUpdatedBy()),
                 proto.getHasCurrentAssignee()
                     ? Optional.of(Account.id(proto.getCurrentAssignee()))
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index 44475db..6d49fc8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -17,6 +17,8 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayInputStream;
@@ -33,18 +35,29 @@
 /** Implements the parsing of comment data, handling JSON decoding and push certificates. */
 class ChangeRevisionNote extends RevisionNote<HumanComment> {
   private final ChangeNoteJson noteJson;
-  private final HumanComment.Status status;
+  private final Comment.Status status;
   private String pushCert;
 
-  private ImmutableList<SubmitRequirementResult> submitRequirementsResult;
+  /**
+   * Submit requirement results stored in this revision note. If null, then no SRs were stored in
+   * the revision note . Otherwise, there were stored SRs in this revision note. The list could be
+   * empty, meaning that no SRs were configured for the project.
+   */
+  @Nullable private ImmutableList<SubmitRequirementResult> submitRequirementsResult;
 
   ChangeRevisionNote(
-      ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, HumanComment.Status status) {
+      ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, Comment.Status status) {
     super(reader, noteId);
     this.noteJson = noteJson;
     this.status = status;
   }
 
+  /**
+   * Returns null if no submit requirements were stored in the revision note. Otherwise, this method
+   * returns a list of submit requirements, which can probably be empty if there were no SRs
+   * configured for the project at the time when the SRs were stored.
+   */
+  @Nullable
   public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
     checkParsed();
     return submitRequirementsResult;
@@ -69,7 +82,7 @@
     }
     this.submitRequirementsResult =
         data.submitRequirementResults == null
-            ? ImmutableList.of()
+            ? null
             : ImmutableList.copyOf(data.submitRequirementResults);
     return data.comments;
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index ddc59f0..590d30f 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -52,6 +52,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Table;
 import com.google.common.collect.Table.Cell;
 import com.google.common.collect.TreeBasedTable;
@@ -63,6 +64,7 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
@@ -74,6 +76,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.LabelVote;
@@ -81,10 +84,10 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Comparator;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
@@ -115,13 +118,20 @@
  * there is a single author and timestamp for each update.
  *
  * <p>This class is not thread-safe.
+ *
+ * <p>NOTE: This class also serializes the change in a custom storage format, used in NoteDB. All
+ * changes to the storage format must be both forward and backward compatible, see comment on {@link
+ * ChangeNotesParser}.
+ *
+ * <p>Such changes include e.g. introducing/removing footers, modifying footer formats, mutations of
+ * the attached {@link ChangeRevisionNote}.
  */
 public class ChangeUpdate extends AbstractChangeUpdate {
   public interface Factory {
-    ChangeUpdate create(ChangeNotes notes, CurrentUser user, Date when);
+    ChangeUpdate create(ChangeNotes notes, CurrentUser user, Instant when);
 
     ChangeUpdate create(
-        ChangeNotes notes, CurrentUser user, Date when, Comparator<String> labelNameComparator);
+        ChangeNotes notes, CurrentUser user, Instant when, Comparator<String> labelNameComparator);
   }
 
   private final NoteDbUpdateManager.Factory updateManagerFactory;
@@ -129,13 +139,13 @@
   private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
   private final DeleteCommentRewriter.Factory deleteCommentRewriterFactory;
   private final ServiceUserClassifier serviceUserClassifier;
+  private final PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator;
 
   private final Table<String, Account.Id, Optional<Short>> approvals;
   private final List<PatchSetApproval> copiedApprovals = new ArrayList<>();
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
   private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
   private final List<HumanComment> comments = new ArrayList<>();
-  private final List<SubmitRequirementResult> submitRequirementResults = new ArrayList<>();
 
   private String commitSubject;
   private String subject;
@@ -169,6 +179,7 @@
   private RobotCommentUpdate robotCommentUpdate;
   private DeleteCommentRewriter deleteCommentRewriter;
   private DeleteChangeMessageRewriter deleteChangeMessageRewriter;
+  private List<SubmitRequirementResult> submitRequirementResults;
 
   @SuppressWarnings("UnusedMethod")
   @AssistedInject
@@ -180,9 +191,10 @@
       DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ProjectCache projectCache,
       ServiceUserClassifier serviceUserClassifier,
+      PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user,
-      @Assisted Date when,
+      @Assisted Instant when,
       ChangeNoteUtil noteUtil) {
     this(
         serverIdent,
@@ -191,6 +203,7 @@
         robotCommentUpdateFactory,
         deleteCommentRewriterFactory,
         serviceUserClassifier,
+        patchSetApprovalUuidGenerator,
         notes,
         user,
         when,
@@ -215,9 +228,10 @@
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
       DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ServiceUserClassifier serviceUserClassifier,
+      PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user,
-      @Assisted Date when,
+      @Assisted Instant when,
       @Assisted Comparator<String> labelNameComparator,
       ChangeNoteUtil noteUtil) {
     super(notes, user, serverIdent, noteUtil, when);
@@ -226,6 +240,7 @@
     this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
     this.serviceUserClassifier = serviceUserClassifier;
+    this.patchSetApprovalUuidGenerator = patchSetApprovalUuidGenerator;
     this.approvals = approvals(labelNameComparator);
   }
 
@@ -287,10 +302,6 @@
     copiedApprovals.add(copiedPatchSetApproval);
   }
 
-  public boolean hasCopiedApprovals() {
-    return !copiedApprovals.isEmpty();
-  }
-
   public void merge(SubmissionId submissionId, Iterable<SubmitRecord> submitRecords) {
     this.status = Change.Status.MERGED;
     this.submissionId = submissionId.toString();
@@ -324,10 +335,13 @@
   }
 
   public void putSubmitRequirementResults(Collection<SubmitRequirementResult> rs) {
+    if (submitRequirementResults == null) {
+      submitRequirementResults = new ArrayList<>();
+    }
     submitRequirementResults.addAll(rs);
   }
 
-  public void putComment(HumanComment.Status status, HumanComment c) {
+  public void putComment(Comment.Status status, HumanComment c) {
     verifyComment(c);
     createDraftUpdateIfNull();
     if (status == HumanComment.Status.DRAFT) {
@@ -513,7 +527,7 @@
   /** Returns the tree id for the updated tree */
   private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
       throws ConfigInvalidException, IOException {
-    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults == null && comments.isEmpty() && pushCert == null) {
       return null;
     }
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
@@ -523,8 +537,23 @@
       c.tag = tag;
       cache.get(c.getCommitId()).putComment(c);
     }
-    for (SubmitRequirementResult sr : submitRequirementResults) {
-      cache.get(sr.patchSetCommitId()).putSubmitRequirementResult(sr);
+    if (submitRequirementResults != null) {
+      if (submitRequirementResults.isEmpty()) {
+        ObjectId latestPsCommitId =
+            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);
+        }
+      }
     }
     if (pushCert != null) {
       checkState(commit != null);
@@ -635,12 +664,12 @@
         deleteCommentRewriter == null && deleteChangeMessageRewriter == null,
         "cannot update and rewrite ref in one BatchUpdate");
 
-    int ps = psId != null ? psId.get() : getChange().currentPatchSetId().get();
+    PatchSet.Id patchSetId = psId != null ? psId : getChange().currentPatchSetId();
     StringBuilder msg = new StringBuilder();
     if (commitSubject != null) {
       msg.append(commitSubject);
     } else {
-      msg.append("Update patch set ").append(ps);
+      msg.append("Update patch set ").append(patchSetId.get());
     }
     msg.append("\n\n");
 
@@ -649,7 +678,7 @@
       msg.append("\n\n");
     }
 
-    addPatchSetFooter(msg, ps);
+    addPatchSetFooter(msg, patchSetId);
 
     if (currentPatchSet) {
       addFooter(msg, FOOTER_CURRENT, Boolean.TRUE);
@@ -723,7 +752,7 @@
     }
 
     for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
-      addLabelFooter(msg, c);
+      addLabelFooter(msg, c, patchSetId);
     }
     for (PatchSetApproval patchSetApproval : copiedApprovals) {
       addCopiedLabelFooter(msg, patchSetApproval);
@@ -792,7 +821,10 @@
       }
     }
 
-    updateAttentionSet(msg);
+    boolean hasAttentionSeUpdates = updateAttentionSet(msg);
+    if (isEmptyWithoutAttentionSet() && !hasAttentionSeUpdates) {
+      return NO_OP_UPDATE;
+    }
 
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage(msg.toString());
@@ -807,17 +839,25 @@
     return cb;
   }
 
-  private void addLabelFooter(StringBuilder msg, Cell<String, Account.Id, Optional<Short>> c) {
+  private void addLabelFooter(
+      StringBuilder msg, Cell<String, Account.Id, Optional<Short>> c, PatchSet.Id patchSetId) {
     addFooter(msg, FOOTER_LABEL);
+    String label = c.getRowKey();
+    Account.Id reviewerId = c.getColumnKey();
     // Label names/values are safe to append without sanitizing.
-    if (!c.getValue().isPresent()) {
-      msg.append('-').append(c.getRowKey());
+    boolean isRemoval = !c.getValue().isPresent();
+    if (isRemoval) {
+      msg.append('-').append(label);
+      // Since vote removals do not need to be referenced, e.g. by the copy approvals, they do not
+      // require a UUID.
     } else {
-      msg.append(LabelVote.create(c.getRowKey(), c.getValue().get()).formatWithEquals());
+      short value = c.getValue().get();
+      msg.append(LabelVote.create(label, c.getValue().get()).formatWithEquals());
+      msg.append(", ");
+      msg.append(patchSetApprovalUuidGenerator.get(patchSetId, reviewerId, label, value, when));
     }
-    Account.Id id = c.getColumnKey();
-    if (!id.equals(getAccountId())) {
-      noteUtil.appendAccountIdIdentString(msg.append(' '), id);
+    if (!reviewerId.equals(getAccountId())) {
+      noteUtil.appendAccountIdIdentString(msg.append(' '), reviewerId);
     }
     msg.append('\n');
   }
@@ -831,6 +871,11 @@
     // Label names/values are safe to append without sanitizing.
     msg.append(
         LabelVote.create(patchSetApproval.label(), patchSetApproval.value()).formatWithEquals());
+    // Might be copied from the vote that was generated before UUID was introduced.
+    if (patchSetApproval.uuid().isPresent()) {
+      msg.append(", ");
+      msg.append(patchSetApproval.uuid().get());
+    }
     Account.Id id = patchSetApproval.accountId();
     noteUtil.appendAccountIdIdentString(msg.append(' '), id);
 
@@ -845,6 +890,7 @@
     if (patchSetApproval.tag().isPresent()) {
       msg.append(":\"" + sanitizeFooter(patchSetApproval.tag().get()) + "\"");
     }
+
     msg.append('\n');
   }
 
@@ -912,8 +958,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<>();
     }
@@ -939,6 +988,8 @@
 
     removeInactiveUsersFromAttentionSet(currentReviewers);
 
+    boolean hasUpdates = false;
+
     for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
       if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
           && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
@@ -973,7 +1024,9 @@
       }
 
       addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
+      hasUpdates = true;
     }
+    return hasUpdates;
   }
 
   private void removeInactiveUsersFromAttentionSet(Set<Account.Id> currentReviewers) {
@@ -1026,8 +1079,8 @@
     ignoreFurtherAttentionSetUpdates = true;
   }
 
-  private void addPatchSetFooter(StringBuilder sb, int ps) {
-    addFooter(sb, FOOTER_PATCH_SET).append(ps);
+  private void addPatchSetFooter(StringBuilder sb, PatchSet.Id ps) {
+    addFooter(sb, FOOTER_PATCH_SET).append(ps.get());
     if (psState != null) {
       sb.append(" (").append(psState.name().toLowerCase()).append(')');
     }
@@ -1041,6 +1094,10 @@
 
   @Override
   public boolean isEmpty() {
+    return isEmptyWithoutAttentionSet() && plannedAttentionSetUpdates == null;
+  }
+
+  private boolean isEmptyWithoutAttentionSet() {
     return commitSubject == null
         && approvals.isEmpty()
         && copiedApprovals.isEmpty()
@@ -1053,7 +1110,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 7f440bd..d35a367 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -26,6 +26,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -70,7 +71,7 @@
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.diff.DiffAlgorithm;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.diff.EditList;
@@ -111,6 +112,8 @@
 public class CommitRewriter {
   /** Options to run {@link #backfillProject}. */
   public static class RunOptions implements Serializable {
+    private static final long serialVersionUID = 1L;
+
     /** Whether to rewrite the commit history or only find refs that need to be fixed. */
     public boolean dryRun = true;
     /**
@@ -124,10 +127,9 @@
     /** Max number of refs to update in a single {@link BatchRefUpdate}. */
     public int maxRefsInBatch = 10000;
     /**
-     * Max number of refs to fix by a single {@link RefsUpdate#backfillProject} run. Since second
-     * run on the same set of refs is a no-op, running with this option in a loop will eventually
-     * fix all refs. Number of executed {@link BatchRefUpdate} depends on {@link #maxRefsInBatch}
-     * option.
+     * Max number of refs to fix by a single {@link RefsUpdate} run. Since the second run on the
+     * same set of refs is a no-op, running with this option in a loop will eventually fix all refs.
+     * The number of executed {@link BatchRefUpdate} depends on {@link #maxRefsInBatch} option.
      */
     public int maxRefsToUpdate = 50000;
   }
@@ -228,6 +230,8 @@
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final Splitter COMMIT_MESSAGE_SPLITTER = Splitter.onPattern("\\r?\\n");
+
   private final ChangeNotes.Factory changeNotesFactory;
   private final AccountCache accountCache;
   private final DiffAlgorithm diffAlgorithm = new HistogramDiff();
@@ -264,6 +268,8 @@
     BackfillResult result = new BackfillResult();
     result.ok = true;
     int refsInUpdate = 0;
+
+    @SuppressWarnings("resource")
     RefsUpdate refsUpdate = null;
     try {
       for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
@@ -574,7 +580,7 @@
 
   private boolean verifyPersonIdent(PersonIdent newIdent, PersonIdent originalIdent) {
     return newIdent.getTimeZoneOffset() == originalIdent.getTimeZoneOffset()
-        && newIdent.getWhen().getTime() == originalIdent.getWhen().getTime()
+        && newIdent.getWhenAsInstant().equals(originalIdent.getWhenAsInstant())
         && newIdent.getEmailAddress().equals(originalIdent.getEmailAddress());
   }
 
@@ -688,15 +694,16 @@
         || !originalChangeMessage.startsWith(REMOVED_VOTES_CHANGE_MESSAGE_START)) {
       return Optional.empty();
     }
-    String[] lines = originalChangeMessage.split("\\r?\\n");
+    List<String> lines = COMMIT_MESSAGE_SPLITTER.splitToList(originalChangeMessage);
     StringBuilder fixedLines = new StringBuilder();
     boolean anyFixed = false;
-    for (int i = 1; i < lines.length; i++) {
-      if (lines[i].isEmpty()) {
+    for (int i = 1; i < lines.size(); i++) {
+      String line = lines.get(i);
+      if (line.isEmpty()) {
         continue;
       }
-      Matcher matcher = REMOVED_VOTES_CHANGE_MESSAGE_PATTERN.matcher(lines[i]);
-      String replacementLine = lines[i];
+      Matcher matcher = REMOVED_VOTES_CHANGE_MESSAGE_PATTERN.matcher(line);
+      String replacementLine = line;
       if (matcher.matches() && !NON_REPLACE_ACCOUNT_PATTERN.matcher(matcher.group(2)).matches()) {
         anyFixed = true;
         Optional<String> reviewerReplacement =
@@ -767,7 +774,7 @@
     // Pre fix, try to replace with something meaningful.
     // Retrieve reviewer accounts from cache and try to match by their name.
     onAddReviewerMatcher.reset();
-    StringBuffer sb = new StringBuffer();
+    StringBuilder sb = new StringBuilder();
     while (onAddReviewerMatcher.find()) {
       String reviewerName = normalizeOnCodeOwnerAddReviewerMatch(onAddReviewerMatcher.group(1));
       Optional<String> replacementName =
@@ -944,7 +951,8 @@
           continue;
         }
       } else if (footerKey.equalsIgnoreCase(FOOTER_LABEL.getName())) {
-        int voterIdentStart = footerValue.indexOf(' ');
+        int uuidStart = footerValue.indexOf(", ");
+        int voterIdentStart = footerValue.indexOf(' ', uuidStart != -1 ? uuidStart + 2 : 0);
         FixIdentResult fixedVoter = null;
         if (voterIdentStart > 0) {
           String originalIdentString = footerValue.substring(voterIdentStart + 1);
@@ -1177,7 +1185,8 @@
       // Filter further so we match both email & name
       if (possibleReplacements.size() > 1) {
         logger.atWarning().log(
-            "Fixing ref %s, multiple accounts found with the same email address, while replacing %s",
+            "Fixing ref %s, multiple accounts found with the same email address, while replacing"
+                + " %s",
             changeFixProgress.changeMetaRef, accountInfo);
         possibleReplacements =
             possibleReplacements.entrySet().stream()
diff --git a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
index e8c0fda..76871a8 100644
--- a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -159,6 +159,7 @@
         HumanComment comment = curMap.get(key);
         if (key.equals(uuid)) {
           comment.message = newMessage;
+          comment.unresolved = false;
         }
         comments.add(comment);
       }
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 1ead03c..28436db 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -141,7 +141,7 @@
   }
 
   private void logInfo(String message) {
-    logger.atInfo().log(message);
+    logger.atInfo().log("%s", message);
     uiConsumer.accept(message);
   }
 
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 4988406..5d8f57f 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -20,8 +20,6 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -124,13 +122,13 @@
             reader,
             NoteMap.read(reader, tipCommit),
             HumanComment.Status.DRAFT);
-    ListMultimap<ObjectId, HumanComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+    ImmutableListMultimap.Builder<ObjectId, HumanComment> cs = ImmutableListMultimap.builder();
     for (ChangeRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
       for (HumanComment c : rn.getEntities()) {
         cs.put(c.getCommitId(), c);
       }
     }
-    comments = ImmutableListMultimap.copyOf(cs);
+    comments = cs.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index e817fe6..94e11c8 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -306,12 +306,6 @@
     }
   }
 
-  public BatchRefUpdate prepare() throws IOException {
-    checkNotExecuted();
-    stage();
-    return prepare(changeRepo, false, pushCert);
-  }
-
   @Nullable
   public BatchRefUpdate execute() throws IOException {
     return execute(false);
@@ -359,7 +353,7 @@
     }
   }
 
-  private BatchRefUpdate prepare(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
+  private BatchRefUpdate execute(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
       throws IOException {
     if (or == null || or.cmds.isEmpty()) {
       return null;
@@ -383,18 +377,12 @@
     bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent.get());
     bru.setAtomic(true);
     or.cmds.addTo(bru);
-    bru.setAllowNonFastForwards(true);
+    bru.setAllowNonFastForwards(allowNonFastForwards(or.cmds));
     for (BatchUpdateListener listener : batchUpdateListeners) {
       bru = listener.beforeUpdateRefs(bru);
     }
 
-    return bru;
-  }
-
-  private BatchRefUpdate execute(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
-      throws IOException {
-    BatchRefUpdate bru = prepare(or, dryrun, pushCert);
-    if (bru != null && !dryrun) {
+    if (!dryrun) {
       RefUpdateUtil.executeChecked(bru, or.rw);
     }
     return bru;
@@ -470,4 +458,27 @@
       }
     }
   }
+
+  /**
+   * Returns true if we should allow non-fast-forwards while performing the batch ref update. Non-ff
+   * updates are necessary in some specific cases:
+   *
+   * <p>1. Draft ref updates are non fast-forward, since the ref always points to a single commit
+   * that has no parents.
+   *
+   * <p>2. NoteDb rewriters.
+   *
+   * <p>3. If any of the receive commands is of type {@link
+   * org.eclipse.jgit.transport.ReceiveCommand.Type#UPDATE_NONFASTFORWARD} (for example due to a
+   * force push).
+   *
+   * <p>Note that we don't need to explicitly allow non fast-forward updates for DELETE commands
+   * since JGit forces the update implicitly in this case.
+   */
+  private boolean allowNonFastForwards(ChainedReceiveCommands receiveCommands) {
+    return !draftUpdates.isEmpty()
+        || !rewriters.isEmpty()
+        || receiveCommands.getCommands().values().stream()
+            .anyMatch(cmd -> cmd.getType().equals(ReceiveCommand.Type.UPDATE_NONFASTFORWARD));
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index 7998476..35a014c 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayOutputStream;
@@ -73,7 +74,13 @@
   final Map<Comment.Key, Comment> put;
   private final Set<Comment.Key> delete;
 
-  private List<SubmitRequirementResult> submitRequirementResults;
+  /**
+   * Submit requirement results to be stored in the revision note. If this field is null, we don't
+   * store results in the revision note. Otherwise, we store a "submit requirements" section in the
+   * revision note even if it's empty.
+   */
+  @Nullable private List<SubmitRequirementResult> submitRequirementResults;
+
   private String pushCert;
 
   private RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
@@ -83,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];
@@ -90,7 +98,6 @@
       put = new HashMap<>();
       pushCert = null;
     }
-    submitRequirementResults = new ArrayList<>();
     delete = new HashSet<>();
   }
 
@@ -109,7 +116,22 @@
     put.put(comment.key, comment);
   }
 
+  /**
+   * Call this method to designate that we should store submit requirement results in the revision
+   * note. Even if no results are added, an empty submit requirements section will be added.
+   */
+  void createEmptySubmitRequirementResults() {
+    submitRequirementResults = new ArrayList<>();
+  }
+
+  void clearSubmitRequirementResults() {
+    submitRequirementResults = null;
+  }
+
   void putSubmitRequirementResult(SubmitRequirementResult result) {
+    if (submitRequirementResults == null) {
+      submitRequirementResults = new ArrayList<>();
+    }
     submitRequirementResults.add(result);
   }
 
@@ -140,19 +162,19 @@
 
   private void buildNoteJson(ChangeNoteJson noteUtil, OutputStream out) throws IOException {
     ListMultimap<Integer, Comment> comments = buildCommentMap();
-    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults == null && comments.isEmpty() && pushCert == null) {
       return;
     }
 
     RevisionNoteData data = new RevisionNoteData();
     data.comments = COMMENT_ORDER.sortedCopy(comments.values());
     data.pushCert = pushCert;
-    if (!submitRequirementResults.isEmpty()) {
-      data.submitRequirementResults =
-          submitRequirementResults.stream()
-              .sorted(SUBMIT_REQUIREMENT_RESULT_COMPARATOR)
-              .collect(Collectors.toList());
-    }
+    data.submitRequirementResults =
+        submitRequirementResults == null
+            ? null
+            : submitRequirementResults.stream()
+                .sorted(SUBMIT_REQUIREMENT_RESULT_COMPARATOR)
+                .collect(Collectors.toList());
 
     try (OutputStreamWriter osw = new OutputStreamWriter(out, UTF_8)) {
       noteUtil.getGson().toJson(data, osw);
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index 5a0b67b..98c9873 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.HumanComment;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -42,7 +41,7 @@
   }
 
   static RevisionNoteMap<ChangeRevisionNote> parse(
-      ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, HumanComment.Status status)
+      ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, Comment.Status status)
       throws ConfigInvalidException, IOException {
     ImmutableMap.Builder<ObjectId, ChangeRevisionNote> result = ImmutableMap.builder();
     for (Note note : noteMap) {
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index d53b2ca..2ec68f1 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -15,8 +15,6 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
@@ -94,13 +92,13 @@
     revisionNoteMap =
         RevisionNoteMap.parseRobotComments(
             args.changeNoteJson, reader, NoteMap.read(reader, tipCommit));
-    ListMultimap<ObjectId, RobotComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+    ImmutableListMultimap.Builder<ObjectId, RobotComment> cs = ImmutableListMultimap.builder();
     for (RobotCommentsRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
       for (RobotComment c : rn.getEntities()) {
         cs.put(c.getCommitId(), c);
       }
     }
-    comments = ImmutableListMultimap.copyOf(cs);
+    comments = cs.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
index d13e74d..7f067f5 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -28,9 +28,9 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -57,14 +57,14 @@
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
         PersonIdent authorIdent,
-        Date when);
+        Instant when);
 
     RobotCommentUpdate create(
         Change change,
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
         PersonIdent authorIdent,
-        Date when);
+        Instant when);
   }
 
   private List<RobotComment> put = new ArrayList<>();
@@ -78,7 +78,7 @@
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
+      @Assisted Instant when) {
     super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
   }
 
@@ -91,7 +91,7 @@
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
+      @Assisted Instant when) {
     super(noteUtil, serverIdent, null, change, accountId, realAccountId, authorIdent, when);
   }
 
diff --git a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
index d128633..2e62fc1 100644
--- a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
+++ b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2021 The Android Open Source Project
+// 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.
@@ -14,58 +14,64 @@
 
 package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.server.experiments.ExperimentFeatures;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
-import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
 
 /** A {@link BatchUpdateOp} that stores the evaluated submit requirements of a change in NoteDb. */
 public class StoreSubmitRequirementsOp implements BatchUpdateOp {
-  private final ChangeData.Factory changeDataFactory;
-  private final SubmitRequirementsEvaluator evaluator;
-  private final boolean storeRequirementsInNoteDb;
+  private final Collection<SubmitRequirementResult> submitRequirementResults;
+  private final ChangeData changeData;
+  private final OnStoreSubmitRequirementResultModifier onStoreSubmitRequirementResultModifier;
 
   public interface Factory {
-    StoreSubmitRequirementsOp create();
+
+    /**
+     * {@code submitRequirements} are explicitly passed to the operation so that they are evaluated
+     * before the {@link #updateChange} is called.
+     *
+     * <p>This is because the return results of {@link ChangeData#submitRequirements()} depend on
+     * the status of the change, which can be modified by other {@link BatchUpdateOp}, sharing the
+     * same {@link ChangeContext}.
+     */
+    StoreSubmitRequirementsOp create(
+        Collection<SubmitRequirementResult> submitRequirements, ChangeData changeData);
   }
 
   @Inject
   public StoreSubmitRequirementsOp(
-      ChangeData.Factory changeDataFactory,
-      ExperimentFeatures experimentFeatures,
-      SubmitRequirementsEvaluator evaluator) {
-    this.changeDataFactory = changeDataFactory;
-    this.evaluator = evaluator;
-    this.storeRequirementsInNoteDb =
-        experimentFeatures.isFeatureEnabled(
-            ExperimentFeaturesConstants
-                .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE);
+      OnStoreSubmitRequirementResultModifier onStoreSubmitRequirementResultModifier,
+      @Assisted Collection<SubmitRequirementResult> submitRequirementResults,
+      @Assisted ChangeData changeData) {
+    this.onStoreSubmitRequirementResultModifier = onStoreSubmitRequirementResultModifier;
+    this.submitRequirementResults = submitRequirementResults;
+    this.changeData = changeData;
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx) throws Exception {
-    if (!storeRequirementsInNoteDb) {
-      // Temporarily stop storing submit requirements in NoteDb when the change is merged.
-      return false;
-    }
-    // Create ChangeData using the project/change IDs instead of ctx.getChange(). We do that because
-    // for changes requiring a rebase before submission (e.g. if submit type = RebaseAlways), the
-    // RebaseOp inserts a new patchset that is visible here (via Change#getCurrentPatchset). If we
-    // then try to get ChangeData#currentPatchset it will return null, since it loads patchsets from
-    // NoteDb but tries to find the patchset with the ID of the one just inserted by the rebase op.
-    // Note that this implementation means that, in this case, submit requirement results will be
-    // stored in change notes of the pre last patchset commit. This is fine since submit requirement
-    // results should evaluate to the exact same results for both commits. Additionally, the
-    // pre-last commit is the one for which we displayed the submit requirement results of the last
-    // patchset to the user before it was merged.
-    ChangeData changeData = changeDataFactory.create(ctx.getProject(), ctx.getChange().getId());
     ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    // We do not want to store submit requirements in NoteDb for legacy submit records
-    update.putSubmitRequirementResults(
-        evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false).values());
-    return !changeData.submitRequirements().isEmpty();
+    List<SubmitRequirementResult> nonLegacySubmitRequirements =
+        submitRequirementResults.stream()
+            // We don't store results for legacy submit requirements in NoteDb. While
+            // surfacing submit requirements for closed changes, we load submit records
+            // from NoteDb and convert them to submit requirement results. See
+            // ChangeData#submitRequirements().
+            .filter(srResult -> !srResult.isLegacy())
+            .map(
+                // Pass to OnStoreSubmitRequirementResultModifier for override
+                srResult ->
+                    onStoreSubmitRequirementResultModifier.modifyResultOnStore(
+                        srResult.submitRequirement(), srResult, changeData, ctx))
+            .collect(Collectors.toList());
+    update.putSubmitRequirementResults(nonLegacySubmitRequirements);
+    return !nonLegacySubmitRequirements.isEmpty();
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
index 3caa4d4..a815f57 100644
--- a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
+++ b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
@@ -32,10 +32,14 @@
 
   private static final FieldDescriptor SR_APPLICABILITY_EXPR_RESULT_FIELD =
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(2);
+  private static final FieldDescriptor SR_SUBMITTABILITY_EXPR_RESULT_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(3);
   private static final FieldDescriptor SR_OVERRIDE_EXPR_RESULT_FIELD =
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(4);
   private static final FieldDescriptor SR_LEGACY_FIELD =
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(6);
+  private static final FieldDescriptor SR_FORCED_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(7);
 
   @Override
   public SubmitRequirementResultProto toProto(SubmitRequirementResult r) {
@@ -46,13 +50,19 @@
     if (r.legacy().isPresent()) {
       builder.setLegacy(r.legacy().get());
     }
+    if (r.forced().isPresent()) {
+      builder.setForced(r.forced().get());
+    }
     if (r.applicabilityExpressionResult().isPresent()) {
       builder.setApplicabilityExpressionResult(
           SubmitRequirementExpressionResultSerializer.serialize(
               r.applicabilityExpressionResult().get()));
     }
-    builder.setSubmittabilityExpressionResult(
-        SubmitRequirementExpressionResultSerializer.serialize(r.submittabilityExpressionResult()));
+    if (r.submittabilityExpressionResult().isPresent()) {
+      builder.setSubmittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.serialize(
+              r.submittabilityExpressionResult().get()));
+    }
     if (r.overrideExpressionResult().isPresent()) {
       builder.setOverrideExpressionResult(
           SubmitRequirementExpressionResultSerializer.serialize(
@@ -71,15 +81,20 @@
     if (proto.hasField(SR_LEGACY_FIELD)) {
       builder.legacy(Optional.of(proto.getLegacy()));
     }
+    if (proto.hasField(SR_FORCED_FIELD)) {
+      builder.forced(Optional.of(proto.getForced()));
+    }
     if (proto.hasField(SR_APPLICABILITY_EXPR_RESULT_FIELD)) {
       builder.applicabilityExpressionResult(
           Optional.of(
               SubmitRequirementExpressionResultSerializer.deserialize(
                   proto.getApplicabilityExpressionResult())));
     }
-    builder.submittabilityExpressionResult(
-        SubmitRequirementExpressionResultSerializer.deserialize(
-            proto.getSubmittabilityExpressionResult()));
+    if (proto.hasField(SR_SUBMITTABILITY_EXPR_RESULT_FIELD)) {
+      builder.submittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.deserialize(
+              proto.getSubmittabilityExpressionResult()));
+    }
     if (proto.hasField(SR_OVERRIDE_EXPR_RESULT_FIELD)) {
       builder.overrideExpressionResult(
           Optional.of(
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 9a84398..f29d1c1 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -145,7 +145,7 @@
       return existingCommit.get();
     }
     counter.increment(OperationType.IN_MEMORY_WRITE);
-    logger.atInfo().log("Computing in-memory AutoMerge for " + merge.name());
+    logger.atInfo().log("Computing in-memory AutoMerge for %s", merge.name());
     try (Timer1.Context<OperationType> ignored = latency.start(OperationType.IN_MEMORY_WRITE)) {
       return rw.parseCommit(createAutoMergeCommit(repo.getConfig(), rw, ins, merge, mergeStrategy));
     }
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 d2da736..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;
 
@@ -18,10 +18,14 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import java.util.Map;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * An interface for all file diff related operations. Clients should use this interface to request:
@@ -56,7 +60,31 @@
    *     an internal error occurred in Git while evaluating the diff.
    */
   Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, int parentNum) throws DiffNotAvailableException;
+      Project.NameKey project, ObjectId newCommit, int parentNum, DiffOptions diffOptions)
+      throws DiffNotAvailableException;
+
+  /**
+   * This method is similar to {@link #listModifiedFilesAgainstParent(NameKey, ObjectId, int,
+   * DiffOptions)} but loads the modified files directly instead of retrieving them from the diff
+   * cache.
+   *
+   * <p>A RevWalk and repoConfig are also supplied and are used to look up the commit IDs. This is
+   * useful in case one the commits is currently being created, that's why the {@code revWalk}
+   * parameter is needed.
+   *
+   * <p>Note that rename detection is disabled for this method.
+   *
+   * @return a map of file paths to {@link ModifiedFile}. The {@link ModifiedFile} contains the
+   *     old/new file paths and the change type (added, deleted, etc...).
+   */
+  Map<String, ModifiedFile> loadModifiedFilesAgainstParent(
+      Project.NameKey project,
+      ObjectId newCommit,
+      int parentNum,
+      DiffOptions diffOptions,
+      RevWalk revWalk,
+      Config repoConfig)
+      throws DiffNotAvailableException;
 
   /**
    * Returns the list of added, deleted or modified files between two commits (patchsets). The
@@ -72,7 +100,30 @@
    *     diff.
    */
   Map<String, FileDiffOutput> listModifiedFiles(
-      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit, DiffOptions diffOptions)
+      throws DiffNotAvailableException;
+
+  /**
+   * This method is similar to {@link #listModifiedFilesAgainstParent(NameKey, ObjectId, int,
+   * DiffOptions)} but loads the modified files directly instead of retrieving them from the diff
+   * cache.
+   *
+   * <p>A RevWalk and repoConfig are also supplied and are used to look up the commit IDs. This is
+   * useful in case one the commits is currently being created, that's why the {@code revWalk}
+   * parameter is needed.
+   *
+   * <p>Note that rename detection is disabled for this method.
+   *
+   * @return a map of file paths to {@link ModifiedFile}. The {@link ModifiedFile} contains the
+   *     old/new file paths and the change type (added, deleted, etc...).
+   */
+  Map<String, ModifiedFile> loadModifiedFiles(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      DiffOptions diffOptions,
+      RevWalk revWalk,
+      Config repoConfig)
       throws DiffNotAvailableException;
 
   /**
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index 3423b32..b57ab60 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;
 
@@ -47,7 +47,16 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /**
  * Provides different file diff operations. Uses the underlying Git/Gerrit caches to speed up the
@@ -57,6 +66,19 @@
 public class DiffOperationsImpl implements DiffOperations {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final ImmutableMap<DiffEntry.ChangeType, Patch.ChangeType> changeTypeMap =
+      ImmutableMap.of(
+          DiffEntry.ChangeType.ADD,
+          Patch.ChangeType.ADDED,
+          DiffEntry.ChangeType.MODIFY,
+          Patch.ChangeType.MODIFIED,
+          DiffEntry.ChangeType.DELETE,
+          Patch.ChangeType.DELETED,
+          DiffEntry.ChangeType.RENAME,
+          Patch.ChangeType.RENAMED,
+          DiffEntry.ChangeType.COPY,
+          Patch.ChangeType.COPIED);
+
   private static final int RENAME_SCORE = 60;
   private static final DiffAlgorithm DEFAULT_DIFF_ALGORITHM =
       DiffAlgorithm.HISTOGRAM_WITH_FALLBACK_MYERS;
@@ -91,10 +113,11 @@
 
   @Override
   public Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, int parent) throws DiffNotAvailableException {
+      Project.NameKey project, ObjectId newCommit, int parent, DiffOptions diffOptions)
+      throws DiffNotAvailableException {
     try {
       DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
-      return getModifiedFiles(diffParams);
+      return getModifiedFiles(diffParams, diffOptions);
     } catch (IOException e) {
       throw new DiffNotAvailableException(
           "Failed to evaluate the parent/base commit for commit " + newCommit, e);
@@ -102,8 +125,29 @@
   }
 
   @Override
+  public Map<String, ModifiedFile> loadModifiedFilesAgainstParent(
+      Project.NameKey project,
+      ObjectId newCommit,
+      int parentNum,
+      DiffOptions diffOptions,
+      RevWalk revWalk,
+      Config repoConfig)
+      throws DiffNotAvailableException {
+    try {
+      DiffParameters diffParams = computeDiffParameters(project, newCommit, parentNum);
+      return loadModifiedFilesWithoutCache(project, diffParams, revWalk, repoConfig);
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          String.format(
+              "Failed to evaluate the parent/base commit for commit '%s' with parentNum=%d",
+              newCommit, parentNum),
+          e);
+    }
+  }
+
+  @Override
   public Map<String, FileDiffOutput> listModifiedFiles(
-      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit, DiffOptions diffOptions)
       throws DiffNotAvailableException {
     DiffParameters params =
         DiffParameters.builder()
@@ -112,7 +156,26 @@
             .baseCommit(oldCommit)
             .comparisonType(ComparisonType.againstOtherPatchSet())
             .build();
-    return getModifiedFiles(params);
+    return getModifiedFiles(params, diffOptions);
+  }
+
+  @Override
+  public Map<String, ModifiedFile> loadModifiedFiles(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      DiffOptions diffOptions,
+      RevWalk revWalk,
+      Config repoConfig)
+      throws DiffNotAvailableException {
+    DiffParameters params =
+        DiffParameters.builder()
+            .project(project)
+            .newCommit(newCommit)
+            .baseCommit(oldCommit)
+            .comparisonType(ComparisonType.againstOtherPatchSet())
+            .build();
+    return loadModifiedFilesWithoutCache(project, params, revWalk, repoConfig);
   }
 
   @Override
@@ -161,8 +224,8 @@
     return getModifiedFileForKey(key);
   }
 
-  private ImmutableMap<String, FileDiffOutput> getModifiedFiles(DiffParameters diffParams)
-      throws DiffNotAvailableException {
+  private ImmutableMap<String, FileDiffOutput> getModifiedFiles(
+      DiffParameters diffParams, DiffOptions diffOptions) throws DiffNotAvailableException {
     try {
       Project.NameKey project = diffParams.project();
       ObjectId newCommit = diffParams.newCommit();
@@ -211,7 +274,7 @@
                         /* whitespace= */ null))
             .forEach(fileCacheKeys::add);
       }
-      return getModifiedFilesForKeys(fileCacheKeys);
+      return getModifiedFilesForKeys(fileCacheKeys, diffOptions);
     } catch (IOException e) {
       throw new DiffNotAvailableException(e);
     }
@@ -219,7 +282,8 @@
 
   private FileDiffOutput getModifiedFileForKey(FileDiffCacheKey key)
       throws DiffNotAvailableException {
-    Map<String, FileDiffOutput> diffList = getModifiedFilesForKeys(ImmutableList.of(key));
+    Map<String, FileDiffOutput> diffList =
+        getModifiedFilesForKeys(ImmutableList.of(key), DiffOptions.DEFAULTS);
     return diffList.containsKey(key.newFilePath())
         ? diffList.get(key.newFilePath())
         : FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
@@ -230,8 +294,8 @@
    * results, e.g. due to timeouts in the cache loader, this method requests the diff again using
    * the fallback algorithm {@link DiffAlgorithm#HISTOGRAM_NO_FALLBACK}.
    */
-  private ImmutableMap<String, FileDiffOutput> getModifiedFilesForKeys(List<FileDiffCacheKey> keys)
-      throws DiffNotAvailableException {
+  private ImmutableMap<String, FileDiffOutput> getModifiedFilesForKeys(
+      List<FileDiffCacheKey> keys, DiffOptions diffOptions) throws DiffNotAvailableException {
     ImmutableMap<FileDiffCacheKey, FileDiffOutput> fileDiffs = fileDiffCache.getAll(keys);
     List<FileDiffCacheKey> fallbackKeys = new ArrayList<>();
 
@@ -251,7 +315,7 @@
                 DiffAlgorithm.HISTOGRAM_NO_FALLBACK,
                 // We don't enforce timeouts with the fallback algorithm. Timeouts were introduced
                 // because of a bug in JGit that happens only when the histogram algorithm uses
-                // Myers as fallback. See https://bugs.chromium.org/p/gerrit/issues/detail?id=487
+                // Myers as fallback. See https://issues.gerritcodereview.com/issues/40000618
                 /* useTimeout= */ false,
                 key.whitespace());
         fallbackKeys.add(fallbackKey);
@@ -260,7 +324,7 @@
       }
     }
     result.addAll(fileDiffCache.getAll(fallbackKeys).values());
-    return mapByFilePath(result.build());
+    return mapByFilePath(result.build(), diffOptions);
   }
 
   /**
@@ -268,11 +332,12 @@
    * represent the old file path for deleted files, or the new path otherwise.
    */
   private ImmutableMap<String, FileDiffOutput> mapByFilePath(
-      ImmutableCollection<FileDiffOutput> fileDiffOutputs) {
+      ImmutableCollection<FileDiffOutput> fileDiffOutputs, DiffOptions diffOptions) {
     ImmutableMap.Builder<String, FileDiffOutput> diffs = ImmutableMap.builder();
 
     for (FileDiffOutput fileDiffOutput : fileDiffOutputs) {
-      if (fileDiffOutput.isEmpty() || allDueToRebase(fileDiffOutput)) {
+      if (fileDiffOutput.isEmpty()
+          || (diffOptions.skipFilesWithAllEditsDueToRebase() && allDueToRebase(fileDiffOutput))) {
         continue;
       }
       if (fileDiffOutput.changeType() == ChangeType.DELETED) {
@@ -286,8 +351,8 @@
 
   private static boolean allDueToRebase(FileDiffOutput fileDiffOutput) {
     return fileDiffOutput.allEditsDueToRebase()
-        && (!(fileDiffOutput.changeType() == ChangeType.RENAMED
-            || fileDiffOutput.changeType() == ChangeType.COPIED));
+        && !(fileDiffOutput.changeType() == ChangeType.RENAMED
+            || fileDiffOutput.changeType() == ChangeType.COPIED);
   }
 
   private boolean isMergeAgainstParent(ComparisonType cmp, Project.NameKey project, ObjectId commit)
@@ -326,6 +391,53 @@
         .build();
   }
 
+  /** Loads the modified file paths between two commits without inspecting the diff cache. */
+  private static Map<String, ModifiedFile> loadModifiedFilesWithoutCache(
+      Project.NameKey project, DiffParameters diffParams, RevWalk revWalk, Config repoConfig)
+      throws DiffNotAvailableException {
+    ObjectId newCommit = diffParams.newCommit();
+    ObjectId oldCommit = diffParams.baseCommit();
+    try {
+      ObjectReader reader = revWalk.getObjectReader();
+      List<DiffEntry> diffEntries;
+      try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+        df.setReader(reader, repoConfig);
+        df.setDetectRenames(false);
+        diffEntries = df.scan(oldCommit.equals(ObjectId.zeroId()) ? null : oldCommit, newCommit);
+      }
+      List<ModifiedFile> modifiedFiles =
+          diffEntries.stream()
+              .map(
+                  entry ->
+                      ModifiedFile.builder()
+                          .changeType(toChangeType(entry.getChangeType()))
+                          .oldPath(getGitPath(entry.getOldPath()))
+                          .newPath(getGitPath(entry.getNewPath()))
+                          .build())
+              .collect(Collectors.toList());
+      return DiffUtil.mergeRewrittenModifiedFiles(modifiedFiles).stream()
+          .collect(ImmutableMap.toImmutableMap(ModifiedFile::getDefaultPath, Function.identity()));
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          String.format(
+              "Failed to compute the modified files for project '%s',"
+                  + " old commit '%s', new commit '%s'.",
+              project, oldCommit.name(), newCommit.name()),
+          e);
+    }
+  }
+
+  private static Optional<String> getGitPath(String path) {
+    return path.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(path);
+  }
+
+  private static Patch.ChangeType toChangeType(DiffEntry.ChangeType changeType) {
+    if (!changeTypeMap.containsKey(changeType)) {
+      throw new IllegalArgumentException("Unsupported type " + changeType);
+    }
+    return changeTypeMap.get(changeType);
+  }
+
   @AutoValue
   abstract static class DiffParameters {
     abstract Project.NameKey project();
diff --git a/java/com/google/gerrit/server/patch/DiffOptions.java b/java/com/google/gerrit/server/patch/DiffOptions.java
new file mode 100644
index 0000000..4d54be1
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffOptions.java
@@ -0,0 +1,36 @@
+// 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.patch;
+
+import com.google.auto.value.AutoValue;
+
+@AutoValue
+public abstract class DiffOptions {
+  public static final DiffOptions DEFAULTS =
+      DiffOptions.builder().skipFilesWithAllEditsDueToRebase(true).build();
+
+  public abstract boolean skipFilesWithAllEditsDueToRebase();
+
+  public static DiffOptions.Builder builder() {
+    return new AutoValue_DiffOptions.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder skipFilesWithAllEditsDueToRebase(boolean value);
+
+    public abstract DiffOptions build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
index fcce672..246544b 100644
--- a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
+++ b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -48,8 +48,9 @@
     ObjectId newId = key.toPatchListKey().getNewId();
     Map<String, FileDiffOutput> diffList =
         oldId == null
-            ? diffOperations.listModifiedFilesAgainstParent(project, newId, /* parentNum= */ 0)
-            : diffOperations.listModifiedFiles(project, oldId, newId);
+            ? diffOperations.listModifiedFilesAgainstParent(
+                project, newId, /* parentNum= */ 0, DiffOptions.DEFAULTS)
+            : diffOperations.listModifiedFiles(project, oldId, newId, DiffOptions.DEFAULTS);
     return toDiffSummary(diffList);
   }
 
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
index 1e88f9f..70a3208 100644
--- a/java/com/google/gerrit/server/patch/DiffUtil.java
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -15,9 +15,16 @@
 
 package com.google.gerrit.server.patch;
 
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
 import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import java.io.IOException;
+import java.util.Comparator;
+import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -29,6 +36,41 @@
 public class DiffUtil {
 
   /**
+   * Return the {@code modifiedFiles} input list while merging rewritten entries.
+   *
+   * <p>Background: In some cases, JGit returns two diff entries (ADDED/DELETED, RENAMED/DELETED,
+   * etc...) for the same file path. This happens e.g. when a file's mode is changed between
+   * patchsets, for example converting a symlink file to a regular file. We identify this case and
+   * return a single modified file with changeType = {@link ChangeType#REWRITE}.
+   */
+  public static ImmutableList<ModifiedFile> mergeRewrittenModifiedFiles(
+      List<ModifiedFile> modifiedFiles) {
+    ImmutableList.Builder<ModifiedFile> result = ImmutableList.builder();
+    ListMultimap<String, ModifiedFile> byPath = ArrayListMultimap.create();
+    modifiedFiles.stream()
+        .forEach(
+            f -> {
+              if (f.changeType() == ChangeType.DELETED) {
+                byPath.get(f.oldPath().get()).add(f);
+              } else {
+                byPath.get(f.newPath().get()).add(f);
+              }
+            });
+    for (String path : byPath.keySet()) {
+      List<ModifiedFile> entries = byPath.get(path);
+      if (entries.size() == 1) {
+        result.add(entries.get(0));
+      } else {
+        // More than one. Return a single REWRITE entry.
+        // Convert the first entry (prioritized according to change type enum order) to REWRITE
+        entries.sort(Comparator.comparingInt(o -> o.changeType().ordinal()));
+        result.add(entries.get(0).toBuilder().changeType(ChangeType.REWRITE).build());
+      }
+    }
+    return result.build();
+  }
+
+  /**
    * Returns the Git tree object ID pointed to by the commitId parameter.
    *
    * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
diff --git a/java/com/google/gerrit/server/patch/MergeListBuilder.java b/java/com/google/gerrit/server/patch/MergeListBuilder.java
index 433fcad..8964956 100644
--- a/java/com/google/gerrit/server/patch/MergeListBuilder.java
+++ b/java/com/google/gerrit/server/patch/MergeListBuilder.java
@@ -16,13 +16,11 @@
 
 import com.google.common.collect.ImmutableList;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 public class MergeListBuilder {
-  public static List<RevCommit> build(RevWalk rw, RevCommit merge, int uninterestingParent)
+  public static ImmutableList<RevCommit> build(RevWalk rw, RevCommit merge, int uninterestingParent)
       throws IOException {
     rw.reset();
     rw.parseBody(merge);
@@ -40,11 +38,11 @@
       }
     }
 
-    List<RevCommit> result = new ArrayList<>();
+    ImmutableList.Builder<RevCommit> result = ImmutableList.builder();
     RevCommit c;
     while ((c = rw.next()) != null) {
       result.add(c);
     }
-    return result;
+    return result.build();
   }
 }
diff --git a/java/com/google/gerrit/server/patch/PatchFile.java b/java/com/google/gerrit/server/patch/PatchFile.java
index 81355cc..7a8180bd 100644
--- a/java/com/google/gerrit/server/patch/PatchFile.java
+++ b/java/com/google/gerrit/server/patch/PatchFile.java
@@ -57,8 +57,9 @@
       throws IOException {
     this.repo = repo;
     this.diff =
-        modifiedFiles.values().stream()
-            .filter(f -> f.newPath().isPresent() && f.newPath().get().equals(fileName))
+        modifiedFiles.entrySet().stream()
+            .filter(f -> f.getKey().equals(fileName))
+            .map(Map.Entry::getValue)
             .findFirst()
             .orElse(FileDiffOutput.empty(fileName, ObjectId.zeroId(), ObjectId.zeroId()));
 
@@ -96,7 +97,13 @@
         bTree = null;
       } else {
         if (diff.oldCommitId() != null) {
-          aTree = rw.parseTree(diff.oldCommitId());
+          if (diff.oldCommitId().equals(ObjectId.zeroId())) {
+            // DiffOperations returns ObjectId.zeroId if newCommit is a root commit, i.e. has no
+            // parents.
+            aTree = null;
+          } else {
+            aTree = rw.parseTree(diff.oldCommitId());
+          }
         } else {
           final RevCommit p = bCommit.getParent(0);
           rw.parseHeaders(p);
diff --git a/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index b983fb8..4efbc69 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -55,7 +55,7 @@
    * We use the ChangeType comparator for a rare case when PatchList contains two entries for the
    * same file, e.g. {ADDED, DELETED}. We return a single entry according to the following order.
    * Check the following bug for an example case:
-   * https://bugs.chromium.org/p/gerrit/issues/detail?id=13914.
+   * https://issues.gerritcodereview.com/issues/40013315.
    */
   @VisibleForTesting
   static class ChangeTypeCmp implements Comparator<ChangeType> {
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 572d73d..2385a70 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -70,6 +70,7 @@
  */
 public class SubmitWithStickyApprovalDiff {
   private static final int HEAP_EST_SIZE = 32 * 1024;
+  private static final int DEFAULT_POST_SUBMIT_SIZE_LIMIT = 300 * 1024; // 300 KiB
 
   private final DiffOperations diffOperations;
   private final ProjectCache projectCache;
@@ -88,6 +89,15 @@
     this.projectCache = projectCache;
     this.patchScriptFactoryFactory = patchScriptFactoryFactory;
     this.repositoryManager = repositoryManager;
+    // (November 2021) We define the max cumulative comment size to 300 KIB since it's a reasonable
+    // size that is large enough for all purposes but not too large to choke the change index by
+    // exceeding the cumulative comment size limit (new comments are not allowed once the limit
+    // is reached). At Google, the change index limit is 5MB, while the cumulative size limit is
+    // set at 3MB. In this example, we can reach at most 3.3MB hence we ensure not to exceed the
+    // limit of 5MB.
+    // The reason we exclude the post submit diff from the cumulative comment size limit is
+    // just because change messages not currently being validated. Change messages are still
+    // counted towards the limit, though.
     maxCumulativeSize =
         serverConfig.getInt(
             "change",
@@ -129,7 +139,9 @@
 
     diff.append("The change was submitted with unreviewed changes in the following files:\n\n");
     TemporaryBuffer.Heap buffer =
-        new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxCumulativeSize), maxCumulativeSize);
+        new TemporaryBuffer.Heap(
+            Math.min(HEAP_EST_SIZE, DEFAULT_POST_SUBMIT_SIZE_LIMIT),
+            DEFAULT_POST_SUBMIT_SIZE_LIMIT);
     try (Repository repository = repositoryManager.openRepository(notes.getProjectName());
         DiffFormatter formatter = new DiffFormatter(buffer)) {
       formatter.setRepository(repository);
@@ -150,6 +162,12 @@
           throw e;
         }
       }
+      if (formatterResult != null) {
+        int addedBytes = formatterResult.stream().mapToInt(String::length).sum();
+        if (!CommentCumulativeSizeValidator.isEnoughSpace(notes, addedBytes, maxCumulativeSize)) {
+          isDiffTooLarge = true;
+        }
+      }
       for (FileDiffOutput fileDiff : modifiedFilesList) {
         diff.append(
             getDiffForFile(
@@ -299,7 +317,8 @@
   private Map<String, FileDiffOutput> listModifiedFiles(
       Project.NameKey project, PatchSet ps, PatchSet priorPatchSet) {
     try {
-      return diffOperations.listModifiedFiles(project, priorPatchSet.commitId(), ps.commitId());
+      return diffOperations.listModifiedFiles(
+          project, priorPatchSet.commitId(), ps.commitId(), DiffOptions.DEFAULTS);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't post diff messsage on submit although "
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 b4d8c04..41f2fed 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;
 
@@ -18,14 +18,11 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
@@ -40,7 +37,6 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Stream;
@@ -143,14 +139,15 @@
               .bTree(bTree)
               .renameScore(key.renameScore())
               .build();
-      List<ModifiedFile> modifiedFiles = mergeRewrittenEntries(gitCache.get(gitKey));
+      ImmutableList<ModifiedFile> modifiedFiles =
+          DiffUtil.mergeRewrittenModifiedFiles(gitCache.get(gitKey));
       if (key.aCommit().equals(ObjectId.zeroId())) {
-        return ImmutableList.copyOf(modifiedFiles);
+        return modifiedFiles;
       }
       RevCommit revCommitA = DiffUtil.getRevCommit(rw, key.aCommit());
       RevCommit revCommitB = DiffUtil.getRevCommit(rw, key.bCommit());
       if (DiffUtil.areRelated(revCommitA, revCommitB)) {
-        return ImmutableList.copyOf(modifiedFiles);
+        return modifiedFiles;
       }
       Set<String> touchedFiles =
           getTouchedFilesWithParents(
@@ -206,37 +203,5 @@
       // value as the set of file paths shouldn't contain it.
       return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
     }
-
-    /**
-     * Return the {@code modifiedFiles} input list while merging rewritten entries.
-     *
-     * <p>Background: In some cases, JGit returns two diff entries (ADDED/DELETED, RENAMED/DELETED,
-     * etc...) for the same file path. This happens e.g. when a file's mode is changed between
-     * patchsets, for example converting a symlink file to a regular file. We identify this case and
-     * return a single modified file with changeType = {@link ChangeType#REWRITE}.
-     */
-    private static List<ModifiedFile> mergeRewrittenEntries(List<ModifiedFile> modifiedFiles) {
-      List<ModifiedFile> result = new ArrayList<>();
-      ListMultimap<String, ModifiedFile> byPath = ArrayListMultimap.create();
-      modifiedFiles.stream()
-          .forEach(
-              f -> {
-                if (f.changeType() == ChangeType.DELETED) {
-                  byPath.get(f.oldPath().get()).add(f);
-                } else {
-                  byPath.get(f.newPath().get()).add(f);
-                }
-              });
-      for (String path : byPath.keySet()) {
-        List<ModifiedFile> entries = byPath.get(path);
-        if (entries.size() == 1) {
-          result.add(entries.get(0));
-        } else {
-          // More than one. Return a single REWRITE entry.
-          result.add(entries.get(0).toBuilder().changeType(ChangeType.REWRITE).build());
-        }
-      }
-      return result;
-    }
   }
 }
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 9d5dc2d..b70f6e1 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 f4e7ca3..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;
 
@@ -47,6 +47,10 @@
    */
   public abstract Optional<String> newPath();
 
+  public String getDefaultPath() {
+    return newPath().isPresent() ? newPath().get() : oldPath().get();
+  }
+
   public static Builder builder() {
     return new AutoValue_ModifiedFile.Builder();
   }
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 44af22e..0cfaa66 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;
 
@@ -60,7 +60,6 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
-import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.DiffEntry;
 import org.eclipse.jgit.diff.DiffEntry.ChangeType;
@@ -156,16 +155,6 @@
   }
 
   static class Loader extends CacheLoader<GitFileDiffCacheKey, GitFileDiff> {
-    /**
-     * Extractor for the file path from a {@link DiffEntry}. Returns the old file path if the entry
-     * corresponds to a deleted file, otherwise it returns the new file path.
-     */
-    private static final Function<DiffEntry, String> pathExtractor =
-        (DiffEntry entry) ->
-            entry.getChangeType().equals(ChangeType.DELETE)
-                ? entry.getOldPath()
-                : entry.getNewPath();
-
     private final GitRepositoryManager repoManager;
     private final ExecutorService diffExecutor;
     private final long timeoutMillis;
@@ -294,10 +283,10 @@
               diffOptions.newTree());
 
       return diffEntries.stream()
-          .filter(d -> filePathsSet.contains(pathExtractor.apply(d)))
+          .filter(d -> filePathsSet.contains(extractPath(d)))
           .collect(
               Multimaps.toMultimap(
-                  d -> pathExtractor.apply(d),
+                  Loader::extractPath,
                   identity(),
                   MultimapBuilder.treeKeys().arrayListValues()::build));
     }
@@ -369,7 +358,7 @@
               });
       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
+        // https://issues.gerritcodereview.com/issues/40000618 for more details. The bug may happen
         // if the algorithm used in diffs is HISTOGRAM_WITH_FALLBACK_MYERS.
         return fileDiffFuture.get(timeoutMillis, TimeUnit.MILLISECONDS);
       } catch (InterruptedException | TimeoutException e) {
@@ -386,6 +375,16 @@
         throw new IOException(e.getMessage(), e.getCause());
       }
     }
+
+    /**
+     * Extract the file path from a {@link DiffEntry}. Returns the old file path if the entry
+     * corresponds to a deleted file, otherwise it returns the new file path.
+     */
+    private static String extractPath(DiffEntry diffEntry) {
+      return diffEntry.getChangeType().equals(ChangeType.DELETE)
+          ? diffEntry.getOldPath()
+          : diffEntry.getNewPath();
+    }
   }
 
   /**
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/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index b5f5283..cadb21c 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -92,8 +92,7 @@
 
   /** Can this user revert this change? */
   private boolean canRevert() {
-    return (refControl.canRevert())
-        && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
+    return refControl.canRevert() && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
   }
 
   /** The range of permitted values associated with a label permission. */
@@ -257,7 +256,7 @@
           case ABANDON:
             return canAbandon();
           case DELETE:
-            return (getProjectControl().isAdmin() || (refControl.canDeleteChanges(isOwner())));
+            return getProjectControl().isAdmin() || refControl.canDeleteChanges(isOwner());
           case ADD_PATCH_SET:
             return canAddPatchSet();
           case EDIT_ASSIGNEE:
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 e8a9996..f179045 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -15,32 +15,34 @@
 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.base.Supplier;
+import com.google.common.base.Suppliers;
 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.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.git.ChangesByProjectCache;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TagMatcher;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
-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.Singleton;
 import com.google.inject.assistedinject.Assisted;
@@ -48,6 +50,7 @@
 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;
@@ -83,37 +86,35 @@
   }
 
   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 ChangesByProjectCache changesByProjectCache;
+  private final ChangeData.Factory changeDataFactory;
   private final Metrics metrics;
   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,
       Metrics metrics,
-      VisibleChangesCache.Factory visibleChangesCacheFactory,
+      ChangesByProjectCache changesByProjectCache,
+      ChangeData.Factory changeDataFactory,
       @Assisted ProjectControl projectControl) {
     this.tagCache = tagCache;
-    this.changeNotesFactory = changeNotesFactory;
     this.permissionBackend = permissionBackend;
     this.refVisibilityControl = refVisibilityControl;
+    this.changesByProjectCache = changesByProjectCache;
+    this.changeDataFactory = changeDataFactory;
     this.skipFullRefEvaluationIfAllRefsAreVisible =
         config.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true);
     this.projectControl = projectControl;
-    this.visibleChangesCacheFactory = visibleChangesCacheFactory;
 
     this.user = projectControl.getUser();
     this.projectState = projectControl.getProjectState();
@@ -123,9 +124,8 @@
   }
 
   /** Filters given refs and tags by visibility. */
-  Collection<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
+  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);
@@ -138,32 +138,25 @@
         "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 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);
-    List<Ref> visibleRefs = initialRefFilter.visibleRefs();
+    Supplier<ImmutableMap<Change.Id, ChangeData>> visibleChanges =
+        Suppliers.memoize(
+            () ->
+                GitVisibleChangeFilter.getVisibleChanges(
+                    changesByProjectCache,
+                    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/* "
@@ -188,8 +181,9 @@
       }
     }
 
-    logger.atFinest().log("visible refs = %s", visibleRefs);
-    return visibleRefs;
+    ImmutableList<Ref> visibleRefList = visibleRefs.build();
+    logger.atFinest().log("visible refs = %s", visibleRefList);
+    return visibleRefList;
   }
 
   /**
@@ -197,25 +191,34 @@
    * 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,
+      Supplier<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) {
         metrics.skipFilterCount.increment();
         logger.atFinest().log(
             "Fast path, all refs are visible because user has READ on refs/*: %s", refs);
-        return new AutoValue_DefaultRefFilter_Result(refs, ImmutableList.of());
+        return new AutoValue_DefaultRefFilter_Result(
+            ImmutableList.copyOf(refs), ImmutableList.of());
       } else if (projectControl.allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG))) {
         metrics.skipFilterCount.increment();
         refs = fastHideRefsMetaConfig(refs);
         logger.atFinest().log(
             "Fast path, all refs except %s are visible: %s", RefNames.REFS_CONFIG, refs);
-        return new AutoValue_DefaultRefFilter_Result(refs, ImmutableList.of());
+        return new AutoValue_DefaultRefFilter_Result(
+            ImmutableList.copyOf(refs), ImmutableList.of());
       }
     }
     logger.atFinest().log("Doing full ref filtering");
@@ -225,8 +228,8 @@
         permissionBackend
             .user(projectControl.getUser())
             .testOrFalse(GlobalPermission.ACCESS_DATABASE);
-    List<Ref> resultRefs = new ArrayList<>(refs.size());
-    List<Ref> deferredTags = new ArrayList<>();
+    ImmutableList.Builder<Ref> resultRefs = ImmutableList.builderWithExpectedSize(refs.size());
+    ImmutableList.Builder<Ref> deferredTags = ImmutableList.builder();
     for (Ref ref : refs) {
       String refName = ref.getName();
       Change.Id changeId;
@@ -262,9 +265,9 @@
         // most recent changes).
         if (hasAccessDatabase) {
           resultRefs.add(ref);
-        } else if (!visibleChangesCache.isVisible(changeId)) {
+        } else if (!visibleChanges.get().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.get())) {
           logger.atFinest().log("Filter out invisible change edit ref %s", refName);
         } else {
           // Change is visible
@@ -274,7 +277,7 @@
         resultRefs.add(ref);
       }
     }
-    Result result = new AutoValue_DefaultRefFilter_Result(resultRefs, deferredTags);
+    Result result = new AutoValue_DefaultRefFilter_Result(resultRefs.build(), deferredTags.build());
     logger.atFinest().log("Result of ref filtering = %s", result);
     return result;
   }
@@ -302,6 +305,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()
@@ -311,7 +327,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);
@@ -320,23 +337,19 @@
 
     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)) {
-      try {
-        // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
-        permissionBackendForProject
-            .ref(visibleChangesCache.getBranchNameKey(id).branch())
-            .check(RefPermission.READ_PRIVATE_CHANGES);
-        logger.atFinest().log("Foreign change edit ref is visible: %s", name);
-        return true;
-      } catch (AuthException e) {
-        logger.atFinest().log("Foreign change edit ref is not visible: %s", name);
-        return false;
-      }
+    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(dest.branch()).test(RefPermission.READ_PRIVATE_CHANGES);
+      logger.atFinest().log(
+          "Foreign change edit ref is " + (canRead ? "visible" : "invisible") + ": %s", name);
+      return canRead;
     }
 
     logger.atFinest().log("Change %d of change edit ref %s is not visible", id.get(), name);
@@ -354,70 +367,24 @@
   }
 
   private boolean canReadRef(String ref) throws PermissionBackendException {
-    try {
-      permissionBackendForProject.ref(ref).check(RefPermission.READ);
-    } catch (AuthException e) {
-      return false;
-    }
-    return projectState.statePermitsRead();
+    return permissionBackendForProject.ref(ref).test(RefPermission.READ);
   }
 
   private boolean checkProjectPermission(
       PermissionBackend.ForProject forProject, ProjectPermission perm)
       throws PermissionBackendException {
-    try {
-      forProject.check(perm);
-    } catch (AuthException e) {
-      return false;
-    }
-    return true;
-  }
-
-  /**
-   * 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);
-    }
-    try {
-      permissionBackendForProject.change(notes).check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return forProject.test(perm);
   }
 
   @AutoValue
   abstract static class Result {
     /** Subset of the refs passed into the computation that is visible to the user. */
-    abstract List<Ref> visibleRefs();
+    abstract ImmutableList<Ref> visibleRefs();
 
     /**
      * List of tags where we couldn't figure out visibility in the first pass and need to do an
      * expensive ref walk.
      */
-    abstract List<Ref> deferredTags();
+    abstract ImmutableList<Ref> deferredTags();
   }
 }
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..a23228f
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -0,0 +1,114 @@
+// 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.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.ChangesByProjectCache;
+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 we use the ChangesByProjectCache.
+ * </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(
+      ChangesByProjectCache changesByProjectCache,
+      ChangeData.Factory changeDataFactory,
+      Project.NameKey projectName,
+      PermissionBackend.ForProject forProject,
+      Repository repository,
+      ImmutableSet<Change.Id> changes) {
+    Stream<ChangeData> changeDatas = Stream.empty();
+    if (changes.size() < CHANGE_LIMIT_FOR_DIRECT_FILTERING) {
+      changeDatas = loadChangeDatasOneByOne(changes, changeDataFactory, projectName);
+    } else {
+      try {
+        changeDatas = changesByProjectCache.streamChangeDatas(projectName, repository);
+      } catch (IOException e) {
+        logger.atWarning().withCause(e).log("Unable to streamChangeDatas for %s", projectName);
+      }
+    }
+
+    return changeDatas
+        .filter(cd -> changes.contains(cd.getId()))
+        .filter(
+            cd -> {
+              try {
+                return forProject.change(cd).test(ChangePermission.READ);
+              } catch (PermissionBackendException e) {
+                // This is almost the same as the message .testOrFalse() would log, but with the
+                // added context of the change and coming from this class
+                logger.atWarning().withCause(e).log(
+                    "Cannot test read permission for %s; assuming not visible", cd);
+                return false;
+              }
+            })
+        .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);
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index d40b138..8c731f6 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -173,7 +173,13 @@
       return ref(notes.getChange().getDest()).change(notes);
     }
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(GlobalOrPluginPermission)}.
+     */
     public abstract void check(GlobalOrPluginPermission perm)
         throws AuthException, PermissionBackendException;
 
@@ -240,10 +246,9 @@
       Set<Project.NameKey> allowed = Sets.newHashSetWithExpectedSize(projects.size());
       for (Project.NameKey project : projects) {
         try {
-          project(project).check(perm);
-          allowed.add(project);
-        } catch (AuthException e) {
-          // Do not include this project in allowed.
+          if (project(project).test(perm)) {
+            allowed.add(project);
+          }
         } catch (PermissionBackendException e) {
           if (e.getCause() instanceof RepositoryNotFoundException) {
             logger.atWarning().withCause(e).log(
@@ -280,7 +285,13 @@
       return ref(notes.getChange().getDest().branch()).change(notes);
     }
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(CoreOrPluginProjectPermission)}.
+     */
     public abstract void check(CoreOrPluginProjectPermission perm)
         throws AuthException, PermissionBackendException;
 
@@ -368,7 +379,13 @@
     /** Returns an instance scoped to change. */
     public abstract ForChange change(ChangeNotes notes);
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(RefPermission)}.
+     */
     public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
 
     /** Filter {@code permSet} to permissions scoped user might be able to perform. */
@@ -406,7 +423,13 @@
     /** Returns the fully qualified resource path that this instance is scoped to. */
     public abstract String resourcePath();
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(ChangePermissionOrLabel)}.
+     */
     public abstract void check(ChangePermissionOrLabel perm)
         throws AuthException, PermissionBackendException;
 
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 8100457d..7f2e62b 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -165,9 +165,8 @@
 
   boolean isAdmin() {
     try {
-      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-      return true;
-    } catch (AuthException | PermissionBackendException e) {
+      return permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER);
+    } catch (PermissionBackendException e) {
       return false;
     }
   }
@@ -347,7 +346,6 @@
   }
 
   private class ForProjectImpl extends ForProject {
-    private DefaultRefFilter refFilter;
     private String resourcePath;
 
     @Override
@@ -424,10 +422,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/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 484fa39..1a3741b 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -271,13 +271,7 @@
       case UNKNOWN:
       case WEB_BROWSER:
       default:
-        return
-        // We allow owner to delete refs even if they have no force-push rights. We forbid
-        // it if force push is blocked, though. See commit 40bd5741026863c99bea13eb5384bd27855c5e1b
-        (isOwner() && !isBlocked(Permission.PUSH, false, true))
-            || canPushWithForce()
-            || canPerform(Permission.DELETE)
-            || projectControl.isAdmin();
+        return canPushWithForce() || canPerform(Permission.DELETE) || projectControl.isAdmin();
     }
   }
 
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
index cc6387b..c2d1139 100644
--- a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -170,17 +169,14 @@
       return true;
     }
 
-    try {
-      // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
-      projectControl
-          .asForProject()
-          .ref(cd.change().getDest().branch())
-          .check(RefPermission.READ_PRIVATE_CHANGES);
-      logger.atFinest().log("Foreign change edit ref is visible: %s", refName);
-      return true;
-    } catch (AuthException e) {
-      logger.atFinest().log("Foreign change edit ref is not visible: %s", refName);
-      return false;
-    }
+    // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
+    boolean canRead =
+        projectControl
+            .asForProject()
+            .ref(cd.change().getDest().branch())
+            .test(RefPermission.READ_PRIVATE_CHANGES);
+    logger.atFinest().log(
+        "Foreign change edit ref is " + (canRead ? "visible" : "invisible") + ": %s", refName);
+    return canRead;
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index e64f8b6..552d8ee 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -125,14 +125,15 @@
   abstract static class EntryKey {
     public abstract String ref();
 
-    public abstract List<String> patterns();
+    public abstract ImmutableList<String> patterns();
 
     static EntryKey create(String refName, List<AccessSection> sections) {
-      List<String> patterns = new ArrayList<>(sections.size());
+      ImmutableList.Builder<String> patterns =
+          ImmutableList.builderWithExpectedSize(sections.size());
       for (AccessSection s : sections) {
         patterns.add(s.getName());
       }
-      return new AutoValue_SectionSortCache_EntryKey(refName, ImmutableList.copyOf(patterns));
+      return new AutoValue_SectionSortCache_EntryKey(refName, patterns.build());
     }
 
     @Memoized
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 6bb3204..0000000
--- a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
+++ /dev/null
@@ -1,107 +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 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.server.git.ChangesByProjectCache;
-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 the changes in a repository visible by the current user. */
-class VisibleChangesCache {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  interface Factory {
-    VisibleChangesCache create(ProjectControl projectControl, Repository repository);
-  }
-
-  private final ProjectState projectState;
-  private final PermissionBackend.ForProject permissionBackendForProject;
-  private final ChangesByProjectCache changesByProjectCache;
-
-  private final Repository repository;
-  private Map<Change.Id, BranchNameKey> visibleChanges;
-
-  @Inject
-  VisibleChangesCache(
-      PermissionBackend permissionBackend,
-      ChangesByProjectCache changesByProjectCache,
-      @Assisted ProjectControl projectControl,
-      @Assisted Repository repository) {
-    this.projectState = projectControl.getProjectState();
-    this.permissionBackendForProject =
-        permissionBackend.user(projectControl.getUser()).project(projectState.getNameKey());
-    this.changesByProjectCache = changesByProjectCache;
-    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 {
-    return cachedVisibleChanges().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() {
-    if (visibleChanges == null) {
-      loadVisibleChanges();
-      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 loadVisibleChanges() {
-    visibleChanges = new HashMap<>();
-    if (!projectState.statePermitsRead()) {
-      return;
-    }
-    Project.NameKey project = projectState.getNameKey();
-    try {
-      for (ChangeData cd : changesByProjectCache.getChangeDatas(project, repository)) {
-        if (permissionBackendForProject.change(cd).testOrFalse(ChangePermission.READ)) {
-          visibleChanges.put(cd.getId(), cd.branchOrThrow());
-        }
-      }
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load changes for project %s, assuming no changes are visible", project);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/plugincontext/PluginMapContext.java b/java/com/google/gerrit/server/plugincontext/PluginMapContext.java
index fb50cd5..cd61429 100644
--- a/java/com/google/gerrit/server/plugincontext/PluginMapContext.java
+++ b/java/com/google/gerrit/server/plugincontext/PluginMapContext.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
 import com.google.inject.Inject;
 import java.util.Iterator;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 
 /**
  * Context to invoke extensions from a {@link DynamicMap}.
@@ -135,7 +135,7 @@
    * @return sorted list of the plugins that have registered implementations for this extension
    *     point
    */
-  public SortedSet<String> plugins() {
+  public NavigableSet<String> plugins() {
     return dynamicMap.plugins();
   }
 
diff --git a/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
index 82f97c9..c00a69d 100644
--- a/java/com/google/gerrit/server/plugins/JarPluginProvider.java
+++ b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -29,9 +29,10 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.List;
 import java.util.jar.JarFile;
 import java.util.jar.Manifest;
@@ -104,8 +105,8 @@
   }
 
   private static String tempNameFor(String name) {
-    SimpleDateFormat fmt = new SimpleDateFormat("yyMMdd_HHmm");
-    return PLUGIN_TMP_PREFIX + name + "_" + fmt.format(new Date()) + "_";
+    DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyMMdd_HHmm").withZone(ZoneId.of("UTC"));
+    return PLUGIN_TMP_PREFIX + name + "_" + fmt.format(Instant.now()) + "_";
   }
 
   public static Path storeInTemp(String pluginName, InputStream in, SitePaths sitePaths)
diff --git a/java/com/google/gerrit/server/plugins/JarScanner.java b/java/com/google/gerrit/server/plugins/JarScanner.java
index 0f87135..e119bf1 100644
--- a/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.plugins;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.Iterables.transform;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.flogger.FluentLogger;
@@ -31,7 +31,6 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -42,6 +41,7 @@
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
 import java.util.jar.Manifest;
+import java.util.stream.Stream;
 import org.eclipse.jgit.util.IO;
 import org.objectweb.asm.AnnotationVisitor;
 import org.objectweb.asm.Attribute;
@@ -78,9 +78,7 @@
       classObjToClassDescr.put(annotation, descriptor);
     }
 
-    Enumeration<JarEntry> e = jarFile.entries();
-    while (e.hasMoreElements()) {
-      JarEntry entry = e.nextElement();
+    for (JarEntry entry : entriesOf(jarFile)) {
       if (skip(entry)) {
         continue;
       }
@@ -137,9 +135,7 @@
     String name = superClass.replace('.', '/');
 
     List<String> classes = new ArrayList<>();
-    Enumeration<JarEntry> e = jarFile.entries();
-    while (e.hasMoreElements()) {
-      JarEntry entry = e.nextElement();
+    for (JarEntry entry : entriesOf(jarFile)) {
       if (skip(entry)) {
         continue;
       }
@@ -294,10 +290,9 @@
   }
 
   @Override
-  public Enumeration<PluginEntry> entries() {
-    return Collections.enumeration(
-        Lists.transform(
-            Collections.list(jarFile.entries()),
+  public Stream<PluginEntry> entries() {
+    return jarFile.stream()
+        .map(
             jarEntry -> {
               try {
                 return resourceOf(jarEntry);
@@ -305,7 +300,7 @@
                 throw new IllegalArgumentException(
                     "Cannot convert jar entry " + jarEntry + " to a resource", e);
               }
-            }));
+            });
   }
 
   @Override
@@ -333,4 +328,8 @@
     }
     return Maps.transformEntries(attributes, (key, value) -> (String) value);
   }
+
+  private static Iterable<JarEntry> entriesOf(JarFile jarFile) {
+    return jarFile.stream().collect(toImmutableList());
+  }
 }
diff --git a/java/com/google/gerrit/server/plugins/PluginContentScanner.java b/java/com/google/gerrit/server/plugins/PluginContentScanner.java
index b19d6de..12c06f3 100644
--- a/java/com/google/gerrit/server/plugins/PluginContentScanner.java
+++ b/java/com/google/gerrit/server/plugins/PluginContentScanner.java
@@ -19,10 +19,10 @@
 import java.lang.annotation.Annotation;
 import java.nio.file.NoSuchFileException;
 import java.util.Collections;
-import java.util.Enumeration;
 import java.util.Map;
 import java.util.Optional;
 import java.util.jar.Manifest;
+import java.util.stream.Stream;
 
 /**
  * Scans the plugin returning classes and resources.
@@ -58,8 +58,8 @@
         }
 
         @Override
-        public Enumeration<PluginEntry> entries() {
-          return Collections.emptyEnumeration();
+        public Stream<PluginEntry> entries() {
+          return Stream.empty();
         }
       };
 
@@ -122,5 +122,5 @@
    *
    * @return the enumeration of all resources found
    */
-  Enumeration<PluginEntry> entries();
+  Stream<PluginEntry> entries();
 }
diff --git a/java/com/google/gerrit/server/plugins/PluginResource.java b/java/com/google/gerrit/server/plugins/PluginResource.java
index e7ebd56..fb69233 100644
--- a/java/com/google/gerrit/server/plugins/PluginResource.java
+++ b/java/com/google/gerrit/server/plugins/PluginResource.java
@@ -19,8 +19,7 @@
 import com.google.inject.TypeLiteral;
 
 public class PluginResource implements RestResource {
-  public static final TypeLiteral<RestView<PluginResource>> PLUGIN_KIND =
-      new TypeLiteral<RestView<PluginResource>>() {};
+  public static final TypeLiteral<RestView<PluginResource>> PLUGIN_KIND = new TypeLiteral<>() {};
 
   private final Plugin plugin;
   private final String name;
diff --git a/java/com/google/gerrit/server/plugins/PluginScannerThread.java b/java/com/google/gerrit/server/plugins/PluginScannerThread.java
index 705e3c0..1c2c836 100644
--- a/java/com/google/gerrit/server/plugins/PluginScannerThread.java
+++ b/java/com/google/gerrit/server/plugins/PluginScannerThread.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.plugins;
 
+import com.google.common.util.concurrent.Uninterruptibles;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 
@@ -45,10 +46,6 @@
 
   void end() {
     done.countDown();
-    try {
-      join();
-    } catch (InterruptedException e) {
-      // Ignored
-    }
+    Uninterruptibles.joinUninterruptibly(this);
   }
 }
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/plugins/TestServerPlugin.java b/java/com/google/gerrit/server/plugins/TestServerPlugin.java
index 3751c3f..cd5d5e3 100644
--- a/java/com/google/gerrit/server/plugins/TestServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/TestServerPlugin.java
@@ -28,7 +28,7 @@
       String name,
       String pluginCanonicalWebUrl,
       PluginUser user,
-      ClassLoader classloader,
+      ClassLoader classLoader,
       String sysName,
       String httpName,
       String sshName,
@@ -42,10 +42,10 @@
         null,
         null,
         dataDir,
-        classloader,
+        classLoader,
         null,
         GerritRuntime.DAEMON);
-    this.classLoader = classloader;
+    this.classLoader = classLoader;
     this.sysName = sysName;
     this.httpName = httpName;
     this.sshName = sshName;
diff --git a/java/com/google/gerrit/server/project/BranchResource.java b/java/com/google/gerrit/server/project/BranchResource.java
index a8936ac..fb1fa57 100644
--- a/java/com/google/gerrit/server/project/BranchResource.java
+++ b/java/com/google/gerrit/server/project/BranchResource.java
@@ -18,19 +18,20 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 
 public class BranchResource extends RefResource {
-  public static final TypeLiteral<RestView<BranchResource>> BRANCH_KIND =
-      new TypeLiteral<RestView<BranchResource>>() {};
+  public static final TypeLiteral<RestView<BranchResource>> BRANCH_KIND = new TypeLiteral<>() {};
 
   private final String refName;
-  private final String revision;
+  private final Optional<String> revision;
 
   public BranchResource(ProjectState projectState, CurrentUser user, Ref ref) {
     super(projectState, user);
     this.refName = ref.getName();
-    this.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
+    this.revision = Optional.ofNullable(ref.getObjectId()).map(ObjectId::name);
   }
 
   public BranchNameKey getBranchKey() {
@@ -43,7 +44,7 @@
   }
 
   @Override
-  public String getRevision() {
+  public Optional<String> getRevision() {
     return revision;
   }
 }
diff --git a/java/com/google/gerrit/server/project/ChildProjectResource.java b/java/com/google/gerrit/server/project/ChildProjectResource.java
index 4b641ca..854f876 100644
--- a/java/com/google/gerrit/server/project/ChildProjectResource.java
+++ b/java/com/google/gerrit/server/project/ChildProjectResource.java
@@ -21,7 +21,7 @@
 
 public class ChildProjectResource implements RestResource {
   public static final TypeLiteral<RestView<ChildProjectResource>> CHILD_PROJECT_KIND =
-      new TypeLiteral<RestView<ChildProjectResource>>() {};
+      new TypeLiteral<>() {};
 
   private final ProjectResource parent;
   private final ProjectState child;
diff --git a/java/com/google/gerrit/server/project/CommentLinkProvider.java b/java/com/google/gerrit/server/project/CommentLinkProvider.java
index 1b9dc37..c2ac68a 100644
--- a/java/com/google/gerrit/server/project/CommentLinkProvider.java
+++ b/java/com/google/gerrit/server/project/CommentLinkProvider.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.StoredCommentLinkInfo;
@@ -45,7 +44,8 @@
 
   private List<CommentLinkInfo> parseConfig(Config cfg) {
     Set<String> subsections = cfg.getSubsections(ProjectConfig.COMMENTLINK);
-    List<CommentLinkInfo> cls = Lists.newArrayListWithCapacity(subsections.size());
+    ImmutableList.Builder<CommentLinkInfo> cls =
+        ImmutableList.builderWithExpectedSize(subsections.size());
     for (String name : subsections) {
       try {
         StoredCommentLinkInfo cl = ProjectConfig.buildCommentLink(cfg, name, true);
@@ -58,7 +58,7 @@
         logger.atWarning().log("invalid commentlink: %s", e.getMessage());
       }
     }
-    return ImmutableList.copyOf(cls);
+    return cls.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/project/CommitResource.java b/java/com/google/gerrit/server/project/CommitResource.java
index f71c7fe..ffd498d 100644
--- a/java/com/google/gerrit/server/project/CommitResource.java
+++ b/java/com/google/gerrit/server/project/CommitResource.java
@@ -20,8 +20,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 
 public class CommitResource implements RestResource {
-  public static final TypeLiteral<RestView<CommitResource>> COMMIT_KIND =
-      new TypeLiteral<RestView<CommitResource>>() {};
+  public static final TypeLiteral<RestView<CommitResource>> COMMIT_KIND = new TypeLiteral<>() {};
 
   private final ProjectResource project;
   private final RevCommit commit;
diff --git a/java/com/google/gerrit/server/project/DashboardResource.java b/java/com/google/gerrit/server/project/DashboardResource.java
index 54f958a..c918551 100644
--- a/java/com/google/gerrit/server/project/DashboardResource.java
+++ b/java/com/google/gerrit/server/project/DashboardResource.java
@@ -22,7 +22,7 @@
 
 public class DashboardResource implements RestResource {
   public static final TypeLiteral<RestView<DashboardResource>> DASHBOARD_KIND =
-      new TypeLiteral<RestView<DashboardResource>>() {};
+      new TypeLiteral<>() {};
 
   public static DashboardResource projectDefault(ProjectState projectState, CurrentUser user) {
     return new DashboardResource(projectState, user, null, null, null, true);
diff --git a/java/com/google/gerrit/server/project/FileResource.java b/java/com/google/gerrit/server/project/FileResource.java
index e8926dc..f02522a 100644
--- a/java/com/google/gerrit/server/project/FileResource.java
+++ b/java/com/google/gerrit/server/project/FileResource.java
@@ -33,8 +33,7 @@
  * <p>This is in the project package because it is accessed through the project/branch/file path.
  */
 public class FileResource implements RestResource {
-  public static final TypeLiteral<RestView<FileResource>> FILE_KIND =
-      new TypeLiteral<RestView<FileResource>>() {};
+  public static final TypeLiteral<RestView<FileResource>> FILE_KIND = new TypeLiteral<>() {};
 
   public static FileResource create(
       GitRepositoryManager repoManager, ProjectState projectState, ObjectId rev, String path)
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 63c9d22..71ea12b 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -25,6 +25,7 @@
   public static LabelDefinitionInfo format(Project.NameKey projectName, LabelType labelType) {
     LabelDefinitionInfo label = new LabelDefinitionInfo();
     label.name = labelType.getName();
+    label.description = labelType.getDescription().orElse(null);
     label.projectName = projectName.get();
     label.function = labelType.getFunction().getFunctionName();
     label.values =
diff --git a/java/com/google/gerrit/server/project/LabelResource.java b/java/com/google/gerrit/server/project/LabelResource.java
index 2df9ff1..fcddc61 100644
--- a/java/com/google/gerrit/server/project/LabelResource.java
+++ b/java/com/google/gerrit/server/project/LabelResource.java
@@ -20,8 +20,7 @@
 import com.google.inject.TypeLiteral;
 
 public class LabelResource implements RestResource {
-  public static final TypeLiteral<RestView<LabelResource>> LABEL_KIND =
-      new TypeLiteral<RestView<LabelResource>>() {};
+  public static final TypeLiteral<RestView<LabelResource>> LABEL_KIND = new TypeLiteral<>() {};
 
   private final ProjectResource project;
   private final LabelType labelType;
diff --git a/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifier.java b/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifier.java
new file mode 100644
index 0000000..7e6d93e
--- /dev/null
+++ b/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifier.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.inject.ImplementedBy;
+
+/**
+ * Extension point that allows to modify the {@link SubmitRequirementResult} when it is stored
+ * NoteDB.
+ *
+ * <p>The submit requirements are only stored for the closed (merged and abandoned) changes and the
+ * modifier only affects what {@link SubmitRequirementResult} will be stored in NoteDB.
+ *
+ * <p>It has no impact on open changes or evaluations on merge, i.e. does not affect the
+ * submittability of the change (never blocks the ready change from submission or allows bypassing
+ * unsatisfied submit requirement).
+ *
+ * <p>The extension point only applies to non-legacy submit requirements (including non-applicable,
+ * since they are stored too) and does not affect submit rule results.
+ */
+@ExtensionPoint
+@ImplementedBy(OnStoreSubmitRequirementResultModifierImpl.class)
+public interface OnStoreSubmitRequirementResultModifier {
+
+  /**
+   * Evaluate a single {@link SubmitRequirement} using the change data, modifying the original
+   * {@code SubmitRequirementResult}, if needed.
+   *
+   * <p>Only affects how the submit requirement is stored in NoteDb for closed changes.
+   */
+  SubmitRequirementResult modifyResultOnStore(
+      SubmitRequirement submitRequirement,
+      SubmitRequirementResult submitRequirementResult,
+      ChangeData changeData,
+      ChangeContext ctx);
+}
diff --git a/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifierImpl.java b/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifierImpl.java
new file mode 100644
index 0000000..39a717f
--- /dev/null
+++ b/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifierImpl.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.ChangeContext;
+
+/**
+ * Default implementation of {@link OnStoreSubmitRequirementResultModifier} that does not
+ * re-evaluate {@link SubmitRequirementResult}.
+ */
+public class OnStoreSubmitRequirementResultModifierImpl
+    implements OnStoreSubmitRequirementResultModifier {
+  @Override
+  public SubmitRequirementResult modifyResultOnStore(
+      SubmitRequirement submitRequirement,
+      SubmitRequirementResult result,
+      ChangeData cd,
+      ChangeContext ctx) {
+    return result;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 0f6810f..67c031e 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -130,7 +130,7 @@
             .keySerializer(new ProtobufSerializer<>(Cache.ProjectCacheKeyProto.parser()))
             .valueSerializer(PersistedProjectConfigSerializer.INSTANCE)
             .diskLimit(1 << 30) // 1 GiB
-            .version(2)
+            .version(4)
             .maximumWeight(0);
 
         cache(CACHE_LIST, ListKey.class, new TypeLiteral<ImmutableSortedSet<Project.NameKey>>() {})
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index c101ddf..d816d84 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -23,11 +23,13 @@
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -104,6 +106,7 @@
 
   public static final String COMMENTLINK = "commentlink";
   public static final String LABEL = "label";
+  public static final String KEY_LABEL_DESCRIPTION = "description";
   public static final String KEY_FUNCTION = "function";
   public static final String KEY_DEFAULT_VALUE = "defaultValue";
   public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
@@ -130,6 +133,13 @@
   public static final String KEY_SR_SUBMITTABILITY_EXPRESSION = "submittableIf";
   public static final String KEY_SR_OVERRIDE_EXPRESSION = "overrideIf";
   public static final String KEY_SR_OVERRIDE_IN_CHILD_PROJECTS = "canOverrideInChildProjects";
+  public static final ImmutableSet<String> SR_KEYS =
+      ImmutableSet.of(
+          KEY_SR_DESCRIPTION,
+          KEY_SR_APPLICABILITY_EXPRESSION,
+          KEY_SR_SUBMITTABILITY_EXPRESSION,
+          KEY_SR_OVERRIDE_EXPRESSION,
+          KEY_SR_OVERRIDE_IN_CHILD_PROJECTS);
 
   public static final String KEY_MATCH = "match";
   private static final String KEY_HTML = "html";
@@ -512,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()));
@@ -550,6 +554,11 @@
     submitRequirementSections.put(requirement.name(), requirement);
   }
 
+  @VisibleForTesting
+  public void clearSubmitRequirements() {
+    submitRequirementSections = new LinkedHashMap<>();
+  }
+
   /** Adds or replaces the given {@link LabelType} in this config. */
   public void upsertLabelType(LabelType labelType) {
     labelSections.put(labelType.getName(), labelType);
@@ -913,7 +922,7 @@
       Config rc, String section, String subsection, String varName, boolean useRange) {
     Permission.Builder perm = Permission.builder(varName);
     loadPermissionRules(rc, section, subsection, varName, perm, useRange);
-    return ImmutableList.copyOf(perm.build().getRules());
+    return perm.build().getRules();
   }
 
   private void loadPermissionRules(
@@ -963,6 +972,8 @@
   }
 
   private void loadSubmitRequirementSections(Config rc) {
+    checkForUnsupportedSubmitRequirementParams(rc);
+
     Map<String, String> lowerNames = new HashMap<>();
     submitRequirementSections = new LinkedHashMap<>();
     for (String name : rc.getSubsections(SUBMIT_REQUIREMENT)) {
@@ -975,28 +986,46 @@
       }
       lowerNames.put(lower, name);
       String description = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_DESCRIPTION);
-      String appExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_APPLICABILITY_EXPRESSION);
-      String blockExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
+      String applicabilityExpr =
+          rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_APPLICABILITY_EXPRESSION);
+      String submittabilityExpr =
+          rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
       String overrideExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_EXPRESSION);
-      boolean canInherit =
-          rc.getBoolean(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false);
-
-      if (blockExpr == null) {
+      boolean canInherit;
+      try {
+        canInherit =
+            rc.getBoolean(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false);
+      } catch (IllegalArgumentException e) {
+        String canInheritValue =
+            rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS);
         error(
             String.format(
-                "Submit requirement '%s' does not define a submittability expression.", name));
+                "Invalid value %s.%s.%s for submit requirement '%s': %s",
+                SUBMIT_REQUIREMENT,
+                name,
+                KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+                name,
+                canInheritValue));
         continue;
       }
 
-      // TODO(SR): add expressions validation. Expressions are stored as strings so we need to
-      // validate their syntax.
+      if (submittabilityExpr == null) {
+        error(
+            String.format(
+                "Setting a submittability expression for submit requirement '%s' is required:"
+                    + " Missing %s.%s.%s",
+                name, SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION));
+        continue;
+      }
+
+      // The expressions are validated in SubmitRequirementExpressionsValidator.
 
       SubmitRequirement submitRequirement =
           SubmitRequirement.builder()
               .setName(name)
               .setDescription(Optional.ofNullable(description))
-              .setApplicabilityExpression(SubmitRequirementExpression.of(appExpr))
-              .setSubmittabilityExpression(SubmitRequirementExpression.create(blockExpr))
+              .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
+              .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
               .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
               .setAllowOverrideInChildProjects(canInherit)
               .build();
@@ -1005,6 +1034,43 @@
     }
   }
 
+  /**
+   * Report unsupported submit requirement parameters as errors.
+   *
+   * <p>Unsupported are submit requirements parameters that
+   *
+   * <ul>
+   *   <li>are directly set in the {@code submit-requirement} section (as submit requirements are
+   *       solely defined in subsections)
+   *   <li>are unknown (maybe they were accidentally misspelled?)
+   * </ul>
+   */
+  private void checkForUnsupportedSubmitRequirementParams(Config rc) {
+    Set<String> directSubmitRequirementParams = rc.getNames(SUBMIT_REQUIREMENT);
+    if (!directSubmitRequirementParams.isEmpty()) {
+      error(
+          String.format(
+              "Submit requirements must be defined in %s.<name> subsections."
+                  + " Setting parameters directly in the %s section is not allowed: %s",
+              SUBMIT_REQUIREMENT,
+              SUBMIT_REQUIREMENT,
+              directSubmitRequirementParams.stream().sorted().collect(toImmutableList())));
+    }
+
+    for (String subsection : rc.getSubsections(SUBMIT_REQUIREMENT)) {
+      ImmutableList<String> unknownSubmitRequirementParams =
+          rc.getNames(SUBMIT_REQUIREMENT, subsection).stream()
+              .filter(p -> !SR_KEYS.contains(p))
+              .collect(toImmutableList());
+      if (!unknownSubmitRequirementParams.isEmpty()) {
+        error(
+            String.format(
+                "Unsupported parameters for submit requirement '%s': %s",
+                subsection, unknownSubmitRequirementParams));
+      }
+    }
+  }
+
   private void loadLabelSections(Config rc) {
     Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
     labelSections = new LinkedHashMap<>();
@@ -1041,6 +1107,8 @@
         continue;
       }
 
+      label.setDescription(Optional.ofNullable(rc.getString(LABEL, name, KEY_LABEL_DESCRIPTION)));
+
       String functionName = rc.getString(LABEL, name, KEY_FUNCTION);
       Optional<LabelFunction> function =
           functionName != null
@@ -1105,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)) {
@@ -1122,7 +1194,23 @@
       label.setCanOverride(
           rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE, LabelType.DEF_CAN_OVERRIDE));
       List<String> refPatterns = getStringListOrNull(rc, LABEL, name, KEY_BRANCH);
-      label.setRefPatterns(refPatterns == null ? null : ImmutableList.copyOf(refPatterns));
+      if (refPatterns == null) {
+        label.setRefPatterns(null);
+      } else {
+        for (String pattern : refPatterns) {
+          if (pattern.startsWith("^")) {
+            try {
+              Pattern.compile(pattern);
+            } catch (PatternSyntaxException e) {
+              error(
+                  String.format(
+                      "Invalid ref pattern \"%s\" in %s.%s.%s: %s",
+                      pattern, LABEL, name, KEY_BRANCH, e.getMessage()));
+            }
+          }
+        }
+        label.setRefPatterns(ImmutableList.copyOf(refPatterns));
+      }
       labelSections.put(name, label.build());
     }
   }
@@ -1543,6 +1631,11 @@
       String name = e.getKey();
       LabelType label = e.getValue();
       toUnset.remove(name);
+      if (label.getDescription().isPresent() && !label.getDescription().get().isEmpty()) {
+        rc.setString(LABEL, name, KEY_LABEL_DESCRIPTION, label.getDescription().get());
+      } else {
+        rc.unset(LABEL, name, KEY_LABEL_DESCRIPTION);
+      }
       rc.setString(LABEL, name, KEY_FUNCTION, label.getFunction().getFunctionName());
       rc.setInt(LABEL, name, KEY_DEFAULT_VALUE, label.getDefaultValue());
 
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index 4e778a4..f1c161d 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -135,10 +135,6 @@
           e);
     } catch (RepositoryNotFoundException badName) {
       throw new BadRequestException("invalid project name: " + nameKey, badName);
-    } catch (ConfigInvalidException e) {
-      String msg = "Cannot create " + nameKey;
-      logger.atSevere().withCause(e).log(msg);
-      throw e;
     }
   }
 
diff --git a/java/com/google/gerrit/server/project/ProjectResource.java b/java/com/google/gerrit/server/project/ProjectResource.java
index 8802758..f9fa22e 100644
--- a/java/com/google/gerrit/server/project/ProjectResource.java
+++ b/java/com/google/gerrit/server/project/ProjectResource.java
@@ -21,8 +21,7 @@
 import com.google.inject.TypeLiteral;
 
 public class ProjectResource implements RestResource {
-  public static final TypeLiteral<RestView<ProjectResource>> PROJECT_KIND =
-      new TypeLiteral<RestView<ProjectResource>>() {};
+  public static final TypeLiteral<RestView<ProjectResource>> PROJECT_KIND = new TypeLiteral<>() {};
 
   private final ProjectState projectState;
   private final CurrentUser user;
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index fca1b36..ab4bb70 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -247,7 +247,7 @@
     return r;
   }
 
-  private List<ChangeInfo> executeQueryAndAutoCloseChanges(
+  private ImmutableList<ChangeInfo> executeQueryAndAutoCloseChanges(
       Predicate<ChangeData> basePredicate,
       Set<Change.Id> seenChanges,
       List<Predicate<ChangeData>> predicates,
@@ -269,7 +269,7 @@
               .call();
 
       // Result for this query that we want to return to the client.
-      List<ChangeInfo> autoCloseableChangesByBranch = new ArrayList<>();
+      ImmutableList.Builder<ChangeInfo> autoCloseableChangesByBranch = ImmutableList.builder();
 
       for (ChangeData autoCloseableChange : queryResult) {
         // Skip changes that we have already processed, either by this query or by
@@ -306,7 +306,7 @@
         }
       }
 
-      return autoCloseableChangesByBranch;
+      return autoCloseableChangesByBranch.build();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       throw new StorageException(e);
diff --git a/java/com/google/gerrit/server/project/RefResource.java b/java/com/google/gerrit/server/project/RefResource.java
index fcf6048..c67e10d 100644
--- a/java/com/google/gerrit/server/project/RefResource.java
+++ b/java/com/google/gerrit/server/project/RefResource.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.gerrit.server.CurrentUser;
+import java.util.Optional;
 
 public abstract class RefResource extends ProjectResource {
 
@@ -25,6 +26,10 @@
   /** Returns the ref's name */
   public abstract String getRef();
 
-  /** Returns the ref's revision */
-  public abstract String getRevision();
+  /**
+   * Returns the ref's revision.
+   *
+   * @return the ref's revision, {@link Optional#empty()} if the ref doesn't exist (yet)
+   */
+  public abstract Optional<String> getRevision();
 }
diff --git a/java/com/google/gerrit/server/project/RefValidationHelper.java b/java/com/google/gerrit/server/project/RefValidationHelper.java
index a6020a3..ea92b48 100644
--- a/java/com/google/gerrit/server/project/RefValidationHelper.java
+++ b/java/com/google/gerrit/server/project/RefValidationHelper.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.IdentifiedUser;
@@ -40,13 +41,18 @@
     this.operationType = operationType;
   }
 
-  public void validateRefOperation(String projectName, IdentifiedUser user, RefUpdate update)
+  public void validateRefOperation(
+      String projectName,
+      IdentifiedUser user,
+      RefUpdate update,
+      ImmutableListMultimap<String, String> pushOptions)
       throws ResourceConflictException {
     RefOperationValidators refValidators =
         refValidatorsFactory.create(
             Project.builder(Project.nameKey(projectName)).build(),
             user,
-            RefOperationValidators.getCommand(update, operationType));
+            RefOperationValidators.getCommand(update, operationType),
+            pushOptions);
     try {
       refValidators.validateForRefOperation();
     } catch (ValidationException e) {
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 0336e8e..1bc309c 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -108,30 +108,10 @@
     // owner and site admin can remove anyone
     PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
     PermissionBackend.ForProject forProject = withUser.project(change.getProject());
-    if (check(forProject.ref(change.getDest().branch()), RefPermission.WRITE_CONFIG)
-        || check(withUser, GlobalPermission.ADMINISTRATE_SERVER)) {
+    if (forProject.ref(change.getDest().branch()).test(RefPermission.WRITE_CONFIG)
+        || withUser.test(GlobalPermission.ADMINISTRATE_SERVER)) {
       return true;
     }
     return false;
   }
-
-  private static boolean check(PermissionBackend.ForRef forRef, RefPermission perm)
-      throws PermissionBackendException {
-    try {
-      forRef.check(perm);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
-  }
-
-  private static boolean check(PermissionBackend.WithUser withUser, GlobalPermission perm)
-      throws PermissionBackendException {
-    try {
-      withUser.check(perm);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementEvaluationException.java b/java/com/google/gerrit/server/project/SubmitRequirementEvaluationException.java
new file mode 100644
index 0000000..4d15200
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementEvaluationException.java
@@ -0,0 +1,42 @@
+// 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.project;
+
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+
+/**
+ * Exception that might occur when evaluating {@link SubmitRequirementPredicate} in {@link
+ * SubmitRequirementExpression}.
+ *
+ * <p>This exception will result in {@link
+ * com.google.gerrit.entities.SubmitRequirementResult.Status#ERROR} overall submit requirement
+ * evaluation status.
+ */
+public class SubmitRequirementEvaluationException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  public SubmitRequirementEvaluationException(String errorMessage) {
+    super(errorMessage);
+  }
+
+  public SubmitRequirementEvaluationException(String errorMessage, Throwable why) {
+    super(errorMessage, why);
+  }
+
+  public SubmitRequirementEvaluationException(Throwable why) {
+    super(why);
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
new file mode 100644
index 0000000..8717581
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
@@ -0,0 +1,182 @@
+// 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.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * Validates the expressions of submit requirements in {@code project.config}.
+ *
+ * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
+ * ProjectConfig#loadSubmitRequirementSections(Config)}.
+ *
+ * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
+ * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
+ * {@link ProjectConfig} is cached in the project cache).
+ */
+public class SubmitRequirementExpressionsValidator implements CommitValidationListener {
+  private final DiffOperations diffOperations;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
+
+  @Inject
+  SubmitRequirementExpressionsValidator(
+      DiffOperations diffOperations,
+      ProjectConfig.Factory projectConfigFactory,
+      SubmitRequirementsEvaluator submitRequirementsEvaluator) {
+    this.diffOperations = diffOperations;
+    this.projectConfigFactory = projectConfigFactory;
+    this.submitRequirementsEvaluator = submitRequirementsEvaluator;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
+      throws CommitValidationException {
+    try {
+      if (!event.refName.equals(RefNames.REFS_CONFIG)
+          || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
+        // the project.config file in refs/meta/config was not modified, hence we do not need to
+        // validate the submit requirements in it
+        return ImmutableList.of();
+      }
+
+      ProjectConfig projectConfig = getProjectConfig(event);
+      ImmutableList<CommitValidationMessage> validationMessages =
+          validateSubmitRequirementExpressions(
+              projectConfig.getSubmitRequirementSections().values());
+      if (!validationMessages.isEmpty()) {
+        throw new CommitValidationException(
+            String.format(
+                "invalid submit requirement expressions in %s (revision = %s)",
+                ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision()),
+            validationMessages);
+      }
+      return ImmutableList.of();
+    } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
+      throw new CommitValidationException(
+          String.format(
+              "failed to validate submit requirement expressions in %s for revision %s in ref %s"
+                  + " of project %s",
+              ProjectConfig.PROJECT_CONFIG,
+              event.commit.getName(),
+              RefNames.REFS_CONFIG,
+              event.project.getNameKey()),
+          e);
+    }
+  }
+
+  /**
+   * Whether the given file was changed in the given revision.
+   *
+   * @param receiveEvent the receive event
+   * @param fileName the name of the file
+   */
+  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
+      throws DiffNotAvailableException {
+    return diffOperations
+        .listModifiedFilesAgainstParent(
+            receiveEvent.project.getNameKey(),
+            receiveEvent.commit,
+            /* parentNum=*/ 0,
+            DiffOptions.DEFAULTS)
+        .keySet().stream()
+        .anyMatch(fileName::equals);
+  }
+
+  private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
+      throws IOException, ConfigInvalidException {
+    ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
+    projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
+    return projectConfig;
+  }
+
+  private ImmutableList<CommitValidationMessage> validateSubmitRequirementExpressions(
+      Collection<SubmitRequirement> submitRequirements) {
+    List<CommitValidationMessage> validationMessages = new ArrayList<>();
+    for (SubmitRequirement submitRequirement : submitRequirements) {
+      validateSubmitRequirementExpression(
+          validationMessages,
+          submitRequirement,
+          submitRequirement.submittabilityExpression(),
+          ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+      submitRequirement
+          .applicabilityExpression()
+          .ifPresent(
+              expression ->
+                  validateSubmitRequirementExpression(
+                      validationMessages,
+                      submitRequirement,
+                      expression,
+                      ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
+      submitRequirement
+          .overrideExpression()
+          .ifPresent(
+              expression ->
+                  validateSubmitRequirementExpression(
+                      validationMessages,
+                      submitRequirement,
+                      expression,
+                      ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
+    }
+    return ImmutableList.copyOf(validationMessages);
+  }
+
+  private void validateSubmitRequirementExpression(
+      List<CommitValidationMessage> validationMessages,
+      SubmitRequirement submitRequirement,
+      SubmitRequirementExpression expression,
+      String configKey) {
+    try {
+      submitRequirementsEvaluator.validateExpression(expression);
+    } catch (QueryParseException e) {
+      if (validationMessages.isEmpty()) {
+        validationMessages.add(
+            new CommitValidationMessage(
+                "Invalid project configuration", ValidationMessage.Type.ERROR));
+      }
+      validationMessages.add(
+          new CommitValidationMessage(
+              String.format(
+                  "  %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                      + " invalid: %s",
+                  ProjectConfig.PROJECT_CONFIG,
+                  expression.expressionString(),
+                  submitRequirement.name(),
+                  ProjectConfig.SUBMIT_REQUIREMENT,
+                  submitRequirement.name(),
+                  configKey,
+                  e.getMessage()),
+              ValidationMessage.Type.ERROR));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
index 539edc1..1361122 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.MoreCollectors;
+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;
@@ -27,10 +29,10 @@
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.rules.DefaultSubmitRule;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -48,36 +50,91 @@
    * com.google.gerrit.server.rules.SubmitRule}s) and convert them to submit requirement results.
    */
   public static Map<SubmitRequirement, SubmitRequirementResult> getLegacyRequirements(
-      SubmitRuleEvaluator.Factory evaluator, ChangeData cd) {
+      ChangeData cd) {
     // We use SubmitRuleOptions.defaults() which does not recompute submit rules for closed changes.
     // This doesn't have an effect since we never call this class (i.e. to evaluate submit
     // requirements) for closed changes.
-    List<SubmitRecord> records = evaluator.create(SubmitRuleOptions.defaults()).evaluate(cd);
+    List<SubmitRecord> records = cd.submitRecords(SubmitRuleOptions.defaults());
+    boolean areForced =
+        records.stream().anyMatch(record -> SubmitRecord.Status.FORCED.equals(record.status));
     List<LabelType> labelTypes = cd.getLabelTypes().getLabelTypes();
     ObjectId commitId = cd.currentPatchSet().commitId();
-    return records.stream()
-        .map(r -> createResult(r, labelTypes, commitId))
-        .flatMap(List::stream)
-        .collect(Collectors.toMap(sr -> sr.submitRequirement(), Function.identity()));
+    Map<String, List<SubmitRequirementResult>> srsByName =
+        records.stream()
+            // Filter out the "FORCED" submit record. This is a marker submit record that was just
+            // used to indicate that all other records were forced. "FORCED" means that the change
+            // was pushed with the %submit option bypassing submit rules.
+            .filter(r -> !SubmitRecord.Status.FORCED.equals(r.status))
+            .map(r -> createResult(r, labelTypes, commitId, areForced))
+            .flatMap(List::stream)
+            .collect(Collectors.groupingBy(sr -> sr.submitRequirement().name()));
+
+    // We convert submit records to submit requirements by generating a separate
+    // submit requirement result for each available label in each submit record.
+    // The SR status is derived from the label status of the submit record.
+    // This conversion might result in duplicate entries.
+    // One such example can be a prolog rule emitting the same label name twice.
+    // Another case might happen if two different submit rules emit the same label
+    // name. In such cases, we need to merge these entries and return a single submit
+    // requirement result. If both entries agree in their status, return any of them.
+    // Otherwise, favour the entry that is blocking submission.
+    ImmutableMap.Builder<SubmitRequirement, SubmitRequirementResult> result =
+        ImmutableMap.builder();
+    for (Map.Entry<String, List<SubmitRequirementResult>> entry : srsByName.entrySet()) {
+      if (entry.getValue().size() == 1) {
+        SubmitRequirementResult srResult = entry.getValue().iterator().next();
+        result.put(srResult.submitRequirement(), srResult);
+        continue;
+      }
+      // If all submit requirements with the same name match in status, return the first one.
+      List<SubmitRequirementResult> resultsSameName = entry.getValue();
+      boolean allNonBlocking = resultsSameName.stream().allMatch(sr -> sr.fulfilled());
+      if (allNonBlocking) {
+        result.put(resultsSameName.get(0).submitRequirement(), resultsSameName.get(0));
+      } else {
+        // Otherwise, return the first submit requirement result that is blocking submission.
+        Optional<SubmitRequirementResult> nonFulfilled =
+            resultsSameName.stream().filter(sr -> !sr.fulfilled()).findFirst();
+        if (nonFulfilled.isPresent()) {
+          result.put(nonFulfilled.get().submitRequirement(), nonFulfilled.get());
+        }
+      }
+    }
+    return result.build();
   }
 
   static List<SubmitRequirementResult> createResult(
-      SubmitRecord record, List<LabelType> labelTypes, ObjectId psCommitId) {
+      SubmitRecord record, List<LabelType> labelTypes, ObjectId psCommitId, boolean isForced) {
     List<SubmitRequirementResult> results;
-    if (record.ruleName != null && record.ruleName.equals("gerrit~DefaultSubmitRule")) {
-      results = createFromDefaultSubmitRecord(record.labels, labelTypes, psCommitId);
+    if (record.ruleName != null && record.ruleName.equals(DefaultSubmitRule.RULE_NAME)) {
+      results = createFromDefaultSubmitRecord(record.labels, labelTypes, psCommitId, isForced);
     } else {
-      results = createFromCustomSubmitRecord(record, psCommitId);
+      results = createFromCustomSubmitRecord(record, psCommitId, isForced);
     }
     logger.atFine().log("Converted submit record %s to submit requirements %s", record, results);
     return results;
   }
 
   private static List<SubmitRequirementResult> createFromDefaultSubmitRecord(
-      List<Label> labels, List<LabelType> labelTypes, ObjectId psCommitId) {
+      @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) {
-      LabelType labelType = getLabelType(labelTypes, label.label);
+      if (skipSubmitRequirementFor(label)) {
+        continue;
+      }
+      Optional<LabelType> maybeLabelType = getLabelType(labelTypes, label.label);
+      if (!maybeLabelType.isPresent()) {
+        // Label type might have been removed from the project config. We don't have information
+        // if it was blocking or not, hence we skip the label.
+        continue;
+      }
+      LabelType labelType = maybeLabelType.get();
       if (!isBlocking(labelType)) {
         continue;
       }
@@ -94,13 +151,14 @@
               .submittabilityExpressionResult(
                   createExpressionResult(toExpression(atoms), mapStatus(label), atoms))
               .patchSetCommitId(psCommitId)
+              .forced(Optional.of(isForced))
               .build());
     }
     return result.build();
   }
 
   private static List<SubmitRequirementResult> createFromCustomSubmitRecord(
-      SubmitRecord record, ObjectId psCommitId) {
+      SubmitRecord record, ObjectId psCommitId, boolean isForced) {
     String ruleName = record.ruleName != null ? record.ruleName : "Custom-Rule";
     if (record.labels == null || record.labels.isEmpty()) {
       SubmitRequirement sr =
@@ -116,12 +174,19 @@
               .submitRequirement(sr)
               .submittabilityExpressionResult(
                   createExpressionResult(
-                      sr.submittabilityExpression(), mapStatus(record), ImmutableList.of(ruleName)))
+                      sr.submittabilityExpression(),
+                      mapStatus(record),
+                      ImmutableList.of(ruleName),
+                      record.errorMessage))
               .patchSetCommitId(psCommitId)
+              .forced(Optional.of(isForced))
               .build());
     }
     ImmutableList.Builder<SubmitRequirementResult> result = ImmutableList.builder();
     for (Label label : record.labels) {
+      if (skipSubmitRequirementFor(label)) {
+        continue;
+      }
       String expressionString = String.format("label:%s=%s", label.label, ruleName);
       SubmitRequirement sr =
           SubmitRequirement.builder()
@@ -212,9 +277,40 @@
         status == Status.FAIL ? atoms : ImmutableList.of());
   }
 
-  private static LabelType getLabelType(List<LabelType> labelTypes, String labelName) {
-    return labelTypes.stream()
-        .filter(lt -> lt.getName().equals(labelName))
-        .collect(MoreCollectors.onlyElement());
+  private static SubmitRequirementExpressionResult createExpressionResult(
+      SubmitRequirementExpression expression,
+      Status status,
+      ImmutableList<String> atoms,
+      String errorMessage) {
+    return SubmitRequirementExpressionResult.create(
+        expression,
+        status,
+        status == Status.PASS ? atoms : ImmutableList.of(),
+        status == Status.FAIL ? atoms : ImmutableList.of(),
+        Optional.ofNullable(Strings.emptyToNull(errorMessage)));
+  }
+
+  private static Optional<LabelType> getLabelType(List<LabelType> labelTypes, String labelName) {
+    List<LabelType> label =
+        labelTypes.stream()
+            .filter(lt -> lt.getName().equals(labelName))
+            .collect(Collectors.toList());
+    if (label.isEmpty()) {
+      // Label might have been removed from the project.
+      logger.atFine().log("Label '%s' was not found for the project.", labelName);
+      return Optional.empty();
+    } else if (label.size() > 1) {
+      logger.atWarning().log("Found more than one label definition for label name '%s'", labelName);
+      return Optional.empty();
+    }
+    return Optional.of(label.get(0));
+  }
+
+  /**
+   * Returns true if we should skip creating a "submit requirement" result out of the "submit
+   * record" label.
+   */
+  private static boolean skipSubmitRequirementFor(SubmitRecord.Label label) {
+    return label.status == SubmitRecord.Label.Status.MAY;
   }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
index 402bb51..df836e0 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
-import java.util.Map;
 
 public interface SubmitRequirementsEvaluator {
   /**
@@ -31,7 +31,7 @@
    * @param includeLegacy if set to true, evaluate legacy {@link
    *     com.google.gerrit.entities.SubmitRecord}s and convert them to submit requirements.
    */
-  Map<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
+  ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
       ChangeData cd, boolean includeLegacy);
 
   /** Evaluate a single {@link SubmitRequirement} using change data. */
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index cc2c805..c57fe27 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.collect.ImmutableMap;
@@ -24,23 +25,29 @@
 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.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
 import com.google.inject.Scopes;
-import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Stream;
 
 /** Evaluates submit requirements for different change data. */
 public class SubmitRequirementsEvaluatorImpl implements SubmitRequirementsEvaluator {
 
   private final Provider<SubmitRequirementChangeQueryBuilder> queryBuilder;
   private final ProjectCache projectCache;
-  private final SubmitRuleEvaluator.Factory legacyEvaluator;
+  private final PluginSetContext<SubmitRequirement> globalSubmitRequirements;
+  private final SubmitRequirementsUtil submitRequirementsUtil;
+  private final OneOffRequestContext requestContext;
 
   public static Module module() {
     return new AbstractModule() {
@@ -57,10 +64,14 @@
   private SubmitRequirementsEvaluatorImpl(
       Provider<SubmitRequirementChangeQueryBuilder> queryBuilder,
       ProjectCache projectCache,
-      SubmitRuleEvaluator.Factory legacyEvaluator) {
+      PluginSetContext<SubmitRequirement> globalSubmitRequirements,
+      SubmitRequirementsUtil submitRequirementsUtil,
+      OneOffRequestContext requestContext) {
     this.queryBuilder = queryBuilder;
     this.projectCache = projectCache;
-    this.legacyEvaluator = legacyEvaluator;
+    this.globalSubmitRequirements = globalSubmitRequirements;
+    this.submitRequirementsUtil = submitRequirementsUtil;
+    this.requestContext = requestContext;
   }
 
   @Override
@@ -70,43 +81,50 @@
   }
 
   @Override
-  public Map<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
+  public ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
       ChangeData cd, boolean includeLegacy) {
-    Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements = getRequirements(cd);
-    Map<SubmitRequirement, SubmitRequirementResult> result = projectConfigRequirements;
-    if (includeLegacy) {
-      Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
-          SubmitRequirementsAdapter.getLegacyRequirements(legacyEvaluator, cd);
-      result =
-          SubmitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
-              projectConfigRequirements, legacyReqs);
+    ImmutableMap<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements =
+        getRequirements(cd);
+    if (!includeLegacy) {
+      return projectConfigRequirements;
     }
-    return ImmutableMap.copyOf(result);
+    Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
+        SubmitRequirementsAdapter.getLegacyRequirements(cd);
+    return submitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
+        projectConfigRequirements, legacyReqs, cd);
   }
 
   @Override
   public SubmitRequirementResult evaluateRequirement(SubmitRequirement sr, ChangeData cd) {
-    SubmitRequirementExpressionResult blockingResult =
-        evaluateExpression(sr.submittabilityExpression(), cd);
+    try (ManualRequestContext ignored = requestContext.open()) {
+      // Use a request context to execute predicates as an internal user with expanded visibility.
+      // This is so that the evaluation does not depend on who is running the current request (e.g.
+      // a "ownerin" predicate with group that is not visible to the person making this request).
 
-    Optional<SubmitRequirementExpressionResult> applicabilityResult =
-        sr.applicabilityExpression().isPresent()
-            ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
-            : Optional.empty();
+      Optional<SubmitRequirementExpressionResult> applicabilityResult =
+          sr.applicabilityExpression().isPresent()
+              ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
+              : Optional.empty();
+      Optional<SubmitRequirementExpressionResult> submittabilityResult = Optional.empty();
+      Optional<SubmitRequirementExpressionResult> overrideResult = Optional.empty();
+      if (!sr.applicabilityExpression().isPresent()
+          || SubmitRequirementResult.assertPass(applicabilityResult)) {
+        submittabilityResult = Optional.of(evaluateExpression(sr.submittabilityExpression(), cd));
+        overrideResult =
+            sr.overrideExpression().isPresent()
+                ? Optional.of(evaluateExpression(sr.overrideExpression().get(), cd))
+                : Optional.empty();
+      }
 
-    Optional<SubmitRequirementExpressionResult> overrideResult =
-        sr.overrideExpression().isPresent()
-            ? Optional.of(evaluateExpression(sr.overrideExpression().get(), cd))
-            : Optional.empty();
-
-    return SubmitRequirementResult.builder()
-        .legacy(Optional.of(false))
-        .submitRequirement(sr)
-        .patchSetCommitId(cd.currentPatchSet().commitId())
-        .submittabilityExpressionResult(blockingResult)
-        .applicabilityExpressionResult(applicabilityResult)
-        .overrideExpressionResult(overrideResult)
-        .build();
+      return SubmitRequirementResult.builder()
+          .legacy(Optional.of(false))
+          .submitRequirement(sr)
+          .patchSetCommitId(cd.currentPatchSet().commitId())
+          .submittabilityExpressionResult(submittabilityResult)
+          .applicabilityExpressionResult(applicabilityResult)
+          .overrideExpressionResult(overrideResult)
+          .build();
+    }
   }
 
   @Override
@@ -116,20 +134,58 @@
       Predicate<ChangeData> predicate = queryBuilder.get().parse(expression.expressionString());
       PredicateResult predicateResult = evaluatePredicateTree(predicate, changeData);
       return SubmitRequirementExpressionResult.create(expression, predicateResult);
-    } catch (QueryParseException e) {
+    } catch (QueryParseException | SubmitRequirementEvaluationException e) {
       return SubmitRequirementExpressionResult.error(expression, e.getMessage());
     }
   }
 
-  /** Evaluate and return submit requirements stored in this project's config and its parents. */
-  private Map<SubmitRequirement, SubmitRequirementResult> getRequirements(ChangeData cd) {
+  /**
+   * Evaluate and return all {@link SubmitRequirement}s.
+   *
+   * <p>This includes all globally bound {@link SubmitRequirement}s, as well as requirements stored
+   * in this project's config and its parents.
+   *
+   * <p>The behaviour in case of the name match is controlled by {@link
+   * SubmitRequirement#allowOverrideInChildProjects} of global {@link SubmitRequirement}.
+   */
+  private ImmutableMap<SubmitRequirement, SubmitRequirementResult> getRequirements(ChangeData cd) {
+    Map<String, SubmitRequirement> globalRequirements = getGlobalRequirements();
+
     ProjectState state = projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
-    Map<String, SubmitRequirement> requirements = state.getSubmitRequirements();
-    Map<SubmitRequirement, SubmitRequirementResult> result = new HashMap<>();
+    Map<String, SubmitRequirement> projectConfigRequirements = state.getSubmitRequirements();
+
+    ImmutableMap<String, SubmitRequirement> requirements =
+        Stream.concat(
+                globalRequirements.entrySet().stream(),
+                projectConfigRequirements.entrySet().stream())
+            .collect(
+                toImmutableMap(
+                    Map.Entry::getKey,
+                    Map.Entry::getValue,
+                    (globalSubmitRequirement, projectConfigRequirement) ->
+                        // Override with projectConfigRequirement if allowed by
+                        // globalSubmitRequirement configuration
+                        globalSubmitRequirement.allowOverrideInChildProjects()
+                            ? projectConfigRequirement
+                            : globalSubmitRequirement));
+    ImmutableMap.Builder<SubmitRequirement, SubmitRequirementResult> results =
+        ImmutableMap.builder();
     for (SubmitRequirement requirement : requirements.values()) {
-      result.put(requirement, evaluateRequirement(requirement, cd));
+      results.put(requirement, evaluateRequirement(requirement, cd));
     }
-    return result;
+    return results.build();
+  }
+
+  /**
+   * Returns a map of all global {@link SubmitRequirement}s, keyed by their lower-case name.
+   *
+   * <p>The global {@link SubmitRequirement}s apply to all projects and can be bound by plugins.
+   */
+  private Map<String, SubmitRequirement> getGlobalRequirements() {
+    return globalSubmitRequirements.stream()
+        .collect(
+            toImmutableMap(
+                globalRequirement -> globalRequirement.name().toLowerCase(), Function.identity()));
   }
 
   /** Evaluate the predicate recursively using change data. */
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
index 102d3f2..ec2533f 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
@@ -14,19 +14,100 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
+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.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;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 /**
  * A utility class for different operations related to {@link
  * com.google.gerrit.entities.SubmitRequirement}s.
  */
+@Singleton
 public class SubmitRequirementsUtil {
 
-  private SubmitRequirementsUtil() {}
+  @Singleton
+  static class Metrics {
+    final Counter2<String, String> submitRequirementsMatchingWithLegacy;
+    final Counter2<String, String> submitRequirementsMismatchingWithLegacy;
+    final Counter2<String, String> legacyNotInSrs;
+    final Counter2<String, String> srsNotInLegacy;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      submitRequirementsMatchingWithLegacy =
+          metricMaker.newCounter(
+              "change/submit_requirements/matching_with_legacy",
+              new Description(
+                      "Total number of times there was a legacy and non-legacy "
+                          + "submit requirements with the same name for a change, "
+                          + "and the evaluation of both requirements had the same result "
+                          + "w.r.t. change submittability.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofProjectName("project").build(),
+              Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
+                  .description("Submit requirement name")
+                  .build());
+      submitRequirementsMismatchingWithLegacy =
+          metricMaker.newCounter(
+              "change/submit_requirements/mismatching_with_legacy",
+              new Description(
+                      "Total number of times there was a legacy and non-legacy "
+                          + "submit requirements with the same name for a change, "
+                          + "and the evaluation of both requirements had a different result "
+                          + "w.r.t. change submittability.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofProjectName("project").build(),
+              Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
+                  .description("Submit requirement name")
+                  .build());
+      legacyNotInSrs =
+          metricMaker.newCounter(
+              "change/submit_requirements/legacy_not_in_srs",
+              new Description(
+                      "Total number of times there was a legacy submit requirement result "
+                          + "but not a project config requirement with the same name for a change.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofProjectName("project").build(),
+              Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
+                  .description("Submit requirement name")
+                  .build());
+      srsNotInLegacy =
+          metricMaker.newCounter(
+              "change/submit_requirements/srs_not_in_legacy",
+              new Description(
+                      "Total number of times there was a project config submit requirement "
+                          + "result but not a legacy requirement with the same name for a change.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofProjectName("project").build(),
+              Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
+                  .description("Submit requirement name")
+                  .build());
+    }
+  }
+
+  private final Metrics metrics;
+
+  @Inject
+  public SubmitRequirementsUtil(Metrics metrics) {
+    this.metrics = metrics;
+  }
 
   /**
    * Merge legacy and non-legacy submit requirement results. If both input maps have submit
@@ -43,9 +124,12 @@
    * @return a map that is the result of merging both input maps, while eliminating requirements
    *     with the same name and status.
    */
-  public static Map<SubmitRequirement, SubmitRequirementResult> mergeLegacyAndNonLegacyRequirements(
-      Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements,
-      Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements) {
+  public ImmutableMap<SubmitRequirement, SubmitRequirementResult>
+      mergeLegacyAndNonLegacyRequirements(
+          Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements,
+          Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements,
+          ChangeData cd) {
+    // Cannot use ImmutableMap.Builder here since entries in the map may be overridden.
     Map<SubmitRequirement, SubmitRequirementResult> result = new HashMap<>();
     result.putAll(projectConfigRequirements);
     Map<String, SubmitRequirementResult> requirementsByName =
@@ -53,15 +137,52 @@
             .collect(Collectors.toMap(sr -> sr.getKey().name().toLowerCase(), sr -> sr.getValue()));
     for (Map.Entry<SubmitRequirement, SubmitRequirementResult> legacy :
         legacyRequirements.entrySet()) {
-      String name = legacy.getKey().name().toLowerCase();
-      SubmitRequirementResult projectConfigResult = requirementsByName.get(name);
+      String srName = legacy.getKey().name().toLowerCase();
+      SubmitRequirementResult projectConfigResult = requirementsByName.get(srName);
       SubmitRequirementResult legacyResult = legacy.getValue();
-      if (projectConfigResult != null && matchByStatus(projectConfigResult, legacyResult)) {
+      // If there's no project config requirement with the same name as the legacy requirement
+      // 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 (shouldReportMetric(cd)) {
+          metrics.legacyNotInSrs.increment(cd.project().get(), srName);
+        }
         continue;
       }
+      if (matchByStatus(projectConfigResult, legacyResult)) {
+        // 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 (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 (shouldReportMetric(cd)) {
+        metrics.submitRequirementsMismatchingWithLegacy.increment(cd.project().get(), srName);
+      }
       result.put(legacy.getKey(), legacy.getValue());
     }
-    return result;
+    Set<String> legacyNames =
+        legacyRequirements.keySet().stream()
+            .map(SubmitRequirement::name)
+            .map(String::toLowerCase)
+            .collect(Collectors.toSet());
+    for (String projectConfigSrName : requirementsByName.keySet()) {
+      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. */
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 84d91a4..c5928f6 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -18,7 +18,6 @@
 
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitTypeRecord;
@@ -118,25 +117,12 @@
         "Evaluate submit rules for change %d (caller: %s)",
         cd.change().getId().get(), callerFinder.findCallerLazy());
     try (Timer0.Context ignored = metrics.submitRuleEvaluationLatency.start()) {
-      Change change;
-      ProjectState projectState;
-      try {
-        change = cd.change();
-        if (change == null) {
-          throw new StorageException("Change not found");
-        }
-
-        Project.NameKey name = cd.project();
-        Optional<ProjectState> projectStateOptional = projectCache.get(name);
-        if (!projectStateOptional.isPresent()) {
-          throw new NoSuchProjectException(name);
-        }
-        projectState = projectStateOptional.get();
-      } catch (NoSuchProjectException e) {
-        throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
+      if (cd.change() == null) {
+        throw new StorageException("Change not found");
       }
 
-      if (change.isClosed() && (!opts.recomputeOnClosedChanges() || OnlineReindexMode.isActive())) {
+      if (cd.change().isClosed()
+          && (!opts.recomputeOnClosedChanges() || OnlineReindexMode.isActive())) {
         return cd.notes().getSubmitRecords().stream()
             .map(
                 r -> {
@@ -150,6 +136,15 @@
             .collect(toImmutableList());
       }
 
+      ProjectState projectState =
+          projectCache
+              .get(cd.project())
+              .orElseThrow(
+                  () ->
+                      new IllegalStateException(
+                          "Unable to find project while evaluating submit rule",
+                          new NoSuchProjectException(cd.project())));
+
       // We evaluate all the plugin-defined evaluators,
       // and then we collect the results in one list.
       return Streams.stream(submitRules)
@@ -163,12 +158,14 @@
               c ->
                   c.call(
                       s -> {
-                        Optional<SubmitRecord> evaluate = s.evaluate(cd);
-                        if (evaluate.isPresent()) {
-                          evaluate.get().ruleName =
+                        Optional<SubmitRecord> record = s.evaluate(cd);
+                        if (record.isPresent() && record.get().ruleName == null) {
+                          // Only back-fill the ruleName if it was not populated by the "submit
+                          // rule".
+                          record.get().ruleName =
                               c.getPluginName() + "~" + s.getClass().getSimpleName();
                         }
-                        return evaluate;
+                        return record;
                       }))
           .filter(Optional::isPresent)
           .map(Optional::get)
diff --git a/java/com/google/gerrit/server/project/TagResource.java b/java/com/google/gerrit/server/project/TagResource.java
index 08ef669..d46b9ab 100644
--- a/java/com/google/gerrit/server/project/TagResource.java
+++ b/java/com/google/gerrit/server/project/TagResource.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
+import java.util.Optional;
 
 public class TagResource extends RefResource {
-  public static final TypeLiteral<RestView<TagResource>> TAG_KIND =
-      new TypeLiteral<RestView<TagResource>>() {};
+  public static final TypeLiteral<RestView<TagResource>> TAG_KIND = new TypeLiteral<>() {};
 
   private final TagInfo tagInfo;
 
@@ -40,7 +40,7 @@
   }
 
   @Override
-  public String getRevision() {
-    return tagInfo.revision;
+  public Optional<String> getRevision() {
+    return Optional.ofNullable(tagInfo.revision);
   }
 }
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
index 62f8560..32b87ec 100644
--- a/java/com/google/gerrit/server/project/testing/TestLabels.java
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -19,21 +19,30 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
 import java.util.Arrays;
+import java.util.Optional;
 
 public class TestLabels {
+  public static final String CODE_REVIEW_LABEL_DESCRIPTION = "Code review label description";
+  public static final String VERIFIED_LABEL_DESCRIPTION = "Verified label description";
+
   public static LabelType codeReview() {
     return label(
         LabelId.CODE_REVIEW,
+        CODE_REVIEW_LABEL_DESCRIPTION,
         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() {
     return label(
-        LabelId.VERIFIED, value(1, LabelId.VERIFIED), value(0, "No score"), value(-1, "Fails"));
+        LabelId.VERIFIED,
+        VERIFIED_LABEL_DESCRIPTION,
+        value(1, LabelId.VERIFIED),
+        value(0, "No score"),
+        value(-1, "Fails"));
   }
 
   public static LabelType patchSetLock() {
@@ -48,6 +57,10 @@
     return LabelValue.create((short) value, text);
   }
 
+  public static LabelType label(String name, String description, LabelValue... values) {
+    return labelBuilder(name, values).setDescription(Optional.of(description)).build();
+  }
+
   public static LabelType label(String name, LabelValue... values) {
     return labelBuilder(name, values).build();
   }
diff --git a/java/com/google/gerrit/server/query/FileEditsPredicate.java b/java/com/google/gerrit/server/query/FileEditsPredicate.java
new file mode 100644
index 0000000..7058765
--- /dev/null
+++ b/java/com/google/gerrit/server/query/FileEditsPredicate.java
@@ -0,0 +1,197 @@
+// 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;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.server.git.GitRepositoryManager;
+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.FilePathAdapter;
+import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.filediff.TaggedEdit;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * A submit-requirement predicate that can be used in submit requirements expressions. This
+ * predicate is fulfilled if the diff between the latest patchset of the change and the base commit
+ * includes a specific file path pattern with some specific content modification. The modification
+ * could be an added, deleted or replaced content.
+ */
+public class FileEditsPredicate extends SubmitRequirementPredicate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private DiffOperations diffOperations;
+  private GitRepositoryManager repoManager;
+  private final FileEditsArgs fileEditsArgs;
+
+  public interface Factory {
+    FileEditsPredicate create(FileEditsArgs fileEditsArgs);
+  }
+
+  @AutoValue
+  public abstract static class FileEditsArgs {
+    abstract String filePattern();
+
+    abstract String editPattern();
+
+    public static FileEditsArgs create(String filePattern, String contentPattern) {
+      return new AutoValue_FileEditsPredicate_FileEditsArgs(filePattern, contentPattern);
+    }
+  }
+
+  @AssistedInject
+  public FileEditsPredicate(
+      DiffOperations diffOperations,
+      GitRepositoryManager repoManager,
+      @Assisted FileEditsPredicate.FileEditsArgs fileEditsArgs) {
+    super("fileEdits", fileEditsArgs.filePattern() + "," + fileEditsArgs.editPattern());
+    this.diffOperations = diffOperations;
+    this.repoManager = repoManager;
+    this.fileEditsArgs = fileEditsArgs;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    try {
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffOperations.listModifiedFilesAgainstParent(
+              cd.project(),
+              cd.currentPatchSet().commitId(),
+              /* parentNum= */ 0,
+              DiffOptions.DEFAULTS);
+      FileDiffOutput firstDiff =
+          Iterables.getFirst(modifiedFiles.values(), /* defaultValue= */ null);
+      if (firstDiff == null) {
+        // No available diffs. We cannot identify old and new commit IDs.
+        // engine.fail();
+        return false;
+      }
+
+      Pattern filePattern = null;
+      Pattern editPattern = null;
+      if (fileEditsArgs.filePattern().startsWith("^")) {
+        // We validated the pattern before creating this predicate. No need to revalidate.
+        String pattern = fileEditsArgs.filePattern();
+        filePattern = Pattern.compile(pattern);
+      }
+      if (fileEditsArgs.editPattern().startsWith("^")) {
+        // We validated the pattern before creating this predicate. No need to revalidate.
+        String pattern = fileEditsArgs.editPattern();
+        editPattern = Pattern.compile(pattern);
+      }
+      try (Repository repo = repoManager.openRepository(cd.project());
+          ObjectReader reader = repo.newObjectReader();
+          RevWalk rw = new RevWalk(reader)) {
+        RevTree aTree =
+            firstDiff.oldCommitId().equals(ObjectId.zeroId())
+                ? null
+                : rw.parseTree(firstDiff.oldCommitId());
+        RevTree bTree = rw.parseCommit(firstDiff.newCommitId()).getTree();
+
+        for (FileDiffOutput entry : modifiedFiles.values()) {
+          String newName =
+              FilePathAdapter.getNewPath(entry.oldPath(), entry.newPath(), entry.changeType());
+          String oldName = FilePathAdapter.getOldPath(entry.oldPath(), entry.changeType());
+
+          if (Patch.isMagic(newName)) {
+            continue;
+          }
+
+          if (match(newName, fileEditsArgs.filePattern(), filePattern)
+              || (oldName != null && match(oldName, fileEditsArgs.filePattern(), filePattern))) {
+            List<Edit> edits =
+                entry.edits().stream().map(TaggedEdit::jgitEdit).collect(Collectors.toList());
+            if (edits.isEmpty()) {
+              continue;
+            }
+            Text tA;
+            if (oldName != null) {
+              tA = load(aTree, oldName, reader);
+            } else {
+              tA = load(aTree, newName, reader);
+            }
+            Text tB = load(bTree, newName, reader);
+            for (Edit edit : edits) {
+              if (tA != Text.EMPTY) {
+                String aDiff = tA.getString(edit.getBeginA(), edit.getEndA(), true);
+                if (match(aDiff, fileEditsArgs.editPattern(), editPattern)) {
+                  return true;
+                }
+              }
+              if (tB != Text.EMPTY) {
+                String bDiff = tB.getString(edit.getBeginB(), edit.getEndB(), true);
+                if (match(bDiff, fileEditsArgs.editPattern(), editPattern)) {
+                  return true;
+                }
+              }
+            }
+          }
+        }
+      } catch (IOException e) {
+        logger.atSevere().withCause(e).log("Error while evaluating commit edits.");
+        return false;
+      }
+    } catch (DiffNotAvailableException e) {
+      logger.atSevere().withCause(e).log("Diff error while evaluating commit edits.");
+      return false;
+    }
+    return false;
+  }
+
+  @Override
+  public int getCost() {
+    return 10;
+  }
+
+  private Text load(@Nullable ObjectId tree, String path, ObjectReader reader) throws IOException {
+    if (tree == null || path == null) {
+      return Text.EMPTY;
+    }
+    final TreeWalk tw = TreeWalk.forPath(reader, path, tree);
+    if (tw == null) {
+      return Text.EMPTY;
+    }
+    if (tw.getFileMode(0).getObjectType() != Constants.OBJ_BLOB) {
+      return Text.EMPTY;
+    }
+    return new Text(reader.open(tw.getObjectId(0), Constants.OBJ_BLOB));
+  }
+
+  private boolean match(String text, String search, @Nullable Pattern searchPattern) {
+    return searchPattern == null ? text.contains(search) : searchPattern.matcher(text).find();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 4a95b2e..6ab51c5 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -127,9 +127,7 @@
           .change(changeNotes.get())
           .check(ChangePermission.READ);
     } catch (AuthException e) {
-      String msg = String.format("change %s not found", change);
-      logger.atSevere().withCause(e).log(msg);
-      throw error(msg);
+      throw error(String.format("change %s not found", change), e);
     }
 
     return AccountPredicates.cansee(args, changeNotes.get());
diff --git a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
index 0252a06..e293285 100644
--- a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.query.account;
 
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -36,15 +35,12 @@
   @Override
   public boolean match(AccountState accountState) {
     try {
-      permissionBackend
+      return permissionBackend
           .absentUser(accountState.account().id())
           .change(changeNotes)
-          .check(ChangePermission.READ);
-      return true;
+          .test(ChangePermission.READ);
     } catch (PermissionBackendException e) {
       throw new StorageException("Failed to check if account can see change", e);
-    } catch (AuthException e) {
-      return false;
     }
   }
 
diff --git a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index 1d67009..a0a9f71 100644
--- a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -82,7 +82,7 @@
       Joiner.on(", ")
           .appendTo(
               msg, accountStates.stream().map(a -> a.account().id().toString()).collect(toList()));
-      logger.atWarning().log(msg.toString());
+      logger.atWarning().log("%s", msg);
     }
     return null;
   }
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalContext.java b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
index 4dedbb5..6e433c5 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalContext.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -21,6 +21,8 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /** Entity representing all required information to match predicates for copying approvals. */
 @AutoValue
@@ -41,8 +43,23 @@
   /** {@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();
+
+  /** {@link RevWalk} of the repository for the current commit. */
+  public abstract Config repoConfig();
+
   public static ApprovalContext create(
-      ChangeNotes changeNotes, PatchSetApproval psa, PatchSet patchSet, ChangeKind changeKind) {
+      ChangeNotes changeNotes,
+      PatchSetApproval psa,
+      PatchSet patchSet,
+      ChangeKind changeKind,
+      boolean isMerge,
+      RevWalk revWalk,
+      Config repoConfig) {
     checkState(
         psa.patchSetId().changeId().equals(patchSet.id().changeId()),
         "approval and target must be the same change. got: %s, %s",
@@ -54,6 +71,7 @@
     // As explained in the commit message of this change doing this check is only possible if there
     // 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);
+    return new AutoValue_ApprovalContext(
+        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..f519b16 100644
--- a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
@@ -32,7 +32,39 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
-    return ctx.changeKind().equals(changeKind);
+    if (ctx.changeKind().equals(changeKind)) {
+      // The configured change kind (changeKind) on which approvals should be copied matches the
+      // actual change kind (ctx.changeKind()).
+      return true;
+    }
+
+    // If the configured change kind (changeKind) is REWORK it means that all kind of change kinds
+    // should be matched, since any other change kind is just a more trivial version of a rework.
+    if (changeKind == ChangeKind.REWORK) {
+      return true;
+    }
+
+    // If the actual change kind (ctx.changeKind()) is NO_CHANGE it is also matched if the
+    // configured change kind (changeKind) is:
+    // * TRIVIAL_REBASE: since NO_CHANGE is a special kind of a trivial rebase
+    // * NO_CODE_CHANGE: if there is no change, there is also no code change
+    // * MERGE_FIRST_PARENT_UPDATE (only if the new patch set is a merge commit): if votes should be
+    //   copied on first parent update, they should also be copied if there was no change
+    //
+    // Motivation:
+    // * https://gerrit-review.googlesource.com/c/gerrit/+/74690
+    // * There is no practical use case where you would want votes to be copied on
+    //   TRIVIAL_REBASE|NO_CODE_CHANGE|MERGE_FIRST_PARENT_UPDATE but not on NO_CHANGE. Matching
+    //   NO_CHANGE implicitly for these change kinds makes configuring copy conditions easier (as
+    //   users can simply configure "changekind:<CHANGE-KIND>", rather than
+    //   "changekind:<CHANGE-KIND> OR changekind:NO_CHANGE").
+    // * This preserves backwards compatibility with the deprecated boolean flags for copying
+    //   approvals based on the change kind ('copyAllScoresOnTrivialRebase',
+    //   'copyAllScoresIfNoCodeChange' and 'copyAllScoresOnMergeFirstParentUpdate').
+    return ctx.changeKind() == ChangeKind.NO_CHANGE
+        && (changeKind == ChangeKind.TRIVIAL_REBASE
+            || changeKind == ChangeKind.NO_CODE_CHANGE
+            || (ctx.isMerge() && changeKind == ChangeKind.MERGE_FIRST_PARENT_UPDATE));
   }
 
   @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/ListOfFilesUnchangedPredicate.java b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
index de7dd0a..2a72c49 100644
--- a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
@@ -23,7 +23,8 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -58,17 +59,30 @@
     Integer parentNum =
         isInitialCommit(ctx.changeNotes().getProjectName(), targetPatchSet.commitId()) ? 0 : 1;
     try {
-      Map<String, FileDiffOutput> baseVsCurrent =
-          diffOperations.listModifiedFilesAgainstParent(
-              ctx.changeNotes().getProjectName(), targetPatchSet.commitId(), parentNum);
-      Map<String, FileDiffOutput> baseVsPrior =
-          diffOperations.listModifiedFilesAgainstParent(
-              ctx.changeNotes().getProjectName(), sourcePatchSet.commitId(), parentNum);
-      Map<String, FileDiffOutput> priorVsCurrent =
-          diffOperations.listModifiedFiles(
+      Map<String, ModifiedFile> baseVsCurrent =
+          diffOperations.loadModifiedFilesAgainstParent(
+              ctx.changeNotes().getProjectName(),
+              targetPatchSet.commitId(),
+              parentNum,
+              DiffOptions.DEFAULTS,
+              ctx.revWalk(),
+              ctx.repoConfig());
+      Map<String, ModifiedFile> baseVsPrior =
+          diffOperations.loadModifiedFilesAgainstParent(
               ctx.changeNotes().getProjectName(),
               sourcePatchSet.commitId(),
-              targetPatchSet.commitId());
+              parentNum,
+              DiffOptions.DEFAULTS,
+              ctx.revWalk(),
+              ctx.repoConfig());
+      Map<String, ModifiedFile> priorVsCurrent =
+          diffOperations.loadModifiedFiles(
+              ctx.changeNotes().getProjectName(),
+              sourcePatchSet.commitId(),
+              targetPatchSet.commitId(),
+              DiffOptions.DEFAULTS,
+              ctx.revWalk(),
+              ctx.repoConfig());
       return match(baseVsCurrent, baseVsPrior, priorVsCurrent);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
@@ -84,9 +98,9 @@
    * {@link ChangeType} matches for each modified file.
    */
   public boolean match(
-      Map<String, FileDiffOutput> baseVsCurrent,
-      Map<String, FileDiffOutput> baseVsPrior,
-      Map<String, FileDiffOutput> priorVsCurrent) {
+      Map<String, ModifiedFile> baseVsCurrent,
+      Map<String, ModifiedFile> baseVsPrior,
+      Map<String, ModifiedFile> priorVsCurrent) {
     Set<String> allFiles = new HashSet<>();
     allFiles.addAll(baseVsCurrent.keySet());
     allFiles.addAll(baseVsPrior.keySet());
@@ -94,17 +108,17 @@
       if (Patch.isMagic(file)) {
         continue;
       }
-      FileDiffOutput fileDiffOutput1 = baseVsCurrent.get(file);
-      FileDiffOutput fileDiffOutput2 = baseVsPrior.get(file);
+      ModifiedFile modifiedFile1 = baseVsCurrent.get(file);
+      ModifiedFile modifiedFile2 = baseVsPrior.get(file);
       if (!priorVsCurrent.containsKey(file)) {
         // If the file is not modified between prior and current patchsets, then scan safely skip
-        // it. The file might has been modified due to rebase.
+        // it. The file might have been modified due to rebase.
         continue;
       }
-      if (fileDiffOutput1 == null || fileDiffOutput2 == null) {
+      if (modifiedFile1 == null || modifiedFile2 == null) {
         return false;
       }
-      if (!fileDiffOutput2.changeType().equals(fileDiffOutput1.changeType())) {
+      if (!modifiedFile2.changeType().equals(modifiedFile1.changeType())) {
         return false;
       }
     }
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/AfterPredicate.java b/java/com/google/gerrit/server/query/change/AfterPredicate.java
index 8f92d9a..2514989 100644
--- a/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -17,14 +17,14 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.QueryParseException;
 import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 
 /**
  * Predicate that matches a {@link Timestamp} field from the index in a range from the passed {@code
  * String} representation of the Timestamp value to the maximum supported time.
  */
 public class AfterPredicate extends TimestampRangeChangePredicate {
-  protected final Date cut;
+  protected final Instant cut;
 
   public AfterPredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
       throws QueryParseException {
@@ -33,13 +33,13 @@
   }
 
   @Override
-  public Date getMinTimestamp() {
+  public Instant getMinTimestamp() {
     return cut;
   }
 
   @Override
-  public Date getMaxTimestamp() {
-    return new Date(Long.MAX_VALUE);
+  public Instant getMaxTimestamp() {
+    return Instant.ofEpochMilli(Long.MAX_VALUE);
   }
 
   @Override
@@ -48,6 +48,6 @@
     if (valueTimestamp == null) {
       return false;
     }
-    return valueTimestamp.getTime() >= cut.getTime();
+    return valueTimestamp.getTime() >= cut.toEpochMilli();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/AgePredicate.java b/java/com/google/gerrit/server/query/change/AgePredicate.java
index d38789f..c1138bd 100644
--- a/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -21,26 +21,27 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.sql.Timestamp;
+import java.time.Instant;
 
 public class AgePredicate extends TimestampRangeChangePredicate {
-  protected final long cut;
+  protected final Instant cut;
 
   public AgePredicate(String value) {
     super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_AGE, value);
 
     long s = ConfigUtil.getTimeUnit(getValue(), 0, SECONDS);
     long ms = MILLISECONDS.convert(s, SECONDS);
-    this.cut = TimeUtil.nowMs() - ms;
+    this.cut = Instant.ofEpochMilli(TimeUtil.nowMs() - ms);
   }
 
   @Override
-  public Timestamp getMinTimestamp() {
-    return new Timestamp(0);
+  public Instant getMinTimestamp() {
+    return Instant.EPOCH;
   }
 
   @Override
-  public Timestamp getMaxTimestamp() {
-    return new Timestamp(cut);
+  public Instant getMaxTimestamp() {
+    return cut;
   }
 
   @Override
@@ -49,6 +50,6 @@
     if (valueTimestamp == null) {
       return false;
     }
-    return valueTimestamp.getTime() <= cut;
+    return valueTimestamp.getTime() <= cut.toEpochMilli();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/BeforePredicate.java b/java/com/google/gerrit/server/query/change/BeforePredicate.java
index 6e28ce6..5d682fb 100644
--- a/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -17,14 +17,14 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.QueryParseException;
 import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 
 /**
  * Predicate that matches a {@link Timestamp} field from the index in a range from the the epoch to
  * the passed {@code String} representation of the Timestamp value.
  */
 public class BeforePredicate extends TimestampRangeChangePredicate {
-  protected final Date cut;
+  protected final Instant cut;
 
   public BeforePredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
       throws QueryParseException {
@@ -33,12 +33,12 @@
   }
 
   @Override
-  public Date getMinTimestamp() {
-    return new Date(0);
+  public Instant getMinTimestamp() {
+    return Instant.EPOCH;
   }
 
   @Override
-  public Date getMaxTimestamp() {
+  public Instant getMaxTimestamp() {
     return cut;
   }
 
@@ -48,6 +48,6 @@
     if (valueTimestamp == null) {
       return false;
     }
-    return valueTimestamp.getTime() <= cut.getTime();
+    return valueTimestamp.getTime() <= cut.toEpochMilli();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 7d7b17b..3f5f63b 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -75,9 +75,8 @@
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.SkipCurrentRulesEvaluationOnClosedChanges;
 import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -99,7 +98,7 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -286,13 +285,13 @@
     ChangeData cd =
         new ChangeData(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, project, id, null, null);
+            null, null, null, false, project, id, null, null);
     cd.currentPatchSet =
         PatchSet.builder()
             .id(PatchSet.id(id, currentPatchSetId))
             .commitId(commitId)
             .uploader(Account.id(1000))
-            .createdOn(TimeUtil.nowTs())
+            .createdOn(TimeUtil.now())
             .build();
     return cd;
   }
@@ -304,7 +303,6 @@
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory notesFactory;
   private final CommentsUtil commentsUtil;
-  private final ExperimentFeatures experimentFeatures;
   private final GitRepositoryManager repoManager;
   private final MergeUtil.Factory mergeUtilFactory;
   private final MergeabilityCache mergeabilityCache;
@@ -314,7 +312,9 @@
   private final TrackingFooters trackingFooters;
   private final PureRevert pureRevert;
   private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
+  private final SubmitRequirementsUtil submitRequirementsUtil;
   private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+  private final boolean skipCurrentRulesEvaluationOnClosedChanges;
 
   // Required assisted injected fields.
   private final Project.NameKey project;
@@ -379,7 +379,7 @@
   private Integer unresolvedCommentCount;
   private Integer totalCommentCount;
   private LabelTypes labelTypes;
-  private Optional<Timestamp> mergedOn;
+  private Optional<Instant> mergedOn;
   private ImmutableSetMultimap<NameKey, RefState> refStates;
   private ImmutableList<byte[]> refStatePatterns;
 
@@ -391,7 +391,6 @@
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory notesFactory,
       CommentsUtil commentsUtil,
-      ExperimentFeatures experimentFeatures,
       GitRepositoryManager repoManager,
       MergeUtil.Factory mergeUtilFactory,
       MergeabilityCache mergeabilityCache,
@@ -401,7 +400,9 @@
       TrackingFooters trackingFooters,
       PureRevert pureRevert,
       SubmitRequirementsEvaluator submitRequirementsEvaluator,
+      SubmitRequirementsUtil submitRequirementsUtil,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
+      @SkipCurrentRulesEvaluationOnClosedChanges Boolean skipCurrentRulesEvaluationOnClosedChange,
       @Assisted Project.NameKey project,
       @Assisted Change.Id id,
       @Assisted @Nullable Change change,
@@ -411,7 +412,6 @@
     this.cmUtil = cmUtil;
     this.notesFactory = notesFactory;
     this.commentsUtil = commentsUtil;
-    this.experimentFeatures = experimentFeatures;
     this.repoManager = repoManager;
     this.mergeUtilFactory = mergeUtilFactory;
     this.mergeabilityCache = mergeabilityCache;
@@ -422,7 +422,9 @@
     this.trackingFooters = trackingFooters;
     this.pureRevert = pureRevert;
     this.submitRequirementsEvaluator = submitRequirementsEvaluator;
+    this.submitRequirementsUtil = submitRequirementsUtil;
     this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
+    this.skipCurrentRulesEvaluationOnClosedChanges = skipCurrentRulesEvaluationOnClosedChange;
 
     this.project = project;
     this.legacyId = id;
@@ -443,6 +445,10 @@
     return this;
   }
 
+  public StorageConstraint getStorageConstraint() {
+    return storageConstraint;
+  }
+
   /** Returns {@code true} if we allow reading data from NoteDb. */
   public boolean lazyload() {
     return storageConstraint.ordinal()
@@ -782,7 +788,7 @@
    * @throws StorageException if {@code lazyLoad} is off, {@link ChangeNotes} can not be loaded
    *     because we do not expect to call the database.
    */
-  public Optional<Timestamp> getMergedOn() throws StorageException {
+  public Optional<Instant> getMergedOn() throws StorageException {
     if (mergedOn == null) {
       // The value was not loaded yet, try to get from the database.
       mergedOn = notes().getMergedOn();
@@ -791,7 +797,7 @@
   }
 
   /** Sets the value e.g. when loading from index. */
-  public void setMergedOn(@Nullable Timestamp mergedOn) {
+  public void setMergedOn(@Nullable Instant mergedOn) {
     this.mergedOn = Optional.ofNullable(mergedOn);
   }
 
@@ -845,7 +851,7 @@
       if (!lazyload()) {
         return ImmutableListMultimap.of();
       }
-      allApprovals = approvalsUtil.byChange(notes());
+      allApprovals = approvalsUtil.byChangeExcludingCopiedApprovals(notes());
     }
     return allApprovals;
   }
@@ -1021,6 +1027,18 @@
   }
 
   /**
+   * 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() {
+    Map<SubmitRequirement, SubmitRequirementResult> projectConfigReqs = submitRequirements();
+    Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
+        SubmitRequirementsAdapter.getLegacyRequirements(this);
+    return submitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
+        projectConfigReqs, legacyReqs, this);
+  }
+
+  /**
    * Get all evaluated submit requirements for this change, including those from parent projects.
    * For closed changes, submit requirements are read from the change notes. For active changes,
    * submit requirements are evaluated online.
@@ -1029,10 +1047,6 @@
    * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS}.
    */
   public Map<SubmitRequirement, SubmitRequirementResult> submitRequirements() {
-    if (!experimentFeatures.isFeatureEnabled(
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)) {
-      return Collections.emptyMap();
-    }
     if (submitRequirements == null) {
       if (!lazyload()) {
         return Collections.emptyMap();
@@ -1041,34 +1055,21 @@
       if (c == null || !c.isClosed()) {
         // Open changes: Evaluate submit requirements online.
         submitRequirements =
-            submitRequirementsEvaluator.evaluateAllRequirements(this, /* includeLegacy= */ true);
+            submitRequirementsEvaluator.evaluateAllRequirements(this, /* includeLegacy= */ false);
         return submitRequirements;
       }
       // Closed changes: Load submit requirement results from NoteDb.
-      Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements =
+      submitRequirements =
           notes().getSubmitRequirementsResult().stream()
               .filter(r -> !r.isLegacy())
               .collect(Collectors.toMap(r -> r.submitRequirement(), Function.identity()));
-      Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements =
-          SubmitRequirementsAdapter.getLegacyRequirements(submitRuleEvaluatorFactory, this);
-      submitRequirements =
-          SubmitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
-              projectConfigRequirements, legacyRequirements);
     }
     return submitRequirements;
   }
 
   public void setSubmitRequirements(
       Map<SubmitRequirement, SubmitRequirementResult> submitRequirements) {
-    if (!experimentFeatures.isFeatureEnabled(
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD)) {
-      // Only set back values from the index if the experiment is not active. While the experiment
-      // is active, we want
-      // to compute SRs from scratch to ensure fresh results.
-      // TODO(ghareeb, hiesel): Remove this.
-      this.submitRequirements = submitRequirements;
-    }
+    this.submitRequirements = submitRequirements;
   }
 
   public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
@@ -1086,6 +1087,9 @@
             project(), getId().get());
         return Collections.emptyList();
       }
+      if (skipCurrentRulesEvaluationOnClosedChanges && change().isClosed()) {
+        return notes().getSubmitRecords();
+      }
       records = submitRuleEvaluatorFactory.create(options).evaluate(this);
       submitRecords.put(options, records);
       if (!change().isClosed() && submitRecords.size() == 1) {
@@ -1437,7 +1441,7 @@
 
     public abstract Account.Id author();
 
-    public abstract Timestamp ts();
+    public abstract Instant ts();
   }
 
   @AutoValue
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 7abe4b7..259239b 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
 import com.google.common.base.CharMatcher;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -21,12 +23,15 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.HashtagsUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 
 /** Predicates that match against {@link ChangeData}. */
 public class ChangePredicates {
@@ -76,8 +81,37 @@
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has a pending draft comment.
    */
-  public static Predicate<ChangeData> draftBy(Account.Id id) {
-    return new ChangeIndexCardinalPredicate(ChangeField.DRAFTBY, id.toString(), 20);
+  public static Predicate<ChangeData> draftBy(
+      boolean computeFromAllUsersRepository, CommentsUtil commentsUtil, Account.Id id) {
+    if (!computeFromAllUsersRepository) {
+      return new ChangeIndexCardinalPredicate(ChangeField.DRAFTBY, id.toString(), 20);
+    }
+    Set<Predicate<ChangeData>> changeIdPredicates =
+        commentsUtil.getChangesWithDrafts(id).stream()
+            .map(ChangePredicates::idStr)
+            .collect(toImmutableSet());
+    return changeIdPredicates.isEmpty()
+        ? ChangeIndexPredicate.none()
+        : Predicate.or(changeIdPredicates);
+  }
+
+  /**
+   * Returns a predicate that matches changes where the provided {@link
+   * com.google.gerrit.entities.Account.Id} has starred changes with {@code label}.
+   */
+  public static Predicate<ChangeData> starBy(
+      boolean computeFromAllUsersRepository,
+      StarredChangesUtil starredChangesUtil,
+      Account.Id id,
+      String label) {
+    if (!computeFromAllUsersRepository) {
+      return new StarPredicate(id, label);
+    }
+    Set<Predicate<ChangeData>> starredChanges =
+        starredChangesUtil.byAccountId(id, label).stream()
+            .map(ChangePredicates::idStr)
+            .collect(toImmutableSet());
+    return starredChanges.isEmpty() ? ChangeIndexPredicate.none() : Predicate.or(starredChanges);
   }
 
   /**
@@ -173,6 +207,11 @@
     return new ChangeIndexPredicate(ChangeField.FUZZY_TOPIC, topic);
   }
 
+  /** Returns a predicate that matches changes in the provided {@code topic}. Used with prefixes */
+  public static Predicate<ChangeData> prefixTopic(String topic) {
+    return new ChangeIndexPredicate(ChangeField.PREFIX_TOPIC, topic);
+  }
+
   /** Returns a predicate that matches changes submitted in the provided {@code changeSet}. */
   public static Predicate<ChangeData> submissionId(String changeSet) {
     return new ChangeIndexPredicate(ChangeField.SUBMISSIONID, changeSet);
@@ -197,6 +236,15 @@
         ChangeField.FUZZY_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
   }
 
+  /**
+   * Returns a predicate that matches changes in the provided {@code hashtag}. Used with prefixes
+   */
+  public static Predicate<ChangeData> prefixHashtag(String hashtag) {
+    // Use toLowerCase without locale to match behavior in ChangeField.
+    return new ChangeIndexPredicate(
+        ChangeField.PREFIX_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+  }
+
   /** Returns a predicate that matches changes that modified the provided {@code file}. */
   public static Predicate<ChangeData> file(ChangeQueryBuilder.Arguments args, String file) {
     Predicate<ChangeData> eqPath = path(file);
@@ -222,6 +270,14 @@
   }
 
   /**
+   * Returns a predicate that matches changes with the provided {@code footer} name in their commit
+   * message.
+   */
+  public static Predicate<ChangeData> hasFooter(String footerName) {
+    return new ChangeIndexPredicate(ChangeField.FOOTER_NAME, footerName);
+  }
+
+  /**
    * Returns a predicate that matches changes that modified files in the provided {@code directory}.
    */
   public static Predicate<ChangeData> directory(String directory) {
@@ -311,4 +367,23 @@
   public static Predicate<ChangeData> submitRuleStatus(String value) {
     return new ChangeIndexPredicate(ChangeField.SUBMIT_RULE_RESULT, value);
   }
+
+  /**
+   * Returns a predicate that matches with changes that are pure reverts if {@code value} is equal
+   * to "1", or non-pure reverts if {@code value} is "0".
+   */
+  public static Predicate<ChangeData> pureRevert(String value) {
+    return new ChangeIndexPredicate(ChangeField.IS_PURE_REVERT, value);
+  }
+
+  /**
+   * Returns a predicate that matches with changes that are submittable if {@code value} is equal to
+   * "1", or non-submittable if {@code value} is "0".
+   *
+   * <p>The computation of this field is based on the evaluation of {@link
+   * com.google.gerrit.entities.SubmitRequirement}s.
+   */
+  public static Predicate<ChangeData> isSubmittable(String value) {
+    return new ChangeIndexPredicate(ChangeField.IS_SUBMITTABLE, value);
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index fad3bac..6932b0c 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.server.account.AccountResolver.isSelf;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -42,6 +43,7 @@
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaUtil;
@@ -58,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;
@@ -70,6 +73,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.HasOperandAliasConfig;
 import com.google.gerrit.server.config.OperatorAliasConfig;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
@@ -81,6 +85,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.PredicateArgs.ValOp;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.submit.SubmitDryRun;
 import com.google.inject.Inject;
@@ -92,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;
@@ -139,7 +145,7 @@
   static final int MAX_ACCOUNTS_PER_DEFAULT_FIELD = 10;
 
   // NOTE: As new search operations are added, please keep the suggestions in
-  // gr-search-bar.js up to date.
+  // gr-search-bar.ts up to date.
 
   public static final String FIELD_ADDED = "added";
   public static final String FIELD_AGE = "age";
@@ -161,6 +167,7 @@
   public static final String FIELD_EXTENSION = "extension";
   public static final String FIELD_ONLY_EXTENSIONS = "onlyextensions";
   public static final String FIELD_FOOTER = "footer";
+  public static final String FIELD_FOOTER_NAME = "footernames";
   public static final String FIELD_CONFLICTS = "conflicts";
   public static final String FIELD_DELETED = "deleted";
   public static final String FIELD_DELTA = "delta";
@@ -178,6 +185,7 @@
   public static final String FIELD_MERGEABLE = "mergeable2";
   public static final String FIELD_MERGED_ON = "mergedon";
   public static final String FIELD_MESSAGE = "message";
+  public static final String FIELD_MESSAGE_EXACT = "messageexact";
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_OWNERIN = "ownerin";
   public static final String FIELD_PARENTOF = "parentof";
@@ -203,15 +211,18 @@
   public static final String FIELD_WATCHEDBY = "watchedby";
   public static final String FIELD_WIP = "wip";
   public static final String FIELD_REVERTOF = "revertof";
+  public static final String FIELD_PURE_REVERT = "ispurerevert";
   public static final String FIELD_CHERRYPICK = "cherrypick";
   public static final String FIELD_CHERRY_PICK_OF_CHANGE = "cherrypickofchange";
   public static final String FIELD_CHERRY_PICK_OF_PATCHSET = "cherrypickofpatchset";
+  public static final String FIELD_IS_SUBMITTABLE = "issubmittable";
 
   public static final String ARG_ID_NAME = "name";
   public static final String ARG_ID_USER = "user";
   public static final String ARG_ID_GROUP = "group";
   public static final String ARG_ID_OWNER = "owner";
   public static final String ARG_ID_NON_UPLOADER = "non_uploader";
+  public static final String ARG_COUNT = "count";
   public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
   public static final Account.Id NON_UPLOADER_ACCOUNT_ID = Account.id(-1);
 
@@ -255,6 +266,7 @@
     final OperatorAliasConfig operatorAliasConfig;
     final boolean indexMergeable;
     final boolean conflictsPredicateEnabled;
+    final ExperimentFeatures experimentFeatures;
     final HasOperandAliasConfig hasOperandAliasConfig;
     final PluginSetContext<SubmitRule> submitRules;
 
@@ -290,6 +302,7 @@
         GroupMembers groupMembers,
         OperatorAliasConfig operatorAliasConfig,
         @GerritServerConfig Config gerritConfig,
+        ExperimentFeatures experimentFeatures,
         HasOperandAliasConfig hasOperandAliasConfig,
         ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory,
         PluginSetContext<SubmitRule> submitRules) {
@@ -322,6 +335,7 @@
           operatorAliasConfig,
           MergeabilityComputationBehavior.fromConfig(gerritConfig).includeInIndex(),
           gerritConfig.getBoolean("change", null, "conflictsPredicateEnabled", true),
+          experimentFeatures,
           hasOperandAliasConfig,
           changeIsVisbleToPredicateFactory,
           submitRules);
@@ -356,6 +370,7 @@
         OperatorAliasConfig operatorAliasConfig,
         boolean indexMergeable,
         boolean conflictsPredicateEnabled,
+        ExperimentFeatures experimentFeatures,
         HasOperandAliasConfig hasOperandAliasConfig,
         ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory,
         PluginSetContext<SubmitRule> submitRules) {
@@ -388,6 +403,7 @@
       this.operatorAliasConfig = operatorAliasConfig;
       this.indexMergeable = indexMergeable;
       this.conflictsPredicateEnabled = conflictsPredicateEnabled;
+      this.experimentFeatures = experimentFeatures;
       this.hasOperandAliasConfig = hasOperandAliasConfig;
       this.submitRules = submitRules;
     }
@@ -422,6 +438,7 @@
           operatorAliasConfig,
           indexMergeable,
           conflictsPredicateEnabled,
+          experimentFeatures,
           hasOperandAliasConfig,
           changeIsVisbleToPredicateFactory,
           submitRules);
@@ -466,6 +483,11 @@
 
   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("_");
+  private static final Splitter LABEL_SPLITTER = Splitter.on(",");
 
   @Inject
   ChangeQueryBuilder(Arguments args) {
@@ -519,22 +541,14 @@
 
   @Operator
   public Predicate<ChangeData> mergedBefore(String value) throws QueryParseException {
-    if (!args.index.getSchema().hasField(ChangeField.MERGED_ON)) {
-      throw new QueryParseException(
-          String.format(
-              "'%s' operator is not supported by change index version", OPERATOR_MERGED_BEFORE));
-    }
+    checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_BEFORE);
     return new BeforePredicate(
         ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
   }
 
   @Operator
   public Predicate<ChangeData> mergedAfter(String value) throws QueryParseException {
-    if (!args.index.getSchema().hasField(ChangeField.MERGED_ON)) {
-      throw new QueryParseException(
-          String.format(
-              "'%s' operator is not supported by change index version", OPERATOR_MERGED_AFTER));
-    }
+    checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_AFTER);
     return new AfterPredicate(
         ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
   }
@@ -568,7 +582,7 @@
   }
 
   @Operator
-  public Predicate<ChangeData> status(String statusName) {
+  public Predicate<ChangeData> status(String statusName) throws QueryParseException {
     if ("reviewed".equalsIgnoreCase(statusName)) {
       return ChangePredicates.unreviewed();
     }
@@ -583,21 +597,16 @@
   public Predicate<ChangeData> rule(String value) throws QueryParseException {
     String ruleNameArg = value;
     String statusArg = null;
-    String[] queryArgs = value.split("=");
-    if (queryArgs.length > 2) {
+    List<String> queryArgs = RULE_SPLITTER.splitToList(value);
+    if (queryArgs.size() > 2) {
       throw new QueryParseException(
           "Invalid query arguments. Correct format is 'rule:<rule_name>=<status>' "
               + "with <rule_name> in the form of <plugin>~<rule>. For Gerrit core rules, "
-              + "rule name should be specified either as gerrit~<rule> or <rule>.");
+              + "rule name should be specified as gerrit~<rule>.");
     }
-    if (queryArgs.length == 2) {
-      ruleNameArg = queryArgs[0];
-      statusArg = queryArgs[1];
-    }
-
-    // If ruleName is not prefixed by the plugin name, add the "gerrit~" prefix to it.
-    if (!ruleNameArg.contains("~")) {
-      ruleNameArg = "gerrit~" + ruleNameArg;
+    if (queryArgs.size() == 2) {
+      ruleNameArg = queryArgs.get(0);
+      statusArg = queryArgs.get(1);
     }
 
     return statusArg == null
@@ -624,10 +633,7 @@
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
-        throw new QueryParseException(
-            "'has:attention' operator is not supported by change index version");
-      }
+      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
       return new IsAttentionPredicate();
     }
 
@@ -636,7 +642,7 @@
     }
 
     // for plugins the value will be operandName_pluginName
-    List<String> names = Lists.newArrayList(Splitter.on('_').split(value));
+    List<String> names = PLUGIN_SPLITTER.splitToList(value);
     if (names.size() == 2) {
       ChangeHasOperandFactory op = args.hasOperands.get(names.get(1), names.get(0));
       if (op != null) {
@@ -670,20 +676,13 @@
     }
 
     if ("uploader".equalsIgnoreCase(value)) {
-      if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
-        throw new QueryParseException(
-            "'is:uploader' operator is not supported by change index version");
-      }
+      checkFieldAvailable(ChangeField.UPLOADER, "is:uploader");
       return ChangePredicates.uploader(self());
     }
 
     if ("reviewer".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.WIP)) {
-        return Predicate.and(
-            Predicate.not(new BooleanPredicate(ChangeField.WIP)),
-            ReviewerPredicate.reviewer(self()));
-      }
-      return ReviewerPredicate.reviewer(self());
+      return Predicate.and(
+          Predicate.not(new BooleanPredicate(ChangeField.WIP)), ReviewerPredicate.reviewer(self()));
     }
 
     if ("cc".equalsIgnoreCase(value)) {
@@ -698,25 +697,16 @@
     }
 
     if ("merge".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.MERGE)) {
-        return new BooleanPredicate(ChangeField.MERGE);
-      }
-      throw new QueryParseException("'is:merge' operator is not supported by change index version");
+      checkFieldAvailable(ChangeField.MERGE, "is:merge");
+      return new BooleanPredicate(ChangeField.MERGE);
     }
 
     if ("private".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.PRIVATE)) {
-        return new BooleanPredicate(ChangeField.PRIVATE);
-      }
-      throw new QueryParseException(
-          "'is:private' operator is not supported by change index version");
+      return new BooleanPredicate(ChangeField.PRIVATE);
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
-        throw new QueryParseException(
-            "'is:attention' operator is not supported by change index version");
-      }
+      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
       return new IsAttentionPredicate();
     }
 
@@ -728,16 +718,25 @@
       return ChangePredicates.assignee(Account.id(ChangeField.NO_ASSIGNEE));
     }
 
+    if ("pure-revert".equalsIgnoreCase(value)) {
+      checkFieldAvailable(ChangeField.IS_PURE_REVERT, "is:pure-revert");
+      return ChangePredicates.pureRevert("1");
+    }
+
     if ("submittable".equalsIgnoreCase(value)) {
-      // SubmittablePredicate will match if *any* of the submit records are OK,
-      // but we need to check that they're *all* OK, so check that none of the
-      // submit records match any of the negative cases. To avoid checking yet
-      // more negative cases for CLOSED and FORCED, instead make sure at least
-      // one submit record is OK.
-      return Predicate.and(
-          new SubmittablePredicate(SubmitRecord.Status.OK),
-          Predicate.not(new SubmittablePredicate(SubmitRecord.Status.NOT_READY)),
-          Predicate.not(new SubmittablePredicate(SubmitRecord.Status.RULE_ERROR)));
+      if (!args.index.getSchema().hasField(ChangeField.IS_SUBMITTABLE)) {
+        // SubmittablePredicate will match if *any* of the submit records are OK,
+        // but we need to check that they're *all* OK, so check that none of the
+        // submit records match any of the negative cases. To avoid checking yet
+        // more negative cases for CLOSED and FORCED, instead make sure at least
+        // one submit record is OK.
+        return Predicate.and(
+            new SubmittablePredicate(SubmitRecord.Status.OK),
+            Predicate.not(new SubmittablePredicate(SubmitRecord.Status.NOT_READY)),
+            Predicate.not(new SubmittablePredicate(SubmitRecord.Status.RULE_ERROR)));
+      }
+      checkFieldAvailable(ChangeField.IS_SUBMITTABLE, "is:submittable");
+      return new IsSubmittablePredicate();
     }
 
     if ("ignored".equalsIgnoreCase(value)) {
@@ -745,30 +744,21 @@
     }
 
     if ("started".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.STARTED)) {
-        return new BooleanPredicate(ChangeField.STARTED);
-      }
-      throw new QueryParseException(
-          "'is:started' operator is not supported by change index version");
+      checkFieldAvailable(ChangeField.STARTED, "is:started");
+      return new BooleanPredicate(ChangeField.STARTED);
     }
 
     if ("wip".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.WIP)) {
-        return new BooleanPredicate(ChangeField.WIP);
-      }
-      throw new QueryParseException("'is:wip' operator is not supported by change index version");
+      return new BooleanPredicate(ChangeField.WIP);
     }
 
     if ("cherrypick".equalsIgnoreCase(value)) {
-      if (args.getSchema().hasField(ChangeField.CHERRY_PICK)) {
-        return new BooleanPredicate(ChangeField.CHERRY_PICK);
-      }
-      throw new QueryParseException(
-          "'is:cherrypick' operator is not supported by change index version");
+      checkFieldAvailable(ChangeField.CHERRY_PICK, "is:cherrypick");
+      return new BooleanPredicate(ChangeField.CHERRY_PICK);
     }
 
     // for plugins the value will be operandName_pluginName
-    List<String> names = Lists.newArrayList(Splitter.on('_').split(value));
+    List<String> names = PLUGIN_SPLITTER.splitToList(value);
     if (names.size() == 2) {
       ChangeIsOperandFactory op = args.isOperands.get(names.get(1), names.get(0));
       if (op != null) {
@@ -898,14 +888,21 @@
       return ChangePredicates.hashtag(hashtag);
     }
 
-    if (!args.index.getSchema().hasField(ChangeField.FUZZY_HASHTAG)) {
-      throw new QueryParseException(
-          "'inhashtag' operator is not supported by change index version");
-    }
+    checkFieldAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
     return ChangePredicates.fuzzyHashtag(hashtag);
   }
 
   @Operator
+  public Predicate<ChangeData> prefixhashtag(String hashtag) throws QueryParseException {
+    if (hashtag.isEmpty()) {
+      return ChangePredicates.hashtag(hashtag);
+    }
+
+    checkFieldAvailable(ChangeField.PREFIX_HASHTAG, "prefixhashtag");
+    return ChangePredicates.prefixHashtag(hashtag);
+  }
+
+  @Operator
   public Predicate<ChangeData> topic(String name) {
     return ChangePredicates.exactTopic(name);
   }
@@ -922,6 +919,16 @@
   }
 
   @Operator
+  public Predicate<ChangeData> prefixtopic(String name) throws QueryParseException {
+    if (name.isEmpty()) {
+      return ChangePredicates.exactTopic(name);
+    }
+
+    checkFieldAvailable(ChangeField.PREFIX_TOPIC, "prefixtopic");
+    return ChangePredicates.prefixTopic(name);
+  }
+
+  @Operator
   public Predicate<ChangeData> ref(String ref) throws QueryParseException {
     if (ref.startsWith("^")) {
       return new RegexRefPredicate(ref);
@@ -930,12 +937,18 @@
   }
 
   @Operator
-  public Predicate<ChangeData> f(String file) {
+  public Predicate<ChangeData> f(String file) throws QueryParseException {
     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) {
+  public Predicate<ChangeData> file(String file) throws QueryParseException {
     if (file.startsWith("^")) {
       return new RegexPathPredicate(file);
     }
@@ -951,54 +964,47 @@
   }
 
   @Operator
-  public Predicate<ChangeData> ext(String ext) throws QueryParseException {
+  public Predicate<ChangeData> ext(String ext) {
     return extension(ext);
   }
 
   @Operator
-  public Predicate<ChangeData> extension(String ext) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.EXTENSION)) {
-      return new FileExtensionPredicate(ext);
-    }
-    throw new QueryParseException("'extension' operator is not supported by change index version");
+  public Predicate<ChangeData> extension(String ext) {
+    return new FileExtensionPredicate(ext);
   }
 
   @Operator
-  public Predicate<ChangeData> onlyexts(String extList) throws QueryParseException {
+  public Predicate<ChangeData> onlyexts(String extList) {
     return onlyextensions(extList);
   }
 
   @Operator
-  public Predicate<ChangeData> onlyextensions(String extList) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.ONLY_EXTENSIONS)) {
-      return new FileExtensionListPredicate(extList);
-    }
-    throw new QueryParseException(
-        "'onlyextensions' operator is not supported by change index version");
+  public Predicate<ChangeData> onlyextensions(String extList) {
+    return new FileExtensionListPredicate(extList);
   }
 
   @Operator
-  public Predicate<ChangeData> footer(String footer) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.FOOTER)) {
-      return ChangePredicates.footer(footer);
-    }
-    throw new QueryParseException("'footer' operator is not supported by change index version");
+  public Predicate<ChangeData> footer(String footer) {
+    return ChangePredicates.footer(footer);
   }
 
   @Operator
-  public Predicate<ChangeData> dir(String directory) throws QueryParseException {
+  public Predicate<ChangeData> hasfooter(String footerName) throws QueryParseException {
+    checkFieldAvailable(ChangeField.FOOTER_NAME, "hasfooter");
+    return ChangePredicates.hasFooter(footerName);
+  }
+
+  @Operator
+  public Predicate<ChangeData> dir(String directory) {
     return directory(directory);
   }
 
   @Operator
-  public Predicate<ChangeData> directory(String directory) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.DIRECTORY)) {
-      if (directory.startsWith("^")) {
-        return new RegexDirectoryPredicate(directory);
-      }
-      return ChangePredicates.directory(directory);
+  public Predicate<ChangeData> directory(String directory) {
+    if (directory.startsWith("^")) {
+      return new RegexDirectoryPredicate(directory);
     }
-    throw new QueryParseException("'directory' operator is not supported by change index version");
+    return ChangePredicates.directory(directory);
   }
 
   @Operator
@@ -1006,6 +1012,8 @@
       throws QueryParseException, IOException, ConfigInvalidException {
     Set<Account.Id> accounts = null;
     AccountGroup.UUID group = null;
+    Integer count = null;
+    PredicateArgs.Operator countOp = null;
 
     // Parse for:
     // label:Code-Review=1,user=jsmith or
@@ -1016,24 +1024,48 @@
     // Special case: votes by owners can be tracked with ",owner":
     // label:Code-Review+2,owner
     // label:Code-Review+2,user=owner
-    List<String> splitReviewer = Lists.newArrayList(Splitter.on(',').limit(2).split(name));
+    // label:Code-Review+1,count=2
+    List<String> splitReviewer = LABEL_SPLITTER.limit(2).splitToList(name);
     name = splitReviewer.get(0); // remove all but the vote piece, e.g.'CodeReview=1'
 
     if (splitReviewer.size() == 2) {
       // process the user/group piece
       PredicateArgs lblArgs = new PredicateArgs(splitReviewer.get(1));
 
-      for (Map.Entry<String, String> pair : lblArgs.keyValue.entrySet()) {
-        if (pair.getKey().equalsIgnoreCase(ARG_ID_USER)) {
-          if (pair.getValue().equals(ARG_ID_OWNER)) {
+      // Disallow using the "count=" arg in conjunction with the "user=" or "group=" args. to avoid
+      // unnecessary complexity.
+      assertDisjunctive(lblArgs, ARG_COUNT, ARG_ID_USER);
+      assertDisjunctive(lblArgs, ARG_COUNT, ARG_ID_GROUP);
+
+      for (Map.Entry<String, ValOp> pair : lblArgs.keyValue.entrySet()) {
+        String key = pair.getKey();
+        String value = pair.getValue().value();
+        PredicateArgs.Operator operator = pair.getValue().operator();
+        if (key.equalsIgnoreCase(ARG_ID_USER)) {
+          if (value.equals(ARG_ID_OWNER)) {
             accounts = Collections.singleton(OWNER_ACCOUNT_ID);
-          } else if (pair.getValue().equals(ARG_ID_NON_UPLOADER)) {
+          } else if (value.equals(ARG_ID_NON_UPLOADER)) {
             accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
           } else {
-            accounts = parseAccount(pair.getValue());
+            accounts = parseAccount(value);
           }
-        } else if (pair.getKey().equalsIgnoreCase(ARG_ID_GROUP)) {
-          group = parseGroup(pair.getValue()).getUUID();
+        } else if (key.equalsIgnoreCase(ARG_ID_GROUP)) {
+          group = parseGroup(value).getUUID();
+        } else if (key.equalsIgnoreCase(ARG_COUNT)) {
+          if (!isInt(value)) {
+            throw new QueryParseException("Invalid count argument. Value should be an integer");
+          }
+          count = Integer.parseInt(value);
+          countOp = operator;
+          if (count == 0) {
+            throw new QueryParseException("Argument count=0 is not allowed.");
+          }
+          if (count > LabelPredicate.MAX_COUNT) {
+            throw new QueryParseException(
+                String.format(
+                    "count=%d is not allowed. Maximum allowed value for count is %d.",
+                    count, LabelPredicate.MAX_COUNT));
+          }
         } else {
           throw new QueryParseException("Invalid argument identifier '" + pair.getKey() + "'");
         }
@@ -1070,7 +1102,7 @@
     // If the vote piece looks like Code-Review=NEED with a valid non-numeric
     // submit record status, interpret as a submit record query.
     int eq = name.indexOf('=');
-    if (args.getSchema().hasField(ChangeField.SUBMIT_RECORD) && eq > 0) {
+    if (eq > 0) {
       String statusName = name.substring(eq + 1).toUpperCase();
       if (!isInt(statusName) && !MagicLabelValue.tryParse(statusName).isPresent()) {
         SubmitRecord.Label.Status status =
@@ -1082,7 +1114,18 @@
       }
     }
 
-    return new LabelPredicate(args, name, accounts, group);
+    return new LabelPredicate(args, name, accounts, group, count, countOp);
+  }
+
+  /** Assert that keys {@code k1} and {@code k2} do not exist in {@code labelArgs} together. */
+  private void assertDisjunctive(PredicateArgs labelArgs, String k1, String k2)
+      throws QueryParseException {
+    Map<String, ValOp> keyValArgs = labelArgs.keyValue;
+    if (keyValArgs.containsKey(k1) && keyValArgs.containsKey(k2)) {
+      throw new QueryParseException(
+          String.format(
+              "Cannot use the '%s' argument in conjunction with the '%s' argument", k1, k2));
+    }
   }
 
   private static boolean isInt(String s) {
@@ -1096,7 +1139,11 @@
   }
 
   @Operator
-  public Predicate<ChangeData> message(String text) {
+  public Predicate<ChangeData> message(String text) throws QueryParseException {
+    if (text.startsWith("^")) {
+      checkFieldAvailable(ChangeField.COMMIT_MESSAGE_EXACT, "messageexact");
+      return new RegexMessagePredicate(text);
+    }
     return ChangePredicates.message(text);
   }
 
@@ -1112,15 +1159,29 @@
   }
 
   private Predicate<ChangeData> ignoredBySelf() throws QueryParseException {
-    return new StarPredicate(self(), StarredChangesUtil.IGNORE_LABEL);
+    return ChangePredicates.starBy(
+        args.experimentFeatures.isFeatureEnabled(
+            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
+        args.starredChangesUtil,
+        self(),
+        StarredChangesUtil.IGNORE_LABEL);
   }
 
   private Predicate<ChangeData> starredBySelf() throws QueryParseException {
-    return new StarPredicate(self(), StarredChangesUtil.DEFAULT_LABEL);
+    return ChangePredicates.starBy(
+        args.experimentFeatures.isFeatureEnabled(
+            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
+        args.starredChangesUtil,
+        self(),
+        StarredChangesUtil.DEFAULT_LABEL);
   }
 
   private Predicate<ChangeData> draftBySelf() throws QueryParseException {
-    return ChangePredicates.draftBy(self());
+    return ChangePredicates.draftBy(
+        args.experimentFeatures.isFeatureEnabled(
+            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
+        args.commentsUtil,
+        self());
   }
 
   @Operator
@@ -1198,9 +1259,7 @@
   @Operator
   public Predicate<ChangeData> uploader(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
-      throw new QueryParseException("'uploader' operator is not supported by change index version");
-    }
+    checkFieldAvailable(ChangeField.UPLOADER, "uploader");
     return uploader(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1215,10 +1274,7 @@
   @Operator
   public Predicate<ChangeData> attention(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
-      throw new QueryParseException(
-          "'attention' operator is not supported by change index version");
-    }
+    checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
     return attention(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1258,9 +1314,7 @@
 
   @Operator
   public Predicate<ChangeData> uploaderin(String group) throws QueryParseException, IOException {
-    if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
-      throw new QueryParseException("'uploader' operator is not supported by change index version");
-    }
+    checkFieldAvailable(ChangeField.UPLOADER, "uploaderin");
 
     GroupReference g = parseGroup(group);
     AccountGroup.UUID groupId = g.getUUID();
@@ -1300,10 +1354,7 @@
     if (Objects.equals(byState, Predicate.<ChangeData>any())) {
       return Predicate.any();
     }
-    if (args.getSchema().hasField(ChangeField.WIP)) {
-      return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP)), byState);
-    }
-    return byState;
+    return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP)), byState);
   }
 
   @Operator
@@ -1388,7 +1439,7 @@
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       // [name=]<name>
       if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
-        name = inputArgs.keyValue.get(ARG_ID_NAME);
+        name = inputArgs.keyValue.get(ARG_ID_NAME).value();
       } else if (inputArgs.positional.size() == 1) {
         name = Iterables.getOnlyElement(inputArgs.positional);
       } else if (inputArgs.positional.size() > 1) {
@@ -1397,7 +1448,7 @@
 
       // [,user=<user>]
       if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
-        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
         if (accounts != null && accounts.size() > 1) {
           throw error(
               String.format(
@@ -1439,7 +1490,7 @@
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       // [name=]<name>
       if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
-        name = inputArgs.keyValue.get(ARG_ID_NAME);
+        name = inputArgs.keyValue.get(ARG_ID_NAME).value();
       } else if (inputArgs.positional.size() == 1) {
         name = Iterables.getOnlyElement(inputArgs.positional);
       } else if (inputArgs.positional.size() > 1) {
@@ -1448,7 +1499,7 @@
 
       // [,user=<user>]
       if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
-        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+        Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
         if (accounts != null && accounts.size() > 1) {
           throw error(
               String.format(
@@ -1459,9 +1510,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);
       }
@@ -1474,32 +1523,33 @@
     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 {
-    if (args.getSchema().hasField(ChangeField.EXACT_AUTHOR)) {
-      return getAuthorOrCommitterPredicate(
-          who.trim(), ChangePredicates::exactAuthor, ChangePredicates::author);
-    }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), ChangePredicates::author);
+    return getAuthorOrCommitterPredicate(
+        who.trim(), ChangePredicates::exactAuthor, ChangePredicates::author);
   }
 
   @Operator
   public Predicate<ChangeData> committer(String who) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.EXACT_COMMITTER)) {
-      return getAuthorOrCommitterPredicate(
-          who.trim(), ChangePredicates::exactCommitter, ChangePredicates::committer);
-    }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), ChangePredicates::committer);
-  }
-
-  @Operator
-  public Predicate<ChangeData> submittable(String str) throws QueryParseException {
-    SubmitRecord.Status status =
-        Enums.getIfPresent(SubmitRecord.Status.class, str.toUpperCase()).orNull();
-    if (status == null) {
-      throw error("invalid value for submittable:" + str);
-    }
-    return new SubmittablePredicate(status);
+    return getAuthorOrCommitterPredicate(
+        who.trim(), ChangePredicates::exactCommitter, ChangePredicates::committer);
   }
 
   @Operator
@@ -1512,41 +1562,31 @@
     if (value == null || Ints.tryParse(value) == null) {
       throw new QueryParseException("'revertof' must be an integer");
     }
-    if (args.getSchema().hasField(ChangeField.REVERT_OF)) {
-      return ChangePredicates.revertOf(Change.id(Ints.tryParse(value)));
-    }
-    throw new QueryParseException("'revertof' operator is not supported by change index version");
+    return ChangePredicates.revertOf(Change.id(Ints.tryParse(value)));
   }
 
   @Operator
-  public Predicate<ChangeData> submissionId(String value) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.SUBMISSIONID)) {
-      return ChangePredicates.submissionId(value);
-    }
-    throw new QueryParseException(
-        "'submissionid' operator is not supported by change index version");
+  public Predicate<ChangeData> submissionId(String value) {
+    return ChangePredicates.submissionId(value);
   }
 
   @Operator
   public Predicate<ChangeData> cherryPickOf(String value) throws QueryParseException {
-    if (args.getSchema().hasField(ChangeField.CHERRY_PICK_OF_CHANGE)
-        && args.getSchema().hasField(ChangeField.CHERRY_PICK_OF_PATCHSET)) {
-      if (Ints.tryParse(value) != null) {
-        return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
-      }
-      try {
-        PatchSet.Id patchSetId = PatchSet.Id.parse(value);
-        return ChangePredicates.cherryPickOf(patchSetId);
-      } catch (IllegalArgumentException e) {
-        throw new QueryParseException(
-            "'"
-                + value
-                + "' is not a valid input. It must be in the 'ChangeNumber[,PatchsetNumber]' format.",
-            e);
-      }
+    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
+    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
+    if (Ints.tryParse(value) != null) {
+      return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
     }
-    throw new QueryParseException(
-        "'cherrypickof' operator is not supported by change index version");
+    try {
+      PatchSet.Id patchSetId = PatchSet.Id.parse(value);
+      return ChangePredicates.cherryPickOf(patchSetId);
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(
+          "'"
+              + value
+              + "' is not a valid input. It must be in the 'ChangeNumber[,PatchsetNumber]' format.",
+          e);
+    }
   }
 
   @Override
@@ -1605,6 +1645,14 @@
     return Predicate.or(predicates);
   }
 
+  protected void checkFieldAvailable(FieldDef<ChangeData, ?> field, String operator)
+      throws QueryParseException {
+    if (!args.index.getSchema().hasField(field)) {
+      throw new QueryParseException(
+          String.format("'%s' operator is not supported by change index version", operator));
+    }
+  }
+
   private Predicate<ChangeData> getAuthorOrCommitterPredicate(
       String who,
       Function<String, Predicate<ChangeData>> exactPredicateFunc,
@@ -1719,11 +1767,9 @@
       String who, ReviewerStateInternal state, boolean forDefaultField)
       throws QueryParseException, IOException, ConfigInvalidException {
     Predicate<ChangeData> reviewerByEmailPredicate = null;
-    if (args.index.getSchema().hasField(ChangeField.REVIEWER_BY_EMAIL)) {
-      Address address = Address.tryParse(who);
-      if (address != null) {
-        reviewerByEmailPredicate = ReviewerByEmailPredicate.forState(address, state);
-      }
+    Address address = Address.tryParse(who);
+    if (address != null) {
+      reviewerByEmailPredicate = ReviewerByEmailPredicate.forState(address, state);
     }
 
     Predicate<ChangeData> reviewerPredicate = null;
diff --git a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 4a56cdf..5840db4 100644
--- a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.Change.Status;
 import com.google.gerrit.index.query.HasCardinality;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.ArrayList;
 import java.util.List;
@@ -76,7 +77,7 @@
     return status.name().toLowerCase();
   }
 
-  public static Predicate<ChangeData> parse(String value) {
+  public static Predicate<ChangeData> parse(String value) throws QueryParseException {
     String lower = value.toLowerCase();
     NavigableMap<String, Predicate<ChangeData>> head = PREDICATES.tailMap(lower, true);
     if (!head.isEmpty()) {
@@ -86,7 +87,7 @@
         return e.getValue();
       }
     }
-    return NONE;
+    throw new QueryParseException("Unrecognized value: " + value);
   }
 
   public static Predicate<ChangeData> open() {
diff --git a/java/com/google/gerrit/server/query/change/ConstantPredicate.java b/java/com/google/gerrit/server/query/change/ConstantPredicate.java
new file mode 100644
index 0000000..f0a85fe
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ConstantPredicate.java
@@ -0,0 +1,38 @@
+// 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.change;
+
+import com.google.inject.Singleton;
+
+/**
+ * A submit requirement predicate (can only be used in submit requirement expressions) that always
+ * evaluates to {@code true} if the value is equal to "true" or false otherwise.
+ */
+@Singleton
+public class ConstantPredicate extends SubmitRequirementPredicate {
+  public ConstantPredicate(String value) {
+    super("is", value);
+  }
+
+  @Override
+  public boolean match(ChangeData object) {
+    return "true".equals(value);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java b/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
new file mode 100644
index 0000000..5a51f5d
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
@@ -0,0 +1,124 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A submit requirement predicate that allows checking for distinct voters across labels.
+ *
+ * <p>Examples:
+ *
+ * <ul>
+ *   <li>[Label-Name1,Label-Name2],value=MAX,count=2
+ *   <li>[Label-Name1,Label-Name2,Label-Name3],count=5
+ * </ul>
+ */
+public class DistinctVotersPredicate extends SubmitRequirementPredicate {
+  public interface Factory {
+    DistinctVotersPredicate create(String value) throws QueryParseException;
+  }
+
+  private static final Pattern PATTERN =
+      Pattern.compile(
+          "\\[(?<labels>[^\\]]+)\\](,value=(?<value>MAX|MIN|-?[0-9]+))?,count\\>(?<count>[0-9]+)");
+
+  private final ProjectCache projectCache;
+  private final ImmutableList<String> labelNames;
+  private final boolean enforceMaxVote;
+  private final boolean enforceMinVote;
+  private final Optional<Integer> enforceIntegerVote;
+  private final int numDistinctVotes;
+
+  @Inject
+  public DistinctVotersPredicate(ProjectCache projectCache, @Assisted String value)
+      throws QueryParseException {
+    super("distinctvoters", value);
+    this.projectCache = projectCache;
+    Matcher m = PATTERN.matcher(value);
+    if (!m.matches()) {
+      throw new QueryParseException("input " + value + " invalid");
+    }
+    labelNames = ImmutableList.copyOf(Splitter.on(',').split(m.group("labels")));
+    Integer votes = Ints.tryParse(m.group("count"));
+    if (votes == null) {
+      throw new QueryParseException("unable to parse number of required votes");
+    }
+    numDistinctVotes = votes + 1; // Regex has > sign
+    if (m.group("value") != null) {
+      enforceMaxVote = "MAX".equals(m.group("value"));
+      enforceMinVote = "MIN".equals(m.group("value"));
+      enforceIntegerVote = Optional.ofNullable(Ints.tryParse(m.group("value")));
+    } else {
+      enforceMaxVote = false;
+      enforceMinVote = false;
+      enforceIntegerVote = Optional.empty();
+    }
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    ProjectState projectState =
+        projectCache
+            .get(cd.project())
+            .orElseThrow(() -> new IllegalStateException("project absent " + cd.project()));
+    return cd.currentApprovals().stream()
+            .filter(psa -> filterPatchSetApproval(psa, projectState))
+            .map(PatchSetApproval::accountId)
+            .distinct()
+            .count()
+        >= numDistinctVotes;
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  private boolean filterPatchSetApproval(PatchSetApproval psa, ProjectState projectState) {
+    if (!labelNames.contains(psa.label())) {
+      return false;
+    }
+
+    Optional<LabelType> labelType = projectState.getLabelTypes().byLabel(psa.labelId());
+    if (labelType.isEmpty()) {
+      // Label is not configured in this project
+      return false;
+    }
+
+    if (enforceMaxVote && psa.value() != labelType.get().getMaxPositive()) {
+      return false;
+    }
+    if (enforceMinVote && psa.value() != labelType.get().getMaxNegative()) {
+      return false;
+    }
+    if (enforceIntegerVote.isPresent() && psa.value() != enforceIntegerVote.get()) {
+      return false;
+    }
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
index 7f3978d..f572063 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
@@ -29,15 +30,17 @@
 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.query.change.ChangeData.StorageConstraint;
 import java.util.Optional;
 
 public class EqualsLabelPredicates {
   public static class PostFilterEqualsLabelPredicate extends PostFilterPredicate<ChangeData> {
     private final Matcher matcher;
 
-    public PostFilterEqualsLabelPredicate(LabelPredicate.Args args, String label, int expVal) {
-      super(ChangeQueryBuilder.FIELD_LABEL, ChangeField.formatLabel(label, expVal));
-      matcher = new Matcher(args, label, expVal);
+    public PostFilterEqualsLabelPredicate(
+        LabelPredicate.Args args, String label, int expVal, @Nullable Integer count) {
+      super(ChangeQueryBuilder.FIELD_LABEL, ChangeField.formatLabel(label, expVal, count));
+      matcher = new Matcher(args, label, expVal, count);
     }
 
     @Override
@@ -54,14 +57,19 @@
   public static class IndexEqualsLabelPredicate extends ChangeIndexPostFilterPredicate {
     private final Matcher matcher;
 
-    public IndexEqualsLabelPredicate(LabelPredicate.Args args, String label, int expVal) {
-      this(args, label, expVal, null);
+    public IndexEqualsLabelPredicate(
+        LabelPredicate.Args args, String label, int expVal, @Nullable Integer count) {
+      this(args, label, expVal, null, count);
     }
 
     public IndexEqualsLabelPredicate(
-        LabelPredicate.Args args, String label, int expVal, Account.Id account) {
-      super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account));
-      this.matcher = new Matcher(args, label, expVal, account);
+        LabelPredicate.Args args,
+        String label,
+        int expVal,
+        Account.Id account,
+        @Nullable Integer count) {
+      super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account, count));
+      this.matcher = new Matcher(args, label, expVal, account, count);
     }
 
     @Override
@@ -79,16 +87,32 @@
     protected final ProjectCache projectCache;
     protected final PermissionBackend permissionBackend;
     protected final IdentifiedUser.GenericFactory userFactory;
+    /** label name to be matched. */
     protected final String label;
+    /** Expected vote value for the label. */
     protected final int expVal;
+
+    /**
+     * Number of times the value {@link #expVal} for label {@link #label} should occur. If null,
+     * match with any count greater or equal to 1.
+     */
+    @Nullable protected final Integer count;
+
+    /** Account ID that has voted on the label. */
     protected final Account.Id account;
+
     protected final AccountGroup.UUID group;
 
-    public Matcher(LabelPredicate.Args args, String label, int expVal) {
-      this(args, label, expVal, null);
+    public Matcher(LabelPredicate.Args args, String label, int expVal, @Nullable Integer count) {
+      this(args, label, expVal, null, count);
     }
 
-    public Matcher(LabelPredicate.Args args, String label, int expVal, Account.Id account) {
+    public Matcher(
+        LabelPredicate.Args args,
+        String label,
+        int expVal,
+        Account.Id account,
+        @Nullable Integer count) {
       this.permissionBackend = args.permissionBackend;
       this.projectCache = args.projectCache;
       this.userFactory = args.userFactory;
@@ -96,6 +120,7 @@
       this.label = label;
       this.expVal = expVal;
       this.account = account;
+      this.count = count;
     }
 
     public boolean match(ChangeData cd) {
@@ -105,6 +130,14 @@
         return false;
       }
 
+      if (Integer.valueOf(0).equals(count)) {
+        // We don't match against count=0 so that the computation is identical to the stored values
+        // in the index. We do that since computing count=0 requires looping on all {label_type,
+        // vote_value} for the change and storing a {count=0} format for it in the change index
+        // which is computationally expensive.
+        return false;
+      }
+
       Optional<ProjectState> project = projectCache.get(c.getDest().project());
       if (!project.isPresent()) {
         // The project has disappeared.
@@ -117,21 +150,23 @@
       }
 
       boolean hasVote = false;
+      int matchingVotes = 0;
+      StorageConstraint currentStorageConstraint = cd.getStorageConstraint();
       cd.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
       for (PatchSetApproval psa : cd.currentApprovals()) {
         if (labelType.matches(psa)) {
           hasVote = true;
           if (match(cd, psa)) {
-            return true;
+            matchingVotes += 1;
           }
         }
       }
-
+      cd.setStorageConstraint(currentStorageConstraint);
       if (!hasVote && expVal == 0) {
         return true;
       }
 
-      return false;
+      return count == null ? matchingVotes >= 1 : matchingVotes == count;
     }
 
     private boolean match(ChangeData cd, PatchSetApproval psa) {
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index c8e9f66..e7b25fb 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -22,6 +22,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
@@ -294,7 +295,7 @@
     return and(project(project), or(groupPredicates));
   }
 
-  public static List<ChangeData> byProjectGroups(
+  public static ImmutableList<ChangeData> byProjectGroups(
       Provider<InternalChangeQuery> queryProvider,
       IndexConfig indexConfig,
       Project.NameKey project,
@@ -314,7 +315,7 @@
           querySupplier, byProjectGroupsPredicate(indexConfig, project, groups));
     }
     Set<Change.Id> seen = new HashSet<>();
-    List<ChangeData> result = new ArrayList<>();
+    ImmutableList.Builder<ChangeData> result = ImmutableList.builder();
     for (List<String> part : Iterables.partition(groups, batchSize)) {
       for (ChangeData cd :
           queryExhaustively(querySupplier, byProjectGroupsPredicate(indexConfig, project, part))) {
@@ -323,6 +324,6 @@
         }
       }
     }
-    return result;
+    return result.build();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java b/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
new file mode 100644
index 0000000..17de132
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
@@ -0,0 +1,65 @@
+// 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.change;
+
+import com.google.gerrit.index.query.NotPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class IsSubmittablePredicate extends BooleanPredicate {
+  public IsSubmittablePredicate() {
+    super(ChangeField.IS_SUBMITTABLE);
+  }
+
+  /**
+   * Rewrite the is:submittable predicate.
+   *
+   * <p>If we run a query with "is:submittable OR -is:submittable" the result should match all
+   * changes. In Lucene, we keep separate sub-indexes for open and closed changes. The Lucene
+   * backend inspects the input predicate and depending on all its child predicates decides if the
+   * query should run against the open sub-index, closed sub-index or both.
+   *
+   * <p>The "is:submittable" operator is implemented as:
+   *
+   * <p>issubmittable:1
+   *
+   * <p>But we want to exclude closed changes from being matched by this query. For the normal case,
+   * we rewrite the query as:
+   *
+   * <p>issubmittable:1 AND status:new
+   *
+   * <p>Hence Lucene will match the query against the open sub-index. For the negated case (i.e.
+   * "-is:submittable"), we cannot just negate the previous query because it would result in:
+   *
+   * <p>-(issubmittable:1 AND status:new)
+   *
+   * <p>Lucene will conclude that it should look for changes that are <b>not</b> new and hence will
+   * run the query against the closed sub-index, not matching with changes that are open but not
+   * submittable. For this case, we need to rewrite the query to match with closed changes <b>or</b>
+   * changes that are not submittable.
+   */
+  public static Predicate<ChangeData> rewrite(Predicate<ChangeData> in) {
+    if (in instanceof IsSubmittablePredicate) {
+      return Predicate.and(
+          new BooleanPredicate(ChangeField.IS_SUBMITTABLE), ChangeStatusPredicate.open());
+    }
+    if (in instanceof NotPredicate && in.getChild(0) instanceof IsSubmittablePredicate) {
+      return Predicate.or(
+          Predicate.not(new BooleanPredicate(ChangeField.IS_SUBMITTABLE)),
+          ChangeStatusPredicate.closed());
+    }
+    return in;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 15356f9..2afaada 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.index.query.OrPredicate;
@@ -30,9 +30,11 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
+import java.util.stream.IntStream;
 
 public class LabelPredicate extends OrPredicate<ChangeData> {
   protected static final int MAX_LABEL_VALUE = 4;
+  protected static final int MAX_COUNT = 5; // inclusive
 
   protected static class Args {
     protected final ProjectCache projectCache;
@@ -41,6 +43,8 @@
     protected final String value;
     protected final Set<Account.Id> accounts;
     protected final AccountGroup.UUID group;
+    protected final Integer count;
+    protected final PredicateArgs.Operator countOp;
     protected final GroupBackend groupBackend;
 
     protected Args(
@@ -50,6 +54,8 @@
         String value,
         Set<Account.Id> accounts,
         AccountGroup.UUID group,
+        @Nullable Integer count,
+        @Nullable PredicateArgs.Operator countOp,
         GroupBackend groupBackend) {
       this.projectCache = projectCache;
       this.permissionBackend = permissionBackend;
@@ -57,6 +63,8 @@
       this.value = value;
       this.accounts = accounts;
       this.group = group;
+      this.count = count;
+      this.countOp = countOp;
       this.groupBackend = groupBackend;
     }
   }
@@ -79,7 +87,9 @@
       ChangeQueryBuilder.Arguments a,
       String value,
       Set<Account.Id> accounts,
-      AccountGroup.UUID group) {
+      AccountGroup.UUID group,
+      @Nullable Integer count,
+      @Nullable PredicateArgs.Operator countOp) {
     super(
         predicates(
             new Args(
@@ -89,16 +99,24 @@
                 value,
                 accounts,
                 group,
+                count,
+                countOp,
                 a.groupBackend)));
     this.value = value;
   }
 
   protected static List<Predicate<ChangeData>> predicates(Args args) {
     String v = args.value;
-
+    List<Integer> counts = getCounts(args.count, args.countOp);
     try {
       MagicLabelVote mlv = MagicLabelVote.parseWithEquals(v);
-      return ImmutableList.of(magicLabelPredicate(args, mlv));
+      List<Predicate<ChangeData>> result = Lists.newArrayListWithCapacity(counts.size());
+      if (counts.isEmpty()) {
+        result.add(magicLabelPredicate(args, mlv, /* count= */ null));
+      } else {
+        counts.forEach(count -> result.add(magicLabelPredicate(args, mlv, count)));
+      }
+      return result;
     } catch (IllegalArgumentException e) {
       // Try next format.
     }
@@ -134,16 +152,24 @@
     int min = range.min;
     int max = range.max;
 
-    List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(max - min + 1);
+    List<Predicate<ChangeData>> r =
+        Lists.newArrayListWithCapacity((counts.isEmpty() ? 1 : counts.size()) * (max - min + 1));
     for (int i = min; i <= max; i++) {
-      r.add(onePredicate(args, prefix, i));
+      if (counts.isEmpty()) {
+        r.add(onePredicate(args, prefix, i, /* count= */ null));
+      } else {
+        for (int count : counts) {
+          r.add(onePredicate(args, prefix, i, count));
+        }
+      }
     }
     return r;
   }
 
-  protected static Predicate<ChangeData> onePredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> onePredicate(
+      Args args, String label, int expVal, @Nullable Integer count) {
     if (expVal != 0) {
-      return equalsLabelPredicate(args, label, expVal);
+      return equalsLabelPredicate(args, label, expVal, count);
     }
     return noLabelQuery(args, label);
   }
@@ -151,46 +177,81 @@
   protected static Predicate<ChangeData> noLabelQuery(Args args, String label) {
     List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(2 * MAX_LABEL_VALUE);
     for (int i = 1; i <= MAX_LABEL_VALUE; i++) {
-      r.add(equalsLabelPredicate(args, label, i));
-      r.add(equalsLabelPredicate(args, label, -i));
+      r.add(equalsLabelPredicate(args, label, i, /* count= */ null));
+      r.add(equalsLabelPredicate(args, label, -i, /* count= */ null));
     }
     return not(or(r));
   }
 
-  protected static Predicate<ChangeData> equalsLabelPredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> equalsLabelPredicate(
+      Args args, String label, int expVal, @Nullable Integer count) {
     if (args.groupBackend.isOrContainsExternalGroup(args.group)) {
       // We can only get members of internal groups and negating an index search that doesn't
       // include the external group information leads to incorrect query results. Use a
       // PostFilterPredicate in this case instead.
-      return new EqualsLabelPredicates.PostFilterEqualsLabelPredicate(args, label, expVal);
+      return new EqualsLabelPredicates.PostFilterEqualsLabelPredicate(args, label, expVal, count);
     }
     if (args.accounts == null || args.accounts.isEmpty()) {
-      return new EqualsLabelPredicates.IndexEqualsLabelPredicate(args, label, expVal);
+      return new EqualsLabelPredicates.IndexEqualsLabelPredicate(args, label, expVal, count);
     }
     List<Predicate<ChangeData>> r = new ArrayList<>();
     for (Account.Id a : args.accounts) {
-      r.add(new EqualsLabelPredicates.IndexEqualsLabelPredicate(args, label, expVal, a));
+      r.add(new EqualsLabelPredicates.IndexEqualsLabelPredicate(args, label, expVal, a, count));
     }
     return or(r);
   }
 
-  protected static Predicate<ChangeData> magicLabelPredicate(Args args, MagicLabelVote mlv) {
+  protected static Predicate<ChangeData> magicLabelPredicate(
+      Args args, MagicLabelVote mlv, @Nullable Integer count) {
     if (args.groupBackend.isOrContainsExternalGroup(args.group)) {
       // We can only get members of internal groups and negating an index search that doesn't
       // include the external group information leads to incorrect query results. Use a
       // PostFilterPredicate in this case instead.
-      return new MagicLabelPredicates.PostFilterMagicLabelPredicate(args, mlv);
+      return new MagicLabelPredicates.PostFilterMagicLabelPredicate(args, mlv, count);
     }
     if (args.accounts == null || args.accounts.isEmpty()) {
-      return new MagicLabelPredicates.IndexMagicLabelPredicate(args, mlv);
+      return new MagicLabelPredicates.IndexMagicLabelPredicate(args, mlv, count);
     }
     List<Predicate<ChangeData>> r = new ArrayList<>();
     for (Account.Id a : args.accounts) {
-      r.add(new MagicLabelPredicates.IndexMagicLabelPredicate(args, mlv, a));
+      r.add(new MagicLabelPredicates.IndexMagicLabelPredicate(args, mlv, a, count));
     }
     return or(r);
   }
 
+  private static List<Integer> getCounts(
+      @Nullable Integer count, @Nullable PredicateArgs.Operator countOp) {
+    List<Integer> result = new ArrayList<>();
+    if (count == null) {
+      return result;
+    }
+    switch (countOp) {
+      case EQUAL:
+      case GREATER_EQUAL:
+      case LESS_EQUAL:
+        result.add(count);
+        break;
+      case GREATER:
+      case LESS:
+      default:
+        break;
+    }
+    switch (countOp) {
+      case GREATER:
+      case GREATER_EQUAL:
+        IntStream.range(count + 1, MAX_COUNT + 1).forEach(result::add);
+        break;
+      case LESS:
+      case LESS_EQUAL:
+        IntStream.range(0, count).forEach(result::add);
+        break;
+      case EQUAL:
+      default:
+        break;
+    }
+    return result;
+  }
+
   @Override
   public String toString() {
     return ChangeQueryBuilder.FIELD_LABEL + ":" + value;
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
index 017abec..c9c8c45 100644
--- a/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.query.change.EqualsLabelPredicates.type;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
@@ -31,21 +32,25 @@
 public class MagicLabelPredicates {
   public static class PostFilterMagicLabelPredicate extends PostFilterPredicate<ChangeData> {
     private static class PostFilterMatcher extends Matcher {
-      public PostFilterMatcher(LabelPredicate.Args args, MagicLabelVote magicLabelVote) {
-        super(args, magicLabelVote);
+      public PostFilterMatcher(
+          LabelPredicate.Args args, MagicLabelVote magicLabelVote, @Nullable Integer count) {
+        super(args, magicLabelVote, count);
       }
 
       @Override
       protected Predicate<ChangeData> numericPredicate(String label, short value) {
-        return new EqualsLabelPredicates.PostFilterEqualsLabelPredicate(args, label, value);
+        return new EqualsLabelPredicates.PostFilterEqualsLabelPredicate(args, label, value, count);
       }
     }
 
     private final PostFilterMatcher matcher;
 
-    public PostFilterMagicLabelPredicate(LabelPredicate.Args args, MagicLabelVote magicLabelVote) {
-      super(ChangeQueryBuilder.FIELD_LABEL, magicLabelVote.formatLabel());
-      this.matcher = new PostFilterMatcher(args, magicLabelVote);
+    public PostFilterMagicLabelPredicate(
+        LabelPredicate.Args args, MagicLabelVote magicLabelVote, @Nullable Integer count) {
+      super(
+          ChangeQueryBuilder.FIELD_LABEL,
+          ChangeField.formatLabel(magicLabelVote.label(), magicLabelVote.value().name(), count));
+      this.matcher = new PostFilterMatcher(args, magicLabelVote, count);
     }
 
     @Override
@@ -62,26 +67,37 @@
   public static class IndexMagicLabelPredicate extends ChangeIndexPredicate {
     private static class IndexMatcher extends Matcher {
       public IndexMatcher(
-          LabelPredicate.Args args, MagicLabelVote magicLabelVote, Account.Id account) {
-        super(args, magicLabelVote, account);
+          LabelPredicate.Args args,
+          MagicLabelVote magicLabelVote,
+          Account.Id account,
+          @Nullable Integer count) {
+        super(args, magicLabelVote, account, count);
       }
 
       @Override
       protected Predicate<ChangeData> numericPredicate(String label, short value) {
-        return new EqualsLabelPredicates.IndexEqualsLabelPredicate(args, label, value, account);
+        return new EqualsLabelPredicates.IndexEqualsLabelPredicate(
+            args, label, value, account, count);
       }
     }
 
     private final Matcher matcher;
 
-    public IndexMagicLabelPredicate(LabelPredicate.Args args, MagicLabelVote magicLabelVote) {
-      this(args, magicLabelVote, null);
+    public IndexMagicLabelPredicate(
+        LabelPredicate.Args args, MagicLabelVote magicLabelVote, @Nullable Integer count) {
+      this(args, magicLabelVote, null, count);
     }
 
     public IndexMagicLabelPredicate(
-        LabelPredicate.Args args, MagicLabelVote magicLabelVote, Account.Id account) {
-      super(ChangeField.LABEL, magicLabelVote.formatLabel());
-      this.matcher = new IndexMatcher(args, magicLabelVote, account);
+        LabelPredicate.Args args,
+        MagicLabelVote magicLabelVote,
+        Account.Id account,
+        @Nullable Integer count) {
+      super(
+          ChangeField.LABEL,
+          ChangeField.formatLabel(
+              magicLabelVote.label(), magicLabelVote.value().name(), account, count));
+      this.matcher = new IndexMatcher(args, magicLabelVote, account, count);
     }
 
     @Override
@@ -94,15 +110,22 @@
     protected final LabelPredicate.Args args;
     protected final MagicLabelVote magicLabelVote;
     protected final Account.Id account;
+    @Nullable protected final Integer count;
 
-    public Matcher(LabelPredicate.Args args, MagicLabelVote magicLabelVote) {
-      this(args, magicLabelVote, null);
+    public Matcher(
+        LabelPredicate.Args args, MagicLabelVote magicLabelVote, @Nullable Integer count) {
+      this(args, magicLabelVote, null, count);
     }
 
-    public Matcher(LabelPredicate.Args args, MagicLabelVote magicLabelVote, Account.Id account) {
+    public Matcher(
+        LabelPredicate.Args args,
+        MagicLabelVote magicLabelVote,
+        Account.Id account,
+        @Nullable Integer count) {
       this.account = account;
       this.args = args;
       this.magicLabelVote = magicLabelVote;
+      this.count = count;
     }
 
     public boolean match(ChangeData cd) {
diff --git a/java/com/google/gerrit/server/query/change/OrSource.java b/java/com/google/gerrit/server/query/change/OrSource.java
index 19df3cd..f2de536 100644
--- a/java/com/google/gerrit/server/query/change/OrSource.java
+++ b/java/com/google/gerrit/server/query/change/OrSource.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.index.query.OrPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.ResultSet;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
@@ -50,7 +49,7 @@
         getChildren().stream().map(p -> ((ChangeDataSource) p).read()).collect(toImmutableList());
     return new LazyResultSet<>(
         () -> {
-          List<ChangeData> r = new ArrayList<>();
+          ImmutableList.Builder<ChangeData> r = ImmutableList.builder();
           Set<Change.Id> have = new HashSet<>();
           for (ResultSet<ChangeData> resultSet : results) {
             for (ChangeData result : resultSet) {
@@ -59,7 +58,7 @@
               }
             }
           }
-          return ImmutableList.copyOf(r);
+          return r.build();
         });
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index 4a54c03..536aae2 100644
--- a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -23,9 +24,6 @@
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
 import java.util.Optional;
 
 public class ParentProjectPredicate extends OrPredicate<ChangeData> {
@@ -39,14 +37,14 @@
     this.value = value;
   }
 
-  protected static List<Predicate<ChangeData>> predicates(
+  protected static ImmutableList<Predicate<ChangeData>> predicates(
       ProjectCache projectCache, ChildProjects childProjects, String value) {
     Optional<ProjectState> projectState = projectCache.get(Project.nameKey(value));
     if (!projectState.isPresent()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
-    List<Predicate<ChangeData>> r = new ArrayList<>();
+    ImmutableList.Builder<Predicate<ChangeData>> r = ImmutableList.builder();
     r.add(ChangePredicates.project(projectState.get().getNameKey()));
     try {
       for (ProjectInfo p : childProjects.list(projectState.get().getNameKey())) {
@@ -55,7 +53,7 @@
     } catch (PermissionBackendException e) {
       logger.atWarning().withCause(e).log("cannot check permissions to expand child projects");
     }
-    return r;
+    return r.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/PredicateArgs.java b/java/com/google/gerrit/server/query/change/PredicateArgs.java
index aa6bfbc..02d2ca6 100644
--- a/java/com/google/gerrit/server/query/change/PredicateArgs.java
+++ b/java/com/google/gerrit/server/query/change/PredicateArgs.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.gerrit.index.query.QueryParseException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /**
  * This class is used to extract comma separated values in a predicate.
@@ -30,8 +33,35 @@
  * appear in the map and others in the positional list (e.g. "vote=approved,jb_2.3).
  */
 public class PredicateArgs {
+  private static final Pattern SPLIT_PATTERN = Pattern.compile("(>|>=|=|<|<=)([^=].*)$");
+
   public List<String> positional;
-  public Map<String, String> keyValue;
+  public Map<String, ValOp> keyValue;
+
+  enum Operator {
+    EQUAL("="),
+    GREATER_EQUAL(">="),
+    GREATER(">"),
+    LESS_EQUAL("<="),
+    LESS("<");
+
+    final String op;
+
+    Operator(String op) {
+      this.op = op;
+    }
+  }
+
+  @AutoValue
+  public abstract static class ValOp {
+    abstract String value();
+
+    abstract Operator operator();
+
+    static ValOp create(String value, Operator operator) {
+      return new AutoValue_PredicateArgs_ValOp(value, operator);
+    }
+  }
 
   /**
    * Parses query arguments into {@link #keyValue} and/or {@link #positional}..
@@ -46,19 +76,39 @@
     keyValue = new HashMap<>();
 
     for (String arg : Splitter.on(',').split(args)) {
-      List<String> splitKeyValue = Splitter.on('=').splitToList(arg);
+      Matcher m = SPLIT_PATTERN.matcher(arg);
 
-      if (splitKeyValue.size() == 1) {
-        positional.add(splitKeyValue.get(0));
-      } else if (splitKeyValue.size() == 2) {
-        if (!keyValue.containsKey(splitKeyValue.get(0))) {
-          keyValue.put(splitKeyValue.get(0), splitKeyValue.get(1));
+      if (!m.find()) {
+        positional.add(arg);
+      } else if (m.groupCount() == 2) {
+        String key = arg.substring(0, m.start());
+        String op = m.group(1);
+        String val = m.group(2);
+        if (!keyValue.containsKey(key)) {
+          keyValue.put(key, ValOp.create(val, getOperator(op)));
         } else {
-          throw new QueryParseException("Duplicate key " + splitKeyValue.get(0));
+          throw new QueryParseException("Duplicate key " + key);
         }
       } else {
-        throw new QueryParseException("invalid arg " + arg);
+        throw new QueryParseException("Invalid arg " + arg);
       }
     }
   }
+
+  private Operator getOperator(String operator) {
+    switch (operator) {
+      case "<":
+        return Operator.LESS;
+      case "<=":
+        return Operator.LESS_EQUAL;
+      case "=":
+        return Operator.EQUAL;
+      case ">=":
+        return Operator.GREATER_EQUAL;
+      case ">":
+        return Operator.GREATER;
+      default:
+        throw new IllegalArgumentException("Invalid Operator " + operator);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java b/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
new file mode 100644
index 0000000..22891bc
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.query.QueryParseException;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+
+/**
+ * A submit requirement predicate that matches with changes having the author email's address
+ * matching a specific regular expression pattern.
+ */
+public class RegexAuthorEmailPredicate extends SubmitRequirementPredicate {
+  protected final RunAutomaton authorEmailPattern;
+
+  public RegexAuthorEmailPredicate(String pattern) throws QueryParseException {
+    super("authoremail", pattern);
+
+    if (pattern.startsWith("^")) {
+      pattern = pattern.substring(1);
+    }
+
+    if (pattern.endsWith("$") && !pattern.endsWith("\\$")) {
+      pattern = pattern.substring(0, pattern.length() - 1);
+    }
+
+    try {
+      this.authorEmailPattern = new RunAutomaton(new RegExp(pattern).toAutomaton());
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(String.format("invalid regular expression: %s", pattern), e);
+    }
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return authorEmailPattern.run(cd.getAuthor().getEmailAddress());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/RegexMessagePredicate.java b/java/com/google/gerrit/server/query/change/RegexMessagePredicate.java
new file mode 100644
index 0000000..a2b9ad8
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexMessagePredicate.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.index.change.ChangeField;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+
+public class RegexMessagePredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
+
+  public RegexMessagePredicate(String re) throws QueryParseException {
+    super(ChangeField.COMMIT_MESSAGE_EXACT, re);
+
+    if (re.startsWith("^")) {
+      re = re.substring(1);
+    }
+
+    if (re.endsWith("$") && !re.endsWith("\\$")) {
+      re = re.substring(0, re.length() - 1);
+    }
+
+    try {
+      this.pattern = new RunAutomaton(new RegExp(re).toAutomaton());
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(String.format("invalid regular expression: %s", re), e);
+    }
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return pattern.run(cd.commitMessage());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index e63714f..2580a1b 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -14,8 +14,16 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.FileEditsPredicate;
+import com.google.gerrit.server.query.FileEditsPredicate.FileEditsArgs;
 import com.google.inject.Inject;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
 
 /**
  * A query builder for submit requirement expressions that includes all {@link ChangeQueryBuilder}
@@ -28,8 +36,96 @@
   private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> def =
       new QueryBuilder.Definition<>(SubmitRequirementChangeQueryBuilder.class);
 
+  private final DistinctVotersPredicate.Factory distinctVotersPredicateFactory;
+
+  /**
+   * Regular expression for the {@link #file(String)} operator. Field value is of the form:
+   *
+   * <p>'$fileRegex',withDiffContaining='$contentRegex'
+   *
+   * <p>Both $fileRegex and $contentRegex may contain escaped single or double quotes.
+   */
+  private static final Pattern FILE_EDITS_PATTERN =
+      Pattern.compile("'((?:(?:\\\\')|(?:[^']))*)',withDiffContaining='((?:(?:\\\\')|(?:[^']))*)'");
+
+  private final FileEditsPredicate.Factory fileEditsPredicateFactory;
+
   @Inject
-  SubmitRequirementChangeQueryBuilder(Arguments args) {
+  SubmitRequirementChangeQueryBuilder(
+      Arguments args,
+      DistinctVotersPredicate.Factory distinctVotersPredicateFactory,
+      FileEditsPredicate.Factory fileEditsPredicateFactory) {
     super(def, args);
+    this.distinctVotersPredicateFactory = distinctVotersPredicateFactory;
+    this.fileEditsPredicateFactory = fileEditsPredicateFactory;
+  }
+
+  @Override
+  protected void checkFieldAvailable(FieldDef<ChangeData, ?> field, String operator) {
+    // Submit requirements don't rely on the index, so they can be used regardless of index schema
+    // version.
+  }
+
+  @Override
+  public Predicate<ChangeData> is(String value) throws QueryParseException {
+    if ("submittable".equalsIgnoreCase(value)) {
+      throw new QueryParseException(
+          String.format(
+              "Operator 'is:submittable' cannot be used in submit requirement expressions."));
+    }
+    if ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value)) {
+      return new ConstantPredicate(value);
+    }
+    return super.is(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> authoremail(String who) throws QueryParseException {
+    return new RegexAuthorEmailPredicate(who);
+  }
+
+  @Operator
+  public Predicate<ChangeData> distinctvoters(String value) throws QueryParseException {
+    return distinctVotersPredicateFactory.create(value);
+  }
+
+  /**
+   * A SR operator that can match with file path and content pattern. The value should be of the
+   * form:
+   *
+   * <p>file:"'$filePattern',withDiffContaining='$contentPattern'"
+   *
+   * <p>The operator matches with changes that have their latest PS vs. base diff containing a file
+   * path matching the {@code filePattern} with an edit (added, deleted, modified) matching the
+   * {@code contentPattern}. {@code filePattern} and {@code contentPattern} can start with "^" to
+   * use regular expression matching.
+   *
+   * <p>If the specified value does not match this form, we fall back to the operator's
+   * implementation in {@link ChangeQueryBuilder}.
+   */
+  @Override
+  public Predicate<ChangeData> file(String value) throws QueryParseException {
+    Matcher matcher = FILE_EDITS_PATTERN.matcher(value);
+    if (!matcher.find()) {
+      return super.file(value);
+    }
+    String filePattern = matcher.group(1);
+    String contentPattern = matcher.group(2);
+    if (filePattern.startsWith("^")) {
+      validateRegularExpression(filePattern, "Invalid file pattern.");
+    }
+    if (contentPattern.startsWith("^")) {
+      validateRegularExpression(contentPattern, "Invalid content pattern.");
+    }
+    return fileEditsPredicateFactory.create(FileEditsArgs.create(filePattern, contentPattern));
+  }
+
+  private static void validateRegularExpression(String pattern, String errorMessage)
+      throws QueryParseException {
+    try {
+      Pattern.compile(pattern);
+    } catch (PatternSyntaxException e) {
+      throw new QueryParseException(errorMessage, e);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 63817d2..2566b72 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -34,7 +34,7 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:compress",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
diff --git a/java/com/google/gerrit/server/restapi/access/AccessResource.java b/java/com/google/gerrit/server/restapi/access/AccessResource.java
index 4847da4..36ffdcf 100644
--- a/java/com/google/gerrit/server/restapi/access/AccessResource.java
+++ b/java/com/google/gerrit/server/restapi/access/AccessResource.java
@@ -26,6 +26,5 @@
  * collection.
  */
 public class AccessResource implements RestResource {
-  public static final TypeLiteral<RestView<AccessResource>> ACCESS_KIND =
-      new TypeLiteral<RestView<AccessResource>>() {};
+  public static final TypeLiteral<RestView<AccessResource>> ACCESS_KIND = new TypeLiteral<>() {};
 }
diff --git a/java/com/google/gerrit/server/restapi/account/Capabilities.java b/java/com/google/gerrit/server/restapi/account/Capabilities.java
index 3d719ff9..469f05d 100644
--- a/java/com/google/gerrit/server/restapi/account/Capabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/Capabilities.java
@@ -71,12 +71,10 @@
     }
 
     GlobalOrPluginPermission perm = parse(id);
-    try {
-      permissionBackend.absentUser(target.getAccountId()).check(perm);
+    if (permissionBackend.absentUser(target.getAccountId()).test(perm)) {
       return new AccountResource.Capability(target, globalOrPluginPermissionName(perm));
-    } catch (AuthException e) {
-      throw new ResourceNotFoundException(id, e);
     }
+    throw new ResourceNotFoundException(id);
   }
 
   private GlobalOrPluginPermission parse(IdString id) throws ResourceNotFoundException {
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index 1485a6e56..fbd99eb 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
@@ -39,6 +40,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangePredicates;
@@ -54,7 +56,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.LinkedHashMap;
@@ -75,6 +77,7 @@
   private final Provider<CommentJson> commentJsonProvider;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
+  private final ExperimentFeatures experimentFeatures;
 
   @Inject
   DeleteDraftComments(
@@ -86,7 +89,8 @@
       ChangeJson.Factory changeJsonFactory,
       Provider<CommentJson> commentJsonProvider,
       CommentsUtil commentsUtil,
-      PatchSetUtil psUtil) {
+      PatchSetUtil psUtil,
+      ExperimentFeatures experimentFeatures) {
     this.userProvider = userProvider;
     this.batchUpdateFactory = batchUpdateFactory;
     this.queryBuilderProvider = queryBuilderProvider;
@@ -96,6 +100,7 @@
     this.commentJsonProvider = commentJsonProvider;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
+    this.experimentFeatures = experimentFeatures;
   }
 
   @Override
@@ -118,7 +123,7 @@
     HumanCommentFormatter humanCommentFormatter =
         commentJsonProvider.get().newHumanCommentFormatter();
     Account.Id accountId = rsrc.getUser().getAccountId();
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
     List<Op> ops = new ArrayList<>();
     for (ChangeData cd :
@@ -146,7 +151,12 @@
 
   private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
       throws BadRequestException {
-    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(accountId);
+    Predicate<ChangeData> hasDraft =
+        ChangePredicates.draftBy(
+            experimentFeatures.isFeatureEnabled(
+                GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
+            commentsUtil,
+            accountId);
     if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
       return hasDraft;
     }
diff --git a/java/com/google/gerrit/server/restapi/account/GetAvatar.java b/java/com/google/gerrit/server/restapi/account/GetAvatar.java
index 5256d68..14fee12 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAvatar.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAvatar.java
@@ -55,12 +55,12 @@
   public Response.Redirect apply(AccountResource rsrc) throws ResourceNotFoundException {
     AvatarProvider impl = avatarProvider.get();
     if (impl == null) {
-      throw (new ResourceNotFoundException()).caching(CacheControl.PUBLIC(1, TimeUnit.DAYS));
+      throw new ResourceNotFoundException().caching(CacheControl.PUBLIC(1, TimeUnit.DAYS));
     }
 
     String url = impl.getUrl(rsrc.getUser(), size);
     if (Strings.isNullOrEmpty(url)) {
-      throw (new ResourceNotFoundException()).caching(CacheControl.PUBLIC(1, TimeUnit.HOURS));
+      throw new ResourceNotFoundException().caching(CacheControl.PUBLIC(1, TimeUnit.HOURS));
     }
     return Response.redirect(url);
   }
diff --git a/java/com/google/gerrit/server/restapi/account/GetDetail.java b/java/com/google/gerrit/server/restapi/account/GetDetail.java
index 1091599..ba7a37f 100644
--- a/java/com/google/gerrit/server/restapi/account/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/account/GetDetail.java
@@ -48,7 +48,7 @@
   public Response<AccountDetailInfo> apply(AccountResource rsrc) throws PermissionBackendException {
     Account a = rsrc.getUser().getAccount();
     AccountDetailInfo info = new AccountDetailInfo(a.id().get());
-    info.registeredOn = a.registeredOn();
+    info.setRegisteredOn(a.registeredOn());
     info.inactive = !a.isActive() ? true : null;
     directory.fillAccountInfo(Collections.singleton(info), EnumSet.allOf(FillOptions.class));
     return Response.ok(info);
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index e6b4eee..c671562 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AccountVisibility;
-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.Response;
@@ -186,11 +185,8 @@
       if (modifyAccountCapabilityChecked) {
         fillOptions.add(FillOptions.SECONDARY_EMAILS);
       } else {
-        try {
-          permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+        if (permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
           fillOptions.add(FillOptions.SECONDARY_EMAILS);
-        } catch (AuthException e) {
-          // Do nothing.
         }
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index 2cfc3f5..8dd0e78 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -30,8 +30,10 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -43,24 +45,30 @@
 @Singleton
 public class Abandon
     implements RestModifyView<ChangeResource, AbandonInput>, UiAction<ChangeResource> {
+  private final ChangeData.Factory changeDataFactory;
   private final BatchUpdate.Factory updateFactory;
   private final ChangeJson.Factory json;
   private final AbandonOp.Factory abandonOpFactory;
   private final NotifyResolver notifyResolver;
   private final PatchSetUtil patchSetUtil;
+  private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
 
   @Inject
   Abandon(
+      ChangeData.Factory changeDataFactory,
       BatchUpdate.Factory updateFactory,
       ChangeJson.Factory json,
       AbandonOp.Factory abandonOpFactory,
       NotifyResolver notifyResolver,
-      PatchSetUtil patchSetUtil) {
+      PatchSetUtil patchSetUtil,
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
+    this.changeDataFactory = changeDataFactory;
     this.updateFactory = updateFactory;
     this.json = json;
     this.abandonOpFactory = abandonOpFactory;
     this.notifyResolver = notifyResolver;
     this.patchSetUtil = patchSetUtil;
+    this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
   }
 
   @Override
@@ -117,9 +125,15 @@
       throws RestApiException, UpdateException {
     AccountState accountState = user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null;
     AbandonOp op = abandonOpFactory.create(accountState, msgTxt);
-    try (BatchUpdate u = updateFactory.create(notes.getProjectName(), user, TimeUtil.nowTs())) {
+    ChangeData changeData = changeDataFactory.create(notes.getProjectName(), notes.getChangeId());
+    try (BatchUpdate u = updateFactory.create(notes.getProjectName(), user, TimeUtil.now())) {
       u.setNotify(notify);
-      u.addOp(notes.getChangeId(), op).execute();
+      u.addOp(notes.getChangeId(), op);
+      u.addOp(
+          notes.getChangeId(),
+          storeSubmitRequirementsOpFactory.create(
+              changeData.submitRequirements().values(), changeData));
+      u.execute();
     }
     return op.getChange();
   }
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index cb1256c..a21431e 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -92,7 +92,7 @@
 
     try (BatchUpdate bu =
         updateFactory.create(
-            changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
+            changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.now())) {
       AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason, true);
       bu.addOp(changeResource.getId(), op);
       NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
diff --git a/java/com/google/gerrit/server/restapi/change/AllowedFormats.java b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
index ebec3295..e3ab135 100644
--- a/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
+++ b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
@@ -22,8 +22,6 @@
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.HashMap;
-import java.util.Map;
 import java.util.Set;
 
 @Singleton
@@ -33,14 +31,14 @@
 
   @Inject
   AllowedFormats(DownloadConfig cfg) {
-    Map<String, ArchiveFormatInternal> exts = new HashMap<>();
+    ImmutableMap.Builder<String, ArchiveFormatInternal> exts = ImmutableMap.builder();
     for (ArchiveFormatInternal format : cfg.getArchiveFormats()) {
       for (String ext : format.getSuffixes()) {
         exts.put(ext, format);
       }
       exts.put(format.name().toLowerCase(), format);
     }
-    extensions = ImmutableMap.copyOf(exts);
+    extensions = exts.build();
 
     // Zip is not supported because it may be interpreted by a Java plugin as a
     // valid JAR file, whose code would have access to cookies on the domain.
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
index 9224154..a1c51e8 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -43,7 +44,7 @@
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-public class ApplyFix implements RestModifyView<FixResource, Void> {
+public class ApplyFix implements RestModifyView<FixResource, Input> {
 
   private final GitRepositoryManager gitRepositoryManager;
   private final FixReplacementInterpreter fixReplacementInterpreter;
@@ -66,7 +67,7 @@
   }
 
   @Override
-  public Response<EditInfo> apply(FixResource fixResource, Void nothing)
+  public Response<EditInfo> apply(FixResource fixResource, Input nothing)
       throws AuthException, BadRequestException, ResourceConflictException, IOException,
           ResourceNotFoundException, PermissionBackendException {
     RevisionResource revisionResource = fixResource.getRevisionResource();
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 318b0fa..19cfd6a 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -127,10 +127,11 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeResource resource, IdString id, FileContentInput input)
+    public Response<Object> apply(
+        ChangeResource resource, IdString id, FileContentInput fileContentInput)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
-      putEdit.apply(resource, id.get(), input);
+      putEdit.apply(resource, id.get(), fileContentInput);
       return Response.none();
     }
   }
@@ -146,7 +147,7 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeResource rsrc, IdString id, Input in)
+    public Response<Object> apply(ChangeResource rsrc, IdString id, Input input)
         throws IOException, AuthException, BadRequestException, ResourceConflictException,
             PermissionBackendException {
       return deleteContent.apply(rsrc, id.get());
@@ -249,15 +250,16 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeResource resource, Post.Input input)
+    public Response<Object> apply(ChangeResource resource, Post.Input postInput)
         throws AuthException, BadRequestException, IOException, ResourceConflictException,
             PermissionBackendException {
       Project.NameKey project = resource.getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
-        if (isRestoreFile(input)) {
-          editModifier.restoreFile(repository, resource.getNotes(), input.restorePath);
-        } else if (isRenameFile(input)) {
-          editModifier.renameFile(repository, resource.getNotes(), input.oldPath, input.newPath);
+        if (isRestoreFile(postInput)) {
+          editModifier.restoreFile(repository, resource.getNotes(), postInput.restorePath);
+        } else if (isRenameFile(postInput)) {
+          editModifier.renameFile(
+              repository, resource.getNotes(), postInput.oldPath, postInput.newPath);
         } else {
           editModifier.createEdit(repository, resource.getNotes());
         }
@@ -267,14 +269,14 @@
       return Response.none();
     }
 
-    private static boolean isRestoreFile(Input input) {
-      return input != null && !Strings.isNullOrEmpty(input.restorePath);
+    private static boolean isRestoreFile(Post.Input postInput) {
+      return postInput != null && !Strings.isNullOrEmpty(postInput.restorePath);
     }
 
-    private static boolean isRenameFile(Input input) {
-      return input != null
-          && !Strings.isNullOrEmpty(input.oldPath)
-          && !Strings.isNullOrEmpty(input.newPath);
+    private static boolean isRenameFile(Post.Input postInput) {
+      return postInput != null
+          && !Strings.isNullOrEmpty(postInput.oldPath)
+          && !Strings.isNullOrEmpty(postInput.newPath);
     }
   }
 
@@ -300,37 +302,38 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeEditResource rsrc, FileContentInput input)
+    public Response<Object> apply(ChangeEditResource rsrc, FileContentInput fileContentInput)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
-      return apply(rsrc.getChangeResource(), rsrc.getPath(), input);
+      return apply(rsrc.getChangeResource(), rsrc.getPath(), fileContentInput);
     }
 
-    public Response<Object> apply(ChangeResource rsrc, String path, FileContentInput input)
+    public Response<Object> apply(
+        ChangeResource rsrc, String path, FileContentInput fileContentInput)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
 
-      if (input.content == null && input.binary_content == null) {
+      if (fileContentInput.content == null && fileContentInput.binary_content == null) {
         throw new BadRequestException("either content or binary_content is required");
       }
 
       RawInput newContent;
-      if (input.binary_content != null) {
-        Matcher m = BINARY_DATA_PATTERN.matcher(input.binary_content);
+      if (fileContentInput.binary_content != null) {
+        Matcher m = BINARY_DATA_PATTERN.matcher(fileContentInput.binary_content);
         if (m.matches() && BASE64.equals(m.group(2))) {
           newContent = RawInputUtil.create(Base64.decode(m.group(3)));
         } else {
           throw new BadRequestException("binary_content must be encoded as base64 data uri");
         }
       } else {
-        newContent = input.content;
+        newContent = fileContentInput.content;
       }
 
-      if (Patch.COMMIT_MSG.equals(path) && input.binary_content == null) {
-        EditMessage.Input editCommitMessageInput = new EditMessage.Input();
-        editCommitMessageInput.message =
+      if (Patch.COMMIT_MSG.equals(path) && fileContentInput.binary_content == null) {
+        EditMessage.Input editMessageInput = new EditMessage.Input();
+        editMessageInput.message =
             new String(ByteStreams.toByteArray(newContent.getInputStream()), UTF_8);
-        return editMessage.apply(rsrc, editCommitMessageInput);
+        return editMessage.apply(rsrc, editMessageInput);
       }
 
       if (Strings.isNullOrEmpty(path) || path.charAt(0) == '/') {
@@ -471,16 +474,16 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeResource rsrc, Input input)
+    public Response<Object> apply(ChangeResource rsrc, EditMessage.Input editMessageInput)
         throws AuthException, IOException, BadRequestException, ResourceConflictException,
             PermissionBackendException {
-      if (input == null || Strings.isNullOrEmpty(input.message)) {
+      if (editMessageInput == null || Strings.isNullOrEmpty(editMessageInput.message)) {
         throw new BadRequestException("commit message must be provided");
       }
 
       Project.NameKey project = rsrc.getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
-        editModifier.modifyMessage(repository, rsrc.getNotes(), input.message);
+        editModifier.modifyMessage(repository, rsrc.getNotes(), editMessageInput.message);
       } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 02b4c13..e09f2f4 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -143,7 +143,6 @@
     get(REVISION_KIND, "related").to(GetRelated.class);
     get(REVISION_KIND, "review").to(GetReview.class);
     post(REVISION_KIND, "review").to(PostReview.class);
-    get(REVISION_KIND, "preview_submit").to(PreviewSubmit.class);
     post(REVISION_KIND, "submit").to(Submit.class);
     post(REVISION_KIND, "rebase").to(Rebase.class);
     put(REVISION_KIND, "description").to(PutDescription.class);
diff --git a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
index 572f704..a0c5b16 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -123,9 +122,7 @@
   }
 
   private boolean canRead(ChangeNotes notes) throws PermissionBackendException {
-    try {
-      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
+    if (!permissionBackend.currentUser().change(notes).test(ChangePermission.READ)) {
       return false;
     }
     Optional<ProjectState> projectState = projectCache.get(notes.getProjectName());
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index c8771ee..a2ab12e 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -19,6 +19,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -67,11 +68,12 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -101,7 +103,7 @@
   private final Sequences seq;
   private final Provider<InternalChangeQuery> queryProvider;
   private final GitRepositoryManager gitManager;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final Provider<IdentifiedUser> user;
   private final ChangeInserter.Factory changeInserterFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
@@ -132,7 +134,7 @@
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.user = user;
     this.changeInserterFactory = changeInserterFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
@@ -170,7 +172,7 @@
         patch.commitId(),
         input,
         dest,
-        TimeUtil.nowTs(),
+        TimeUtil.now(),
         null,
         null,
         null,
@@ -205,7 +207,7 @@
       throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
           ConfigInvalidException, NoSuchProjectException {
     return cherryPick(
-        sourceChange, project, sourceCommit, input, dest, TimeUtil.nowTs(), null, null, null, null);
+        sourceChange, project, sourceCommit, input, dest, TimeUtil.now(), null, null, null, null);
   }
 
   /**
@@ -243,14 +245,13 @@
       ObjectId sourceCommit,
       CherryPickInput input,
       BranchNameKey dest,
-      Timestamp timestamp,
+      Instant timestamp,
       @Nullable Change.Id revertedChange,
       @Nullable ObjectId changeIdForNewChange,
       @Nullable Change.Id idForNewChange,
       @Nullable Boolean workInProgress)
       throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
           ConfigInvalidException, NoSuchProjectException {
-
     IdentifiedUser identifiedUser = user.get();
     try (Repository git = gitManager.openRepository(project);
         // This inserter and revwalk *must* be passed to any BatchUpdates
@@ -305,7 +306,7 @@
       CodeReviewCommit cherryPickCommit;
       ProjectState projectState =
           projectCache.get(dest.project()).orElseThrow(noSuchProject(dest.project()));
-      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverTimeZone);
+      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverZoneId);
 
       try {
         MergeUtil mergeUtil;
@@ -357,6 +358,7 @@
                   cherryPickCommit,
                   sourceChange,
                   newTopic,
+                  input,
                   workInProgress);
         } else {
           // Change key not found on destination branch. We can create a new
@@ -439,6 +441,7 @@
       CodeReviewCommit cherryPickCommit,
       @Nullable Change sourceChange,
       String topic,
+      CherryPickInput input,
       @Nullable Boolean workInProgress)
       throws IOException {
     Change destChange = destNotes.getChange();
@@ -452,6 +455,7 @@
     if (shouldSetToReady(cherryPickCommit, destNotes, workInProgress)) {
       inserter.setWorkInProgress(false);
     }
+    inserter.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
     bu.addOp(destChange.getId(), inserter);
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     // If sourceChange is not provided, reset cherryPickOf to avoid stale value.
@@ -502,6 +506,7 @@
           (sourceChange != null && sourceChange.isWorkInProgress())
               || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
     }
+    ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
     BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     ins.setMessage(
@@ -547,6 +552,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 NotifyResolver.Result resolveNotify(CherryPickInput input)
       throws BadRequestException, ConfigInvalidException, IOException {
     return notifyResolver.resolve(
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index 1a4eb18..24a9c83 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.patch.DiffMappings;
 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.GitPositionTransformer;
 import com.google.gerrit.server.patch.GitPositionTransformer.BestPositionOnConflict;
 import com.google.gerrit.server.patch.GitPositionTransformer.FileMapping;
@@ -193,10 +194,9 @@
                 patchsetComments));
       } else {
         logger.atWarning().log(
-            String.format(
-                "Some comments which should be ported refer to the non-existent patchset %s of"
-                    + " change %d. Omitting %d affected comments.",
-                originalPatchsetId, notes.getChangeId().get(), patchsetComments.size()));
+            "Some comments which should be ported refer to the non-existent patchset %s of"
+                + " change %d. Omitting %d affected comments.",
+            originalPatchsetId, notes.getChangeId().get(), patchsetComments.size());
       }
     }
     return portedComments.build();
@@ -254,8 +254,8 @@
         mappings = loadMappings(project, change, originalPatchset, targetPatchset, side);
       } catch (Exception e) {
         logger.atWarning().withCause(e).log(
-            "Could not determine some necessary diff mappings for porting comments on change %s from"
-                + " patchset %s to patchset %s. Mapping %d affected comments to the fallback"
+            "Could not determine some necessary diff mappings for porting comments on change %s"
+                + " from patchset %s to patchset %s. Mapping %d affected comments to the fallback"
                 + " destination.",
             change.getChangeId(),
             originalPatchset.id().getId(),
@@ -315,7 +315,11 @@
         TraceContext.newTimer(
             "Computing diffs", Metadata.builder().commit(originalCommit.name()).build())) {
       Map<String, FileDiffOutput> modifiedFiles =
-          diffOperations.listModifiedFiles(project, originalCommit, targetCommit);
+          diffOperations.listModifiedFiles(
+              project,
+              originalCommit,
+              targetCommit,
+              DiffOptions.builder().skipFilesWithAllEditsDueToRebase(false).build());
       return modifiedFiles.values().stream()
           .map(CommentPorter::getFileEdits)
           .map(DiffMappings::toMapping)
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index fa47bef..f2127f3 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -84,11 +84,11 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -116,7 +116,7 @@
   private final String anonymousCowardName;
   private final GitRepositoryManager gitManager;
   private final Sequences seq;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final ProjectsCollection projectsCollection;
@@ -156,7 +156,7 @@
     this.anonymousCowardName = anonymousCowardName;
     this.gitManager = gitManager;
     this.seq = seq;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.permissionBackend = permissionBackend;
     this.user = user;
     this.projectsCollection = projectsCollection;
@@ -353,13 +353,13 @@
 
       RevCommit mergeTip = parentCommit == null ? null : rw.parseCommit(parentCommit);
 
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
 
-      PersonIdent committer = me.newCommitterIdent(now, serverTimeZone);
+      PersonIdent committer = me.newCommitterIdent(now, serverZoneId);
       PersonIdent author =
           input.author == null
               ? committer
-              : new PersonIdent(input.author.name, input.author.email, now, serverTimeZone);
+              : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
 
       String commitMessage = getCommitMessage(input.subject, me);
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index 8476767..9e9cf6a 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -82,8 +82,7 @@
           String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       Op op = new Op(rsrc.getPatchSet().id(), in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index e943e47..944b085 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -67,9 +67,9 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
-import java.util.TimeZone;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -84,7 +84,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager gitManager;
   private final CommitsCollection commits;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final Provider<CurrentUser> user;
   private final ChangeJson.Factory jsonFactory;
   private final PatchSetUtil psUtil;
@@ -111,7 +111,7 @@
     this.updateFactory = updateFactory;
     this.gitManager = gitManager;
     this.commits = commits;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.user = user;
     this.jsonFactory = json;
     this.psUtil = psUtil;
@@ -176,12 +176,12 @@
         currentPsCommit = rw.parseCommit(ps.commitId());
       }
 
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
       IdentifiedUser me = user.get().asIdentifiedUser();
       PersonIdent author =
           in.author == null
-              ? me.newCommitterIdent(now, serverTimeZone)
-              : new PersonIdent(in.author.name, in.author.email, now, serverTimeZone);
+              ? me.newCommitterIdent(now, serverZoneId)
+              : new PersonIdent(in.author.name, in.author.email, now, serverZoneId);
       CodeReviewCommit newCommit =
           createMergeCommit(
               in,
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
index d867e00..d818210 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -67,8 +67,7 @@
       throws RestApiException, UpdateException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       Op op = new Op();
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChange.java b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
index 3ca5463..8298abb 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChange.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
@@ -54,8 +54,7 @@
     }
     rsrc.permissions().check(ChangePermission.DELETE);
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       Change.Id id = rsrc.getChange().getId();
       bu.addOp(id, opFactory.create(id));
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index 0e868e70..588d56e 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -89,7 +89,7 @@
     DeleteChangeMessageOp deleteChangeMessageOp =
         new DeleteChangeMessageOp(resource.getChangeMessageId(), newChangeMessage);
     try (BatchUpdate batchUpdate =
-        updateFactory.create(resource.getChangeResource().getProject(), user, TimeUtil.nowTs())) {
+        updateFactory.create(resource.getChangeResource().getProject(), user, TimeUtil.now())) {
       batchUpdate.addOp(resource.getChangeId(), deleteChangeMessageOp).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index 044fd77..2056664 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -84,7 +84,7 @@
     String newMessage = getCommentNewMessage(user.asIdentifiedUser().getName(), input.reason);
     DeleteCommentOp deleteCommentOp = new DeleteCommentOp(rsrc, newMessage);
     try (BatchUpdate batchUpdate =
-        updateFactory.create(rsrc.getRevisionResource().getProject(), user, TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getRevisionResource().getProject(), user, TimeUtil.now())) {
       batchUpdate.addOp(rsrc.getRevisionResource().getChange().getId(), deleteCommentOp).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index 51a0b8e..7d28a39 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -54,7 +54,7 @@
   public Response<CommentInfo> apply(DraftCommentResource rsrc, Input input)
       throws RestApiException, UpdateException {
     try (BatchUpdate bu =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       Op op = new Op(rsrc.getComment().key);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
index 16b7136..08725b5 100644
--- a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
@@ -62,8 +62,7 @@
     }
 
     SetPrivateOp op = setPrivateOpFactory.create(false, input);
-    try (BatchUpdate u =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       u.addOp(rsrc.getId(), op).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
index db8e9de..7a409e8 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
@@ -58,7 +58,7 @@
         updateFactory.create(
             rsrc.getChangeResource().getProject(),
             rsrc.getChangeResource().getUser(),
-            TimeUtil.nowTs())) {
+            TimeUtil.now())) {
       bu.setNotify(getNotify(rsrc.getChange(), input));
       BatchUpdateOp op;
       if (rsrc.isByEmail()) {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 7ee38d4..208cecf 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -40,6 +40,7 @@
 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;
@@ -81,7 +82,7 @@
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
   private final MessageIdGenerator messageIdGenerator;
-  private final AddToAttentionSetOp.Factory attentionSetOpfactory;
+  private final AddToAttentionSetOp.Factory attentionSetOpFactory;
   private final Provider<CurrentUser> currentUserProvider;
 
   @Inject
@@ -108,7 +109,7 @@
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
     this.messageIdGenerator = messageIdGenerator;
-    this.attentionSetOpfactory = attentionSetOpFactory;
+    this.attentionSetOpFactory = attentionSetOpFactory;
     this.currentUserProvider = currentUserProvider;
   }
 
@@ -133,7 +134,7 @@
 
     try (BatchUpdate bu =
         updateFactory.create(
-            change.getProject(), r.getChangeResource().getUser(), TimeUtil.nowTs())) {
+            change.getProject(), r.getChangeResource().getUser(), TimeUtil.now())) {
       bu.setNotify(
           notifyResolver.resolve(
               firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
@@ -146,14 +147,18 @@
               r.getReviewerUser().state(),
               rsrc.getLabel(),
               input));
-      if (!r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
+      if (!input.ignoreAutomaticAttentionSetRules
+          && !r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
         bu.addOp(
             change.getId(),
-            attentionSetOpfactory.create(
+            attentionSetOpFactory.create(
                 r.getReviewerUser().getAccountId(),
                 /* reason= */ "Their vote was deleted",
                 /* notify= */ false));
       }
+      if (input.ignoreAutomaticAttentionSetRules) {
+        bu.addOp(change.getId(), new AttentionSetUnchangedOp());
+      }
       bu.execute();
     }
 
@@ -192,9 +197,7 @@
 
       Account.Id accountId = accountState.account().id();
 
-      for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(
-              ctx.getNotes(), psId, accountId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
+      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)) {
diff --git a/java/com/google/gerrit/server/restapi/change/DraftComments.java b/java/com/google/gerrit/server/restapi/change/DraftComments.java
index ab5b9f4..a4c9400 100644
--- a/java/com/google/gerrit/server/restapi/change/DraftComments.java
+++ b/java/com/google/gerrit/server/restapi/change/DraftComments.java
@@ -75,7 +75,7 @@
   }
 
   private void checkIdentifiedUser() throws AuthException {
-    if (!(user.get().isIdentifiedUser())) {
+    if (!user.get().isIdentifiedUser()) {
       throw new AuthException("drafts only available to authenticated users");
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 320e57d..e89cf6c 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 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.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -172,7 +173,7 @@
       } else if (parentNum != 0) {
         int parents =
             gApi.changes()
-                .id(resource.getChange().getChangeId())
+                .id(resource.getChange().getProject().get(), resource.getChange().getChangeId())
                 .revision(resource.getPatchSet().id().get())
                 .commit(false)
                 .parents
@@ -237,7 +238,7 @@
 
     private Collection<String> reviewed(RevisionResource resource) throws AuthException {
       CurrentUser user = self.get();
-      if (!(user.isIdentifiedUser())) {
+      if (!user.isIdentifiedUser()) {
         throw new AuthException("Authentication required");
       }
 
@@ -280,11 +281,14 @@
 
         Map<String, FileDiffOutput> oldList =
             diffOperations.listModifiedFilesAgainstParent(
-                project, patchSet.commitId(), /* parentNum= */ 0);
+                project, patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
 
         Map<String, FileDiffOutput> curList =
             diffOperations.listModifiedFilesAgainstParent(
-                project, resource.getPatchSet().commitId(), /* parentNum= */ 0);
+                project,
+                resource.getPatchSet().commitId(),
+                /* parentNum= */ 0,
+                DiffOptions.DEFAULTS);
 
         int sz = paths.size();
         List<String> pathList = Lists.newArrayListWithCapacity(sz);
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/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/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
index 187ebce..dea4dc4 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -43,8 +43,6 @@
 public class GetPatch implements RestReadView<RevisionResource> {
   private final GitRepositoryManager repoManager;
 
-  private static final String FILE_NOT_FOUND = "File not found: %s.";
-
   @Option(name = "--zip")
   private boolean zip;
 
@@ -118,7 +116,7 @@
             };
 
         if (path != null && bin.asString().isEmpty()) {
-          throw new ResourceNotFoundException(String.format(FILE_NOT_FOUND, path));
+          throw new ResourceNotFoundException(String.format("File not found: %s.", path));
         }
 
         if (zip) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetRelated.java b/java/com/google/gerrit/server/restapi/change/GetRelated.java
index 0eef468..7a1808b 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRelated.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRelated.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -37,7 +38,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -63,7 +63,7 @@
     return Response.ok(relatedChangesInfo);
   }
 
-  public List<RelatedChangeAndCommitInfo> getRelated(RevisionResource rsrc)
+  public ImmutableList<RelatedChangeAndCommitInfo> getRelated(RevisionResource rsrc)
       throws IOException, PermissionBackendException {
     boolean isEdit = rsrc.getEdit().isPresent();
     PatchSet basePs = isEdit ? rsrc.getEdit().get().getBasePatchSet() : rsrc.getPatchSet();
@@ -92,10 +92,10 @@
     if (result.size() == 1) {
       RelatedChangeAndCommitInfo r = result.get(0);
       if (r.commit != null && r.commit.commit.equals(rsrc.getPatchSet().commitId().name())) {
-        return Collections.emptyList();
+        return ImmutableList.of();
       }
     }
-    return result;
+    return ImmutableList.copyOf(result);
   }
 
   static RelatedChangeAndCommitInfo newChangeAndCommit(
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 5281f1f..5dab1ae 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -113,7 +113,7 @@
       throws RestApiException, UpdateException, PermissionBackendException, IOException {
     if (!moveEnabled) {
       // This will be removed with the above config once we reach consensus for the move change
-      // behavior. See: https://bugs.chromium.org/p/gerrit/issues/detail?id=9877
+      // behavior. See: https://issues.gerritcodereview.com/issues/40009784
       throw new MethodNotAllowedException("move changes endpoint is disabled");
     }
 
@@ -159,7 +159,7 @@
     projectCache.get(project).orElseThrow(illegalState(project)).checkStatePermitsWrite();
 
     Op op = new Op(input);
-    try (BatchUpdate u = updateFactory.create(project, caller, TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(project, caller, TimeUtil.now())) {
       u.addOp(change.getId(), op);
       u.execute();
     }
@@ -257,11 +257,8 @@
      * proposal: https://gerrit-review.googlesource.com/c/gerrit/+/129171
      */
     private void updateApprovals(
-        ChangeContext ctx, ChangeUpdate update, PatchSet.Id psId, Project.NameKey project)
-        throws IOException {
-      for (PatchSetApproval psa :
-          approvalsUtil.byPatchSet(
-              ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
+        ChangeContext ctx, ChangeUpdate update, PatchSet.Id psId, Project.NameKey project) {
+      for (PatchSetApproval psa : approvalsUtil.byPatchSet(ctx.getNotes(), psId)) {
         ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
         Optional<LabelType> type =
             projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId());
diff --git a/java/com/google/gerrit/server/restapi/change/PostHashtags.java b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
index c1a6a13..bcaa145 100644
--- a/java/com/google/gerrit/server/restapi/change/PostHashtags.java
+++ b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
@@ -48,7 +48,7 @@
     req.permissions().check(ChangePermission.EDIT_HASHTAGS);
 
     try (BatchUpdate bu =
-        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
       SetHashtagsOp op = hashtagsFactory.create(input);
       bu.addOp(req.getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/PostPrivate.java b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
index f774457..45d7250 100644
--- a/java/com/google/gerrit/server/restapi/change/PostPrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
@@ -74,8 +74,7 @@
     }
 
     SetPrivateOp op = setPrivateOpFactory.create(true, input);
-    try (BatchUpdate u =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       u.addOp(rsrc.getId(), op).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 5c252f4..4605d7c 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -137,6 +137,7 @@
 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;
@@ -274,10 +275,10 @@
   public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input)
       throws RestApiException, UpdateException, IOException, PermissionBackendException,
           ConfigInvalidException, PatchListNotAvailableException {
-    return apply(revision, input, TimeUtil.nowTs());
+    return apply(revision, input, TimeUtil.now());
   }
 
-  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input, Timestamp ts)
+  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input, Instant ts)
       throws RestApiException, UpdateException, IOException, PermissionBackendException,
           ConfigInvalidException, PatchListNotAvailableException {
     // Respect timestamp, but truncate at change created-on time.
@@ -530,7 +531,7 @@
       ChangeData cd,
       PatchSet patchSet,
       List<ReviewerModification> reviewerModifications,
-      Timestamp when) {
+      Instant when) {
     List<AccountState> newlyAddedReviewers = new ArrayList<>();
 
     // There are no events for CCs and reviewers added/deleted by email.
@@ -1203,7 +1204,7 @@
                     parent);
           } else {
             // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
-            comment.writtenOn = ctx.getWhen();
+            comment.writtenOn = Timestamp.from(ctx.getWhen());
             comment.side = inputComment.side();
             comment.message = inputComment.message;
           }
@@ -1230,7 +1231,9 @@
         throws CommentsRejectedException {
       CommentValidationContext commentValidationCtx =
           CommentValidationContext.create(
-              ctx.getChange().getChangeId(), ctx.getChange().getProject().get());
+              ctx.getChange().getChangeId(),
+              ctx.getChange().getProject().get(),
+              ctx.getChange().getDest().branch());
       String changeMessage = Strings.nullToEmpty(in.message).trim();
       ImmutableList<CommentForValidation> draftsForValidation =
           Stream.concat(
@@ -1309,17 +1312,18 @@
       return robotComment;
     }
 
-    private List<FixSuggestion> createFixSuggestionsFromInput(
+    private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
         List<FixSuggestionInfo> fixSuggestionInfos) {
       if (fixSuggestionInfos == null) {
-        return Collections.emptyList();
+        return ImmutableList.of();
       }
 
-      List<FixSuggestion> fixSuggestions = new ArrayList<>(fixSuggestionInfos.size());
+      ImmutableList.Builder<FixSuggestion> fixSuggestions =
+          ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
       for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
         fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
       }
-      return fixSuggestions;
+      return fixSuggestions.build();
     }
 
     private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
@@ -1406,7 +1410,7 @@
     }
 
     private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
-        throws ResourceConflictException, IOException {
+        throws ResourceConflictException {
       Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
 
       // If no labels were modified and change is closed, abort early.
@@ -1573,18 +1577,12 @@
     }
 
     private Map<String, PatchSetApproval> scanLabels(
-        ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del)
-        throws IOException {
+        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(),
-              ctx.getRevWalk(),
-              ctx.getRepoView().getConfig())) {
+          approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
         if (a.isLegacySubmit()) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index 4691550..9bc80a4 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -70,8 +70,7 @@
     if (modification.op == null) {
       return Response.ok(modification.result);
     }
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       bu.setNotify(resolveNotify(rsrc, input));
       Change.Id id = rsrc.getChange().getId();
       bu.addOp(id, modification.op);
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
deleted file mode 100644
index 4acf809..0000000
--- a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
+++ /dev/null
@@ -1,187 +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.server.restapi.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
-import com.google.gerrit.extensions.restapi.PreconditionFailedException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.change.ArchiveFormatInternal;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.ioutil.LimitedByteArrayOutputStream;
-import com.google.gerrit.server.ioutil.LimitedByteArrayOutputStream.LimitExceededException;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.submit.MergeOp;
-import com.google.gerrit.server.submit.MergeOpRepoManager;
-import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.Collection;
-import org.apache.commons.compress.archivers.ArchiveOutputStream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.storage.pack.PackConfig;
-import org.eclipse.jgit.transport.BundleWriter;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.kohsuke.args4j.Option;
-
-public class PreviewSubmit implements RestReadView<RevisionResource> {
-  private static final int MAX_DEFAULT_BUNDLE_SIZE = 100 * 1024 * 1024;
-
-  private final Provider<MergeOp> mergeOpProvider;
-  private final AllowedFormats allowedFormats;
-  private int maxBundleSize;
-  private String format;
-
-  @Option(name = "--format")
-  public void setFormat(String f) {
-    this.format = f;
-  }
-
-  @Inject
-  PreviewSubmit(
-      Provider<MergeOp> mergeOpProvider,
-      AllowedFormats allowedFormats,
-      @GerritServerConfig Config cfg) {
-    this.mergeOpProvider = mergeOpProvider;
-    this.allowedFormats = allowedFormats;
-    this.maxBundleSize = cfg.getInt("download", "maxBundleSize", MAX_DEFAULT_BUNDLE_SIZE);
-  }
-
-  @Override
-  public Response<BinaryResult> apply(RevisionResource rsrc)
-      throws RestApiException, UpdateException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    if (Strings.isNullOrEmpty(format)) {
-      throw new BadRequestException("format is not specified");
-    }
-    ArchiveFormatInternal f = allowedFormats.extensions.get("." + format);
-    if (f == null && format.equals("tgz")) {
-      // Always allow tgz, even when the allowedFormats doesn't contain it.
-      // Then we allow at least one format even if the list of allowed
-      // formats is empty.
-      f = ArchiveFormatInternal.TGZ;
-    }
-    if (f == null) {
-      throw new BadRequestException("unknown archive format");
-    }
-
-    Change change = rsrc.getChange();
-    if (!change.isNew()) {
-      throw new PreconditionFailedException("change is " + ChangeUtil.status(change));
-    }
-    if (!rsrc.getUser().isIdentifiedUser()) {
-      throw new MethodNotAllowedException("Anonymous users cannot submit");
-    }
-
-    return Response.ok(getBundles(rsrc, f));
-  }
-
-  private BinaryResult getBundles(RevisionResource rsrc, ArchiveFormatInternal f)
-      throws RestApiException, UpdateException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
-    Change change = rsrc.getChange();
-
-    @SuppressWarnings("resource") // Returned BinaryResult takes ownership and handles closing.
-    MergeOp op = mergeOpProvider.get();
-    try {
-      op.merge(change, caller, false, new SubmitInput(), true);
-      BinaryResult bin = new SubmitPreviewResult(op, f, maxBundleSize);
-      bin.disableGzip()
-          .setContentType(f.getMimeType())
-          .setAttachmentName("submit-preview-" + change.getChangeId() + "." + format);
-      return bin;
-    } catch (RestApiException
-        | UpdateException
-        | IOException
-        | ConfigInvalidException
-        | RuntimeException
-        | PermissionBackendException e) {
-      op.close();
-      throw e;
-    }
-  }
-
-  private static class SubmitPreviewResult extends BinaryResult {
-
-    private final MergeOp mergeOp;
-    private final ArchiveFormatInternal archiveFormat;
-    private final int maxBundleSize;
-
-    private SubmitPreviewResult(
-        MergeOp mergeOp, ArchiveFormatInternal archiveFormat, int maxBundleSize) {
-      this.mergeOp = mergeOp;
-      this.archiveFormat = archiveFormat;
-      this.maxBundleSize = maxBundleSize;
-    }
-
-    @Override
-    public void writeTo(OutputStream out) throws IOException {
-      try (ArchiveOutputStream aos = archiveFormat.createArchiveOutputStream(out)) {
-        MergeOpRepoManager orm = mergeOp.getMergeOpRepoManager();
-        for (Project.NameKey p : mergeOp.getAllProjects()) {
-          OpenRepo or = orm.getRepo(p);
-          BundleWriter bw = new BundleWriter(or.getCodeReviewRevWalk().getObjectReader());
-          bw.setObjectCountCallback(null);
-          bw.setPackConfig(new PackConfig(or.getRepo()));
-          Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates().values();
-          for (ReceiveCommand r : refs) {
-            bw.include(r.getRefName(), r.getNewId());
-            ObjectId oldId = r.getOldId();
-            if (!oldId.equals(ObjectId.zeroId())
-                // Probably the client doesn't already have NoteDb data.
-                && !RefNames.isNoteDbMetaRef(r.getRefName())) {
-              bw.assume(or.getCodeReviewRevWalk().parseCommit(oldId));
-            }
-          }
-          LimitedByteArrayOutputStream bos = new LimitedByteArrayOutputStream(maxBundleSize, 1024);
-          bw.writeBundle(NullProgressMonitor.INSTANCE, bos);
-          // This naming scheme cannot produce directory/file conflicts
-          // as no projects contains ".git/":
-          String path = p.get() + ".git";
-          archiveFormat.putEntry(aos, path, bos.toByteArray());
-        }
-      } catch (LimitExceededException e) {
-        throw new NotImplementedException("The bundle is too big to generate at the server", e);
-      } catch (NoSuchProjectException e) {
-        throw new IOException(e);
-      }
-    }
-
-    @Override
-    public void close() throws IOException {
-      mergeOp.close();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index dcf616c..d41620e 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -98,7 +98,7 @@
     }
 
     try (BatchUpdate bu =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       SetAssigneeOp op = assigneeFactory.create(assignee);
       bu.addOp(rsrc.getId(), op);
 
diff --git a/java/com/google/gerrit/server/restapi/change/PutDescription.java b/java/com/google/gerrit/server/restapi/change/PutDescription.java
index 7c54074..5b5bc15 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDescription.java
@@ -57,7 +57,7 @@
 
     Op op = new Op(input != null ? input : new DescriptionInput(), rsrc.getPatchSet().id());
     try (BatchUpdate u =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       u.addOp(rsrc.getChange().getId(), op);
       u.execute();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index 84a3d89..6411087 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -40,7 +40,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collections;
 import java.util.Optional;
 
@@ -87,7 +87,7 @@
           String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
     try (BatchUpdate bu =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       Op op = new Op(rsrc.getComment().key, in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
@@ -145,7 +145,7 @@
     }
   }
 
-  private static HumanComment update(HumanComment e, DraftInput in, Timestamp when) {
+  private static HumanComment update(HumanComment e, DraftInput in, Instant when) {
     if (in.side != null) {
       e.side = in.side();
     }
@@ -154,7 +154,7 @@
     }
     e.setLineNbrAndRange(in.line, in.range);
     e.message = in.message.trim();
-    e.writtenOn = when;
+    e.setWrittenOn(when);
     if (in.tag != null) {
       // TODO(dborowitz): Can we support changing tags via PUT?
       e.tag = in.tag;
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 1ed7fd7..f898dca 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -47,8 +47,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.TimeZone;
+import java.time.Instant;
+import java.time.ZoneId;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -64,7 +64,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repositoryManager;
   private final Provider<CurrentUser> userProvider;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final PatchSetInserter.Factory psInserterFactory;
   private final PermissionBackend permissionBackend;
   private final PatchSetUtil psUtil;
@@ -86,7 +86,7 @@
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
     this.psInserterFactory = psInserterFactory;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
     this.permissionBackend = permissionBackend;
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
@@ -126,7 +126,7 @@
         throw new ResourceConflictException("new and existing commit message are the same");
       }
 
-      Timestamp ts = TimeUtil.nowTs();
+      Instant ts = TimeUtil.now();
       try (BatchUpdate bu =
           updateFactory.create(resource.getChange().getProject(), userProvider.get(), ts)) {
         // Ensure that BatchUpdate will update the same repo
@@ -161,13 +161,14 @@
       ObjectInserter objectInserter,
       RevCommit basePatchSetCommit,
       String commitMessage,
-      Timestamp timestamp)
+      Instant timestamp)
       throws IOException {
     CommitBuilder builder = new CommitBuilder();
     builder.setTreeId(basePatchSetCommit.getTree());
     builder.setParentIds(basePatchSetCommit.getParents());
     builder.setAuthor(basePatchSetCommit.getAuthorIdent());
-    builder.setCommitter(userProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, tz));
+    builder.setCommitter(
+        userProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, zoneId));
     builder.setMessage(commitMessage);
     ObjectId newCommitId = objectInserter.insert(builder);
     objectInserter.flush();
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index 3031781..c9b436e 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -63,7 +63,7 @@
 
     SetTopicOp op = topicOpFactory.create(sanitizedInput.topic);
     try (BatchUpdate u =
-        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
       u.addOp(req.getId(), op);
       u.execute();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 2077fb8..5e30dae 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -16,8 +16,10 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -53,6 +55,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -114,7 +117,7 @@
         ObjectReader reader = oi.newReader();
         RevWalk rw = CodeReviewCommit.newRevWalk(reader);
         BatchUpdate bu =
-            updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+            updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.now())) {
       if (!change.isNew()) {
         throw new ResourceConflictException("change is " + ChangeUtil.status(change));
       } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
@@ -126,6 +129,7 @@
               .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
               .setForceContentMerge(true)
               .setAllowConflicts(input.allowConflicts)
+              .setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions))
               .setFireRevisionCreated(true);
       // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
       bu.setNotify(NotifyResolver.Result.none());
@@ -215,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();
@@ -246,6 +252,20 @@
     return description;
   }
 
+  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();
+  }
+
   public static class CurrentRevision implements RestModifyView<ChangeResource, RebaseInput> {
     private final PatchSetUtil psUtil;
     private final Rebase rebase;
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
index 7fe463e..bd3e8ec 100644
--- a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -81,7 +81,7 @@
     ChangeResource changeResource = attentionResource.getChangeResource();
     try (BatchUpdate bu =
         updateFactory.create(
-            changeResource.getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
+            changeResource.getProject(), changeResource.getUser(), TimeUtil.now())) {
       RemoveFromAttentionSetOp op =
           opFactory.create(attentionResource.getAccountId(), input.reason, true);
       bu.addOp(changeResource.getId(), op);
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 53d0f18..49286fc 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -151,7 +151,7 @@
             commentsUtil.newHumanComment(
                 changeNotes,
                 currentUser,
-                TimeUtil.nowTs(),
+                TimeUtil.now(),
                 commentInput.path,
                 commentInput.patchSet == null
                     ? changeNotes.getChange().currentPatchSetId()
@@ -327,7 +327,7 @@
       // message here, then it would be possible to probe whether an account exists.
     } catch (AuthException ex) {
       // adding users without permission to the attention set should fail silently.
-      logger.atFine().log(ex.getMessage());
+      logger.atFine().log("%s", ex.getMessage());
     }
   }
 
@@ -352,7 +352,7 @@
       // message here, then it would be possible to probe whether an account exists.
     } catch (AuthException ex) {
       // this should never happen since removing users with permissions should work.
-      logger.atSevere().log(ex.getMessage());
+      logger.atSevere().log("%s", ex.getMessage());
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index b2d1d3a..19d0677 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -100,7 +100,7 @@
 
     Op op = new Op(input);
     try (BatchUpdate u =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       u.addOp(rsrc.getId(), op).execute();
     }
     return Response.ok(json.noOptions().format(op.change));
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index 8d48c88..7dd3e7a 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -47,7 +47,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -99,12 +98,11 @@
     if (patch == null) {
       throw new ResourceNotFoundException(changeIdToRevert.toString());
     }
-    Timestamp timestamp = TimeUtil.nowTs();
     return Response.ok(
         json.noOptions()
             .format(
                 rsrc.getProject(),
-                commitUtil.createRevertChange(notes, rsrc.getUser(), input, timestamp)));
+                commitUtil.createRevertChange(notes, rsrc.getUser(), input, TimeUtil.now())));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 1b11eb0..4e5027b 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -80,8 +80,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.text.MessageFormat;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -92,7 +92,7 @@
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
-import org.apache.commons.lang.RandomStringUtils;
+import org.apache.commons.lang3.RandomStringUtils;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -251,7 +251,7 @@
     Multimap<BranchNameKey, ChangeData> changesPerProjectAndBranch = ArrayListMultimap.create();
     changeData.stream().forEach(c -> changesPerProjectAndBranch.put(c.change().getDest(), c));
     cherryPickInput = createCherryPickInput(revertInput);
-    Timestamp timestamp = TimeUtil.nowTs();
+    Instant timestamp = TimeUtil.now();
 
     for (BranchNameKey projectAndBranch : changesPerProjectAndBranch.keySet()) {
       cherryPickInput.base = null;
@@ -290,7 +290,7 @@
       Project.NameKey project,
       Iterator<PatchSetData> sortedChangesInProjectAndBranch,
       Set<ObjectId> commitIdsInProjectAndBranch,
-      Timestamp timestamp)
+      Instant timestamp)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException,
           PermissionBackendException {
 
@@ -314,10 +314,7 @@
   }
 
   private void createCherryPickedRevert(
-      RevertInput revertInput,
-      Project.NameKey project,
-      ChangeNotes changeNotes,
-      Timestamp timestamp)
+      RevertInput revertInput, Project.NameKey project, ChangeNotes changeNotes, Instant timestamp)
       throws IOException, ConfigInvalidException, UpdateException, RestApiException {
     ObjectId revCommitId =
         commitUtil.createRevertCommit(revertInput.message, changeNotes, user.get(), timestamp);
@@ -326,7 +323,7 @@
     cherryPickInput.message = revertInput.message;
     ObjectId generatedChangeId = CommitMessageUtil.generateChangeId();
     Change.Id cherryPickRevertChangeId = Change.id(seq.nextChangeId());
-    try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.setNotify(
           notifyResolver.resolve(
               firstNonNull(cherryPickInput.notify, NotifyHandling.ALL),
@@ -349,7 +346,7 @@
   }
 
   private void createNormalRevert(
-      RevertInput revertInput, ChangeNotes changeNotes, Timestamp timestamp)
+      RevertInput revertInput, ChangeNotes changeNotes, Instant timestamp)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException {
 
     Change.Id revertId =
@@ -558,14 +555,14 @@
     private final ObjectId revCommitId;
     private final ObjectId computedChangeId;
     private final Change.Id cherryPickRevertChangeId;
-    private final Timestamp timestamp;
+    private final Instant timestamp;
     private final boolean workInProgress;
 
     CreateCherryPickOp(
         ObjectId revCommitId,
         ObjectId computedChangeId,
         Change.Id cherryPickRevertChangeId,
-        Timestamp timestamp,
+        Instant timestamp,
         Boolean workInProgress) {
       this.revCommitId = revCommitId;
       this.computedChangeId = computedChangeId;
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 7f7c1ad..d8d51d4 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -18,7 +18,6 @@
 
 import com.google.common.base.Strings;
 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.Account;
@@ -44,6 +43,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -56,7 +56,7 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Stream;
-import org.apache.commons.lang.mutable.MutableDouble;
+import org.apache.commons.lang3.mutable.MutableDouble;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
@@ -227,7 +227,7 @@
     } catch (QueryParseException e) {
       // Unhandled, because owner:self will never provoke a QueryParseException
       logger.atSevere().withCause(e).log("Exception while suggesting reviewers");
-      return ImmutableMap.of();
+      return new HashMap<>();
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index d6c4c51..e3cf4db 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -413,7 +413,7 @@
     GroupAsReviewer result = new GroupAsReviewer();
     int maxAllowed = suggestReviewers.getMaxAllowed();
     int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
-    logger.atFine().log("maxAllowedWithoutConfirmation: " + maxAllowedWithoutConfirmation);
+    logger.atFine().log("maxAllowedWithoutConfirmation: %s", maxAllowedWithoutConfirmation);
 
     if (!ReviewerModifier.isLegalReviewerGroup(group.getUUID())) {
       logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID());
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index 69b82ba..1d5064e 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PatchSet;
@@ -38,9 +39,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
@@ -121,7 +120,7 @@
     }
   }
 
-  private List<RevisionResource> find(ChangeResource change, String id)
+  private ImmutableList<RevisionResource> find(ChangeResource change, String id)
       throws IOException, AuthException {
     if (id.equals("0") || id.equals("edit")) {
       return loadEdit(change, null);
@@ -131,7 +130,7 @@
     } else if (id.length() < 4 || id.length() > ObjectIds.STR_LEN) {
       // Require a minimum of 4 digits.
       // Impossibly long identifier will never match.
-      return Collections.emptyList();
+      return ImmutableList.of();
     } else {
       List<RevisionResource> out = new ArrayList<>();
       for (PatchSet ps : psUtil.byChange(change.getNotes())) {
@@ -143,20 +142,20 @@
       if (out.isEmpty() && ObjectId.isId(id)) {
         return loadEdit(change, ObjectId.fromString(id));
       }
-      return out;
+      return ImmutableList.copyOf(out);
     }
   }
 
-  private List<RevisionResource> byLegacyPatchSetId(ChangeResource change, String id) {
+  private ImmutableList<RevisionResource> byLegacyPatchSetId(ChangeResource change, String id) {
     PatchSet ps = psUtil.get(change.getNotes(), PatchSet.id(change.getId(), Integer.parseInt(id)));
     if (ps != null) {
-      return Collections.singletonList(new RevisionResource(change, ps));
+      return ImmutableList.of(new RevisionResource(change, ps));
     }
-    return Collections.emptyList();
+    return ImmutableList.of();
   }
 
-  private List<RevisionResource> loadEdit(ChangeResource change, @Nullable ObjectId commitId)
-      throws AuthException, IOException {
+  private ImmutableList<RevisionResource> loadEdit(
+      ChangeResource change, @Nullable ObjectId commitId) throws AuthException, IOException {
     Optional<ChangeEdit> edit = editUtil.byChange(change.getNotes(), change.getUser());
     if (edit.isPresent()) {
       RevCommit editCommit = edit.get().getEditCommit();
@@ -165,12 +164,12 @@
               .id(PatchSet.id(change.getId(), 0))
               .commitId(editCommit)
               .uploader(change.getUser().getAccountId())
-              .createdOn(new Timestamp(editCommit.getCommitterIdent().getWhen().getTime()))
+              .createdOn(editCommit.getCommitterIdent().getWhenAsInstant())
               .build();
       if (commitId == null || editCommit.equals(commitId)) {
-        return Collections.singletonList(new RevisionResource(change, ps, edit));
+        return ImmutableList.of(new RevisionResource(change, ps, edit));
       }
     }
-    return Collections.emptyList();
+    return ImmutableList.of();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
index c118766..9f019b6 100644
--- a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
+++ b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
@@ -63,8 +63,7 @@
       throw new ResourceConflictException("change is not work in progress");
     }
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.ALL)));
       bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
index fdaad9d..0ad5180 100644
--- a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
+++ b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
@@ -63,8 +63,7 @@
       throw new ResourceConflictException("change is already work in progress");
     }
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.NONE)));
       bu.addOp(rsrc.getChange().getId(), opFactory.create(true, input));
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 876c92c..9597dde 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -257,9 +257,19 @@
           return "Change " + c.getId() + " is marked work in progress";
         }
         try {
-          MergeOp.checkSubmitRule(c, false);
+          // The data in the change index may be stale (e.g. if submit requirements have been
+          // changed). For that one change for which the submit action is computed, use the
+          // freshly loaded ChangeData instance 'cd' instead of the potentially stale ChangeData
+          // instance 'c' that was loaded from the index. This makes a difference if the ChangeSet
+          // 'cs' only contains this one single change. If the ChangeSet contains further changes
+          // 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());
         }
       }
 
@@ -299,12 +309,15 @@
 
     ChangeData cd = resource.getChangeResource().getChangeData();
     try {
-      MergeOp.checkSubmitRule(cd, false);
+      MergeOp.checkSubmitRequirements(cd);
     } catch (ResourceConflictException e) {
       return null; // submit not visible
     }
 
-    ChangeSet cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
+    ChangeSet cs =
+        mergeSuperSet
+            .get()
+            .completeChangeSet(cd.change(), resource.getUser(), /*includingTopicClosure= */ false);
     String topic = change.getTopic();
     int topicSize = 0;
     if (!Strings.isNullOrEmpty(topic)) {
@@ -314,14 +327,6 @@
 
     String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
 
-    // Recheck mergeability rather than using value stored in the index, which may be stale.
-    // TODO(dborowitz): This is ugly; consider providing a way to not read stored fields from the
-    // index in the first place.
-    // cd.setMergeable(null);
-    // That was done in unmergeableChanges which was called by problemsForSubmittingChangeset, so
-    // now it is safe to read from the cache, as it yields the same result.
-    Boolean enabled = cd.isMergeable();
-
     if (submitProblems != null) {
       return new UiAction.Description()
           .setLabel(treatWithTopic ? submitTopicLabel : (cs.size() > 1) ? labelWithParents : label)
@@ -330,6 +335,14 @@
           .setEnabled(false);
     }
 
+    // Recheck mergeability rather than using value stored in the index, which may be stale.
+    // TODO(dborowitz): This is ugly; consider providing a way to not read stored fields from the
+    // index in the first place.
+    // cd.setMergeable(null);
+    // That was done in unmergeableChanges which was called by problemsForSubmittingChangeset, so
+    // now it is safe to read from the cache, as it yields the same result.
+    Boolean enabled = cd.isMergeable();
+
     if (treatWithTopic) {
       Map<String, String> params =
           ImmutableMap.of(
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index 214a001..2ce82ab 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.TOPIC_CLOSURE;
 import static java.util.Collections.reverseOrder;
-import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
@@ -41,7 +43,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.EnumSet;
@@ -127,7 +128,10 @@
       int hidden;
 
       if (c.isNew()) {
-        ChangeSet cs = mergeSuperSet.get().completeChangeSet(c, resource.getUser());
+        ChangeSet cs =
+            mergeSuperSet
+                .get()
+                .completeChangeSet(c, resource.getUser(), options.contains(TOPIC_CLOSURE));
         cds = ensureRequiredDataIsLoaded(cs.changes().asList());
         hidden = cs.nonVisibleChanges().size();
       } else if (c.isMerged()) {
@@ -153,11 +157,11 @@
     }
   }
 
-  private List<ChangeData> sort(List<ChangeData> cds, int hidden) throws IOException {
+  private ImmutableList<ChangeData> sort(List<ChangeData> cds, int hidden) throws IOException {
     if (cds.size() <= 1 && hidden == 0) {
       // Skip sorting for singleton lists, to avoid WalkSorter opening the
       // repo just to fill out the commit field in PatchSetData.
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     long numProjectsDistinct = cds.stream().map(ChangeData::project).distinct().count();
@@ -167,15 +171,15 @@
       // We either have only a single change per project which means that WalkSorter won't make a
       // difference compared to our index-backed sort, or we are looking at more than 5 projects
       // which would make WalkSorter too expensive for this call.
-      return cds.stream().sorted(COMPARATOR).collect(toList());
+      return cds.stream().sorted(COMPARATOR).collect(toImmutableList());
     }
 
     // Perform more expensive walk-sort.
-    List<ChangeData> sorted = new ArrayList<>(cds.size());
+    ImmutableList.Builder<ChangeData> sorted = ImmutableList.builderWithExpectedSize(cds.size());
     for (PatchSetData psd : sorter.get().sort(cds)) {
       sorted.add(psd.data());
     }
-    return sorted;
+    return sorted.build();
   }
 
   private static List<ChangeData> ensureRequiredDataIsLoaded(List<ChangeData> cds) {
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 0043378..2d3984a 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -66,8 +66,10 @@
 import com.google.inject.Inject;
 import java.nio.file.Files;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
@@ -153,6 +155,7 @@
 
     info.user = getUserInfo();
     info.receive = getReceiveInfo();
+    info.submitRequirementDashboardColumns = getSubmitRequirementDashboardColumns();
     return Response.ok(info);
   }
 
@@ -232,6 +235,8 @@
         toBoolean(this.config.getBoolean("change", null, "enableAttentionSet", true));
     info.enableAssignee =
         toBoolean(this.config.getBoolean("change", null, "enableAssignee", false));
+    info.conflictsPredicateEnabled =
+        toBoolean(config.getBoolean("change", "conflictsPredicateEnabled", true));
     return info;
   }
 
@@ -373,6 +378,10 @@
     return info;
   }
 
+  private List<String> getSubmitRequirementDashboardColumns() {
+    return Arrays.asList(config.getStringList("dashboard", null, "submitRequirementColumns"));
+  }
+
   private static Boolean toBoolean(boolean v) {
     return v ? v : null;
   }
diff --git a/java/com/google/gerrit/server/restapi/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
index eac9653..dcc44ae 100644
--- a/java/com/google/gerrit/server/restapi/config/ListTasks.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -129,7 +129,7 @@
     public TaskInfo(Task<?> task) {
       this.id = HexFormat.fromInt(task.getTaskId());
       this.state = task.getState();
-      this.startTime = new Timestamp(task.getStartTime().getTime());
+      this.startTime = Timestamp.from(task.getStartTime());
       this.delay = task.getDelay(TimeUnit.MILLISECONDS);
       this.command = task.toString();
       this.queueName = task.getQueueName();
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index ee86010..e617931 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -61,13 +61,13 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -77,7 +77,7 @@
 public class CreateGroup
     implements RestCollectionCreateView<TopLevelResource, GroupResource, GroupInput> {
   private final Provider<IdentifiedUser> self;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupCache groupCache;
   private final GroupResolver groups;
@@ -102,7 +102,7 @@
       @GerritServerConfig Config cfg,
       Sequences sequences) {
     this.self = self;
-    this.serverTimeZone = serverIdent.get().getTimeZone();
+    this.serverZoneId = serverIdent.get().getZoneId();
     this.groupsUpdateProvider = groupsUpdateProvider;
     this.groupCache = groupCache;
     this.groups = groups;
@@ -212,7 +212,7 @@
             createGroupArgs.uuid,
             GroupUuid.make(
                 createGroupArgs.getGroupName(),
-                self.get().newCommitterIdent(TimeUtil.nowTs(), serverTimeZone)));
+                self.get().newCommitterIdent(TimeUtil.now(), serverZoneId)));
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
             .setGroupUUID(uuid)
diff --git a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
index e3aa0f3..1b0fcd4 100644
--- a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
@@ -102,7 +102,7 @@
           auditEvents.add(
               GroupAuditEventInfo.createRemoveUserEvent(
                   accountLoader.get(auditEvent.removedBy().orElse(null)),
-                  auditEvent.removedOn(),
+                  auditEvent.removedOn().orElse(null),
                   member));
         }
       }
@@ -134,7 +134,7 @@
           auditEvents.add(
               GroupAuditEventInfo.createRemoveGroupEvent(
                   accountLoader.get(auditEvent.removedBy().orElse(null)),
-                  auditEvent.removedOn(),
+                  auditEvent.removedOn().orElse(null),
                   member));
         }
       }
diff --git a/java/com/google/gerrit/server/restapi/group/GroupJson.java b/java/com/google/gerrit/server/restapi/group/GroupJson.java
index e1459c3..6d3fa01 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupJson.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupJson.java
@@ -121,7 +121,7 @@
       }
     }
 
-    info.createdOn = internalGroup.getCreatedOn();
+    info.setCreatedOn(internalGroup.getCreatedOn());
 
     if (options.contains(MEMBERS)) {
       info.members = listMembers.get().getDirectMembers(internalGroup, groupControlSupplier.get());
diff --git a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
index 08cc974..ac81f54 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
@@ -70,7 +70,7 @@
     final CurrentUser user = self.get();
     if (user instanceof AnonymousUser) {
       throw new AuthException("Authentication required");
-    } else if (!(user.isIdentifiedUser())) {
+    } else if (!user.isIdentifiedUser()) {
       throw new ResourceNotFoundException();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index 854f091..b94e44d 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -57,8 +57,8 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.NavigableMap;
 import java.util.Set;
-import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.function.Predicate;
 import java.util.regex.Pattern;
@@ -235,8 +235,9 @@
   }
 
   @Override
-  public Response<SortedMap<String, GroupInfo>> apply(TopLevelResource resource) throws Exception {
-    SortedMap<String, GroupInfo> output = new TreeMap<>();
+  public Response<NavigableMap<String, GroupInfo>> apply(TopLevelResource resource)
+      throws Exception {
+    NavigableMap<String, GroupInfo> output = new TreeMap<>();
     for (GroupInfo info : get()) {
       output.put(MoreObjects.firstNonNull(info.name, "Group " + Url.decode(info.id)), info);
       info.name = null;
diff --git a/java/com/google/gerrit/server/restapi/group/ListMembers.java b/java/com/google/gerrit/server/restapi/group/ListMembers.java
index 22870c1..1882fc5 100644
--- a/java/com/google/gerrit/server/restapi/group/ListMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/ListMembers.java
@@ -118,9 +118,8 @@
   protected List<AccountInfo> getMembers(InternalGroup group) throws PermissionBackendException {
     if (recursive) {
       return getTransitiveMembers(group);
-    } else {
-      return getDirectMembers(group);
     }
+    return getDirectMembers(group);
   }
 
   private List<AccountInfo> toAccountInfos(Set<Account.Id> members)
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 92038b0..a2d2d49 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -16,6 +16,8 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Change;
@@ -49,7 +51,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -113,8 +114,8 @@
         .checkStatePermitsWrite();
 
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
-    List<AccessSection> removals = setAccess.getAccessSections(input.remove);
-    List<AccessSection> additions = setAccess.getAccessSections(input.add);
+    ImmutableList<AccessSection> removals = setAccess.getAccessSections(input.remove);
+    ImmutableList<AccessSection> additions = setAccess.getAccessSections(input.add);
 
     Project.NameKey newParentProjectName =
         input.parent == null ? null : Project.nameKey(input.parent);
@@ -137,7 +138,15 @@
         throw new IllegalStateException(e);
       }
 
-      md.setMessage("Review access change");
+      if (!Strings.isNullOrEmpty(input.message)) {
+        if (!input.message.endsWith("\n")) {
+          input.message += "\n";
+        }
+        md.setMessage(input.message);
+      } else {
+        md.setMessage("Review access change\n");
+      }
+
       md.setInsertChangeId(true);
       Change.Id changeId = Change.id(seq.nextChangeId());
 
@@ -152,7 +161,7 @@
           ObjectReader objReader = objInserter.newReader();
           RevWalk rw = new RevWalk(objReader);
           BatchUpdate bu =
-              updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.nowTs())) {
+              updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.now())) {
         bu.setRepository(md.getRepository(), rw, objInserter);
         ChangeInserter ins = newInserter(changeId, commit);
         bu.insertChange(ins);
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index 33549fe..018ed86 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -17,6 +17,8 @@
 import static com.google.gerrit.entities.RefNames.isConfigRef;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
@@ -45,6 +47,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Map;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -151,7 +154,11 @@
       u.setNewObjectId(object.copy());
       u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
       u.setRefLogMessage("created via REST from " + input.revision, false);
-      refCreationValidator.validateRefOperation(rsrc.getName(), identifiedUser.get(), u);
+      refCreationValidator.validateRefOperation(
+          rsrc.getName(),
+          identifiedUser.get(),
+          u,
+          getValidateOptionsAsMultimap(input.validationOptions));
       RefUpdate.Result result = u.update(rw);
       switch (result) {
         case FAST_FORWARD:
@@ -213,4 +220,18 @@
   private boolean isBranchAllowed(String branch) {
     return !RefNames.isGerritRef(branch) && !branch.startsWith(RefNames.REFS_TAGS);
   }
+
+  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();
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index e3f293a..01686ff 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -43,6 +43,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -148,6 +149,11 @@
     List<LabelValue> values = LabelDefinitionInputParser.parseValues(input.values);
     LabelType.Builder labelType = LabelType.builder(LabelType.checkName(label), values);
 
+    if (input.description != null) {
+      String description = Strings.emptyToNull(input.description.trim());
+      labelType.setDescription(Optional.ofNullable(description));
+    }
+
     if (input.function != null && !input.function.trim().isEmpty()) {
       labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
     } else {
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index f3b2bad..8203346 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.RefNames;
@@ -57,7 +58,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.locks.Lock;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -194,7 +194,8 @@
     }
   }
 
-  private List<String> normalizeBranchNames(List<String> branches) throws BadRequestException {
+  private ImmutableList<String> normalizeBranchNames(List<String> branches)
+      throws BadRequestException {
     if (branches == null || branches.isEmpty()) {
       // Use host-level default for HEAD or fall back to 'master' if nothing else was specified in
       // the input.
@@ -203,7 +204,7 @@
           defaultBranch != null
               ? normalizeAndValidateBranch(defaultBranch)
               : Constants.R_HEADS + Constants.MASTER;
-      return Collections.singletonList(defaultBranch);
+      return ImmutableList.of(defaultBranch);
     }
     List<String> normalizedBranches = new ArrayList<>();
     for (String branch : branches) {
@@ -212,7 +213,7 @@
         normalizedBranches.add(branch);
       }
     }
-    return normalizedBranches;
+    return ImmutableList.copyOf(normalizedBranches);
   }
 
   private String normalizeAndValidateBranch(String branch) throws BadRequestException {
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index b552ff5..c5013f7 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -44,7 +44,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.TagCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
@@ -136,7 +136,7 @@
                   resource
                       .getUser()
                       .asIdentifiedUser()
-                      .newCommitterIdent(TimeUtil.nowTs(), TimeZone.getDefault()));
+                      .newCommitterIdent(TimeUtil.now(), ZoneId.systemDefault()));
         }
 
         Ref result = tag.call();
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index 60405a6..1e351f8 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -21,6 +21,7 @@
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
@@ -113,13 +114,20 @@
         .check(RefPermission.DELETE);
 
     try (Repository repository = repoManager.openRepository(projectState.getNameKey())) {
-      RefUpdate.Result result;
+      Ref refObj = repository.exactRef(ref);
+      if (refObj == null) {
+        throw new ResourceConflictException(String.format("ref %s doesn't exist", ref));
+      }
       RefUpdate u = repository.updateRef(ref);
-      u.setExpectedOldObjectId(repository.exactRef(ref).getObjectId());
+      u.setExpectedOldObjectId(refObj.getObjectId());
       u.setNewObjectId(ObjectId.zeroId());
       u.setForceUpdate(true);
-      refDeletionValidator.validateRefOperation(projectState.getName(), identifiedUser.get(), u);
-      result = u.delete();
+      refDeletionValidator.validateRefOperation(
+          projectState.getName(),
+          identifiedUser.get(),
+          u,
+          /* pushOptions */ ImmutableListMultimap.of());
+      RefUpdate.Result result = u.delete();
 
       switch (result) {
         case NEW:
@@ -243,9 +251,13 @@
 
     RefUpdate u = r.updateRef(refName);
     u.setForceUpdate(true);
-    u.setExpectedOldObjectId(r.exactRef(refName).getObjectId());
+    u.setExpectedOldObjectId(ref.getObjectId());
     u.setNewObjectId(ObjectId.zeroId());
-    refDeletionValidator.validateRefOperation(projectState.getName(), identifiedUser.get(), u);
+    refDeletionValidator.validateRefOperation(
+        projectState.getName(),
+        identifiedUser.get(),
+        u,
+        /* pushOptions */ ImmutableListMultimap.of());
     return command;
   }
 
@@ -269,7 +281,7 @@
         msg = format("Cannot delete %s: %s", cmd.getRefName(), cmd.getResult());
         break;
     }
-    logger.atSevere().log(msg);
+    logger.atSevere().log("%s", msg);
     errorMessages.append(msg);
     errorMessages.append("\n");
   }
diff --git a/java/com/google/gerrit/server/restapi/project/FilesCollection.java b/java/com/google/gerrit/server/restapi/project/FilesCollection.java
index 888ecf2..ca5284f 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesCollection.java
@@ -46,8 +46,14 @@
   @Override
   public FileResource parse(BranchResource parent, IdString id)
       throws ResourceNotFoundException, IOException {
+    if (parent.getRevision().isEmpty()) {
+      throw new ResourceNotFoundException(id);
+    }
     return FileResource.create(
-        repoManager, parent.getProjectState(), ObjectId.fromString(parent.getRevision()), id.get());
+        repoManager,
+        parent.getProjectState(),
+        ObjectId.fromString(parent.getRevision().get()),
+        id.get());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/project/GetReflog.java b/java/com/google/gerrit/server/restapi/project/GetReflog.java
index f9c6fd9..967b3c5 100644
--- a/java/com/google/gerrit/server/restapi/project/GetReflog.java
+++ b/java/com/google/gerrit/server/restapi/project/GetReflog.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CommonConverters;
-import com.google.gerrit.server.args4j.TimestampHandler;
+import com.google.gerrit.server.args4j.InstantHandler;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.project.BranchResource;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import org.eclipse.jgit.lib.ReflogEntry;
@@ -60,9 +60,9 @@
       metaVar = "TIMESTAMP",
       usage =
           "timestamp from which the reflog entries should be listed (UTC, format: "
-              + TimestampHandler.TIMESTAMP_FORMAT
+              + InstantHandler.TIMESTAMP_FORMAT
               + ")")
-  public GetReflog setFrom(Timestamp from) {
+  public GetReflog setFrom(Instant from) {
     this.from = from;
     return this;
   }
@@ -72,16 +72,16 @@
       metaVar = "TIMESTAMP",
       usage =
           "timestamp until which the reflog entries should be listed (UTC, format: "
-              + TimestampHandler.TIMESTAMP_FORMAT
+              + InstantHandler.TIMESTAMP_FORMAT
               + ")")
-  public GetReflog setTo(Timestamp to) {
+  public GetReflog setTo(Instant to) {
     this.to = to;
     return this;
   }
 
   private int limit;
-  private Timestamp from;
-  private Timestamp to;
+  private Instant from;
+  private Instant to;
 
   @Inject
   public GetReflog(GitRepositoryManager repoManager, PermissionBackend permissionBackend) {
@@ -103,7 +103,7 @@
         r = repo.getReflogReader(rsrc.getRef());
       } catch (UnsupportedOperationException e) {
         String msg = "reflog not supported on repo " + rsrc.getNameKey().get();
-        logger.atSevere().log(msg);
+        logger.atSevere().log("%s", msg);
         throw new MethodNotAllowedException(msg, e);
       }
       if (r == null) {
@@ -115,8 +115,8 @@
       } else {
         entries = limit > 0 ? new ArrayList<>(limit) : new ArrayList<>();
         for (ReflogEntry e : r.getReverseEntries()) {
-          Timestamp timestamp = new Timestamp(e.getWho().getWhen().getTime());
-          if ((from == null || from.before(timestamp)) && (to == null || to.after(timestamp))) {
+          Instant timestamp = e.getWho().getWhenAsInstant();
+          if ((from == null || from.isBefore(timestamp)) && (to == null || to.isAfter(timestamp))) {
             entries.add(e);
           }
           if (limit > 0 && entries.size() >= limit) {
diff --git a/java/com/google/gerrit/server/restapi/project/ListDashboards.java b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
index 4406719..8cedd60 100644
--- a/java/com/google/gerrit/server/restapi/project/ListDashboards.java
+++ b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
@@ -100,7 +100,7 @@
     return tree.values();
   }
 
-  private List<DashboardInfo> scan(ProjectState state, String project, boolean setDefault)
+  private ImmutableList<DashboardInfo> scan(ProjectState state, String project, boolean setDefault)
       throws ResourceNotFoundException, IOException, PermissionBackendException {
     if (!state.statePermitsRead()) {
       return ImmutableList.of();
@@ -109,7 +109,7 @@
     PermissionBackend.ForProject perm = permissionBackend.currentUser().project(state.getNameKey());
     try (Repository git = gitManager.openRepository(state.getNameKey());
         RevWalk rw = new RevWalk(git)) {
-      List<DashboardInfo> all = new ArrayList<>();
+      ImmutableList.Builder<DashboardInfo> all = ImmutableList.builder();
       for (Ref ref : git.getRefDatabase().getRefsByPrefix(REFS_DASHBOARDS)) {
         try {
           perm.ref(ref.getName()).check(RefPermission.READ);
@@ -118,13 +118,13 @@
           // Do nothing.
         }
       }
-      return all;
+      return all.build();
     } catch (RepositoryNotFoundException e) {
       throw new ResourceNotFoundException(project, e);
     }
   }
 
-  private List<DashboardInfo> scanDashboards(
+  private ImmutableList<DashboardInfo> scanDashboards(
       Project definingProject,
       Repository git,
       RevWalk rw,
@@ -132,7 +132,7 @@
       String project,
       boolean setDefault)
       throws IOException {
-    List<DashboardInfo> list = new ArrayList<>();
+    ImmutableList.Builder<DashboardInfo> list = ImmutableList.builder();
     try (TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
       tw.addTree(rw.parseTree(ref.getObjectId()));
       tw.setRecursive(true);
@@ -155,6 +155,6 @@
         }
       }
     }
-    return list;
+    return list.build();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 4d8005b..7c431da 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -76,9 +76,9 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.NavigableSet;
 import java.util.Optional;
 import java.util.SortedMap;
-import java.util.SortedSet;
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.stream.Stream;
@@ -338,7 +338,8 @@
             && state != HIDDEN
             && isNullOrEmpty(matchPrefix)
             && isNullOrEmpty(matchRegex)
-            && isNullOrEmpty(matchSubstring) // TODO: see Issue 10446
+            && isNullOrEmpty(
+                matchSubstring) // TODO: see https://issues.gerritcodereview.com/issues/40010295
             && type == FilterType.ALL
             && showBranch.isEmpty()
             && !showTree
@@ -666,7 +667,7 @@
       } catch (IllegalArgumentException e) {
         throw new BadRequestException(e.getMessage());
       }
-      return searcher.search(ImmutableList.copyOf(projectCache.all()));
+      return searcher.search(projectCache.all().asList());
     } else {
       return projectCache.all().stream();
     }
@@ -680,7 +681,7 @@
 
   private void printProjectTree(
       final PrintWriter stdout, TreeMap<Project.NameKey, ProjectNode> treeMap) {
-    final SortedSet<ProjectNode> sortedNodes = new TreeSet<>();
+    final NavigableSet<ProjectNode> sortedNodes = new TreeSet<>();
 
     // Builds the inheritance tree using a list.
     //
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index 123c78a..ac0dff9 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -39,7 +39,7 @@
 import com.google.gerrit.server.project.RefFilter;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -197,12 +197,12 @@
           tagger != null ? CommonConverters.toGitPerson(tagger) : null,
           canDelete,
           webLinks.isEmpty() ? null : webLinks,
-          tagger != null ? new Timestamp(tagger.getWhen().getTime()) : null);
+          tagger != null ? tagger.getWhenAsInstant() : null);
     }
 
-    Timestamp timestamp =
+    Instant timestamp =
         object instanceof RevCommit
-            ? new Timestamp(((RevCommit) object).getCommitterIdent().getWhen().getTime())
+            ? ((RevCommit) object).getCommitterIdent().getWhenAsInstant()
             : null;
 
     // Lightweight tag
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectNode.java b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
index 1e6200c..816c69d 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectNode.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.server.util.TreeFormatter.TreeNode;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import java.util.TreeSet;
 
 /** Node of a Project in a tree formatted by {@link ListProjects}. */
@@ -32,7 +32,7 @@
   private final Project project;
   private final boolean isVisible;
 
-  private final SortedSet<ProjectNode> children = new TreeSet<>();
+  private final NavigableSet<ProjectNode> children = new TreeSet<>();
 
   @Inject
   protected ProjectNode(
@@ -72,7 +72,7 @@
   }
 
   @Override
-  public SortedSet<? extends ProjectNode> getChildren() {
+  public NavigableSet<? extends ProjectNode> getChildren() {
     return children;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 65cc5a2..205420c 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
@@ -41,8 +42,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -66,13 +65,14 @@
     this.pluginPermissionsUtil = pluginPermissionsUtil;
   }
 
-  List<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
+  ImmutableList<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
       throws UnprocessableEntityException {
     if (sectionInfos == null) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
-    List<AccessSection> sections = new ArrayList<>(sectionInfos.size());
+    ImmutableList.Builder<AccessSection> sections =
+        ImmutableList.builderWithExpectedSize(sectionInfos.size());
     for (Map.Entry<String, AccessSectionInfo> entry : sectionInfos.entrySet()) {
       if (entry.getValue().permissions == null) {
         continue;
@@ -120,7 +120,7 @@
       }
       sections.add(accessSection.build());
     }
-    return sections;
+    return sections.build();
   }
 
   /**
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index 801fac7..79bb4ee 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -38,6 +38,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -147,6 +148,12 @@
       }
     }
 
+    if (input.description != null) {
+      String description = Strings.emptyToNull(input.description.trim());
+      labelTypeBuilder.setDescription(Optional.ofNullable(description));
+      dirty = true;
+    }
+
     if (input.function != null) {
       if (input.function.trim().isEmpty()) {
         throw new BadRequestException("function cannot be empty");
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index 9f64863..8cd0a58 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -39,6 +39,8 @@
  */
 @Singleton
 public final class DefaultSubmitRule implements SubmitRule {
+  public static final String RULE_NAME = "gerrit~DefaultSubmitRule";
+
   public static class DefaultSubmitRuleModule extends FactoryModule {
     @Override
     public void configure() {
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/PrologEnvironment.java
index bc0bb1a..2bf4175 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/PrologEnvironment.java
@@ -35,10 +35,10 @@
 import com.googlecode.prolog_cafe.lang.PredicateEncoder;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
+import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
@@ -73,7 +73,7 @@
     setEnabled(EnumSet.allOf(Prolog.Feature.class), false);
     args = a;
     storedValues = new HashMap<>();
-    cleanup = new LinkedList<>();
+    cleanup = new ArrayList<>();
   }
 
   public Args getArgs() {
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index d64a3b9..cab5b45 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -322,7 +322,7 @@
 
   private SubmitRecord ruleError(String err, Exception e) {
     if (opts.logErrors()) {
-      logger.atSevere().withCause(e).log(err);
+      logger.atSevere().withCause(e).log("%s", err);
       return createRuleError(DEFAULT_MSG);
     }
     logger.atFine().log("rule error: %s", err);
@@ -401,7 +401,7 @@
 
   private SubmitTypeRecord typeError(String err, Exception e) {
     if (opts.logErrors()) {
-      logger.atSevere().withCause(e).log(err);
+      logger.atSevere().withCause(e).log("%s", err);
     }
     return SubmitTypeRecord.error(err);
   }
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index fd66a3a..dbaefb9 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 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.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectState;
@@ -70,7 +71,7 @@
   }
 
   public static final StoredValue<RevCommit> COMMIT =
-      new StoredValue<RevCommit>() {
+      new StoredValue<>() {
         @Override
         public RevCommit createValue(Prolog engine) {
           Change change = getChange(engine);
@@ -86,7 +87,7 @@
       };
 
   public static final StoredValue<Map<String, FileDiffOutput>> DIFF_LIST =
-      new StoredValue<Map<String, FileDiffOutput>>() {
+      new StoredValue<>() {
         @Override
         public Map<String, FileDiffOutput> createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -98,7 +99,7 @@
           try {
             diffList =
                 diffOperations.listModifiedFilesAgainstParent(
-                    project, ps.commitId(), /* parentNum= */ 0);
+                    project, ps.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
           } catch (DiffNotAvailableException e) {
             throw new SystemException(
                 String.format(
@@ -113,7 +114,7 @@
   // It should be minimized or cached to reduce pause time
   // when evaluating Prolog submit rules.
   public static final StoredValue<GitRepositoryManager> REPO_MANAGER =
-      new StoredValue<GitRepositoryManager>() {
+      new StoredValue<>() {
         @Override
         public GitRepositoryManager createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -122,7 +123,7 @@
       };
 
   public static final StoredValue<PluginConfigFactory> PLUGIN_CONFIG_FACTORY =
-      new StoredValue<PluginConfigFactory>() {
+      new StoredValue<>() {
         @Override
         public PluginConfigFactory createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -131,7 +132,7 @@
       };
 
   public static final StoredValue<Repository> REPOSITORY =
-      new StoredValue<Repository>() {
+      new StoredValue<>() {
         @Override
         public Repository createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -150,7 +151,7 @@
       };
 
   public static final StoredValue<PermissionBackend> PERMISSION_BACKEND =
-      new StoredValue<PermissionBackend>() {
+      new StoredValue<>() {
         @Override
         protected PermissionBackend createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -159,7 +160,7 @@
       };
 
   public static final StoredValue<AnonymousUser> ANONYMOUS_USER =
-      new StoredValue<AnonymousUser>() {
+      new StoredValue<>() {
         @Override
         protected AnonymousUser createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -168,7 +169,7 @@
       };
 
   public static final StoredValue<Map<Account.Id, IdentifiedUser>> USERS =
-      new StoredValue<Map<Account.Id, IdentifiedUser>>() {
+      new StoredValue<>() {
         @Override
         protected Map<Account.Id, IdentifiedUser> createValue(Prolog engine) {
           return new HashMap<>();
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index daa24d8..c040347 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -52,8 +52,8 @@
                 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")))
+                LabelValue.create((short) -1, "I would prefer this is not submitted as is"),
+                LabelValue.create((short) -2, "This shall not be submitted")))
         .setCopyMinScore(true)
         .setCopyAllScoresOnTrivialRebase(true)
         .build();
diff --git a/java/com/google/gerrit/server/schema/BUILD b/java/com/google/gerrit/server/schema/BUILD
index 0df7907..ce445e1 100644
--- a/java/com/google/gerrit/server/schema/BUILD
+++ b/java/com/google/gerrit/server/schema/BUILD
@@ -26,6 +26,5 @@
         "//lib/commons:dbcp",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/log:log4j",
     ],
 )
diff --git a/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java
new file mode 100644
index 0000000..d993c4a
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.config.ThreadSettingsConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.sql.SQLException;
+import java.sql.Statement;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class CloudSpannerAccountPatchReviewStore extends JdbcAccountPatchReviewStore {
+
+  private static final int ERR_DUP_KEY = 1022;
+  private static final int ERR_DUP_ENTRY = 1062;
+  private static final int ERR_DUP_UNIQUE = 1169;
+
+  @Inject
+  CloudSpannerAccountPatchReviewStore(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      ThreadSettingsConfig threadSettingsConfig) {
+    super(cfg, sitePaths, threadSettingsConfig);
+  }
+
+  @Override
+  public StorageException convertError(String op, SQLException err) {
+    switch (err.getErrorCode()) {
+      case ERR_DUP_KEY:
+      case ERR_DUP_ENTRY:
+      case ERR_DUP_UNIQUE:
+        return new DuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
+
+      default:
+        return new StorageException(op + " failure on ACCOUNT_PATCH_REVIEWS", err);
+    }
+  }
+
+  @Override
+  protected void doCreateTable(Statement stmt) throws SQLException {
+    stmt.executeUpdate(
+        "CREATE TABLE IF NOT EXISTS account_patch_reviews ("
+            + "account_id INT64 NOT NULL DEFAULT (0),"
+            + "change_id INT64 NOT NULL DEFAULT (0),"
+            + "patch_set_id INT64 NOT NULL DEFAULT (0),"
+            + "file_name STRING(MAX) NOT NULL DEFAULT ('')"
+            + ") PRIMARY KEY(change_id, patch_set_id, account_id, file_name)");
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
index 189d448..8cc140e 100644
--- a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -53,8 +53,9 @@
     implements AccountPatchReviewStore, LifecycleListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  // DB_CLOSE_DELAY=-1: By default the content of an in-memory H2 database is lost at the moment the
-  // last connection is closed. This option keeps the content as long as the VM lives.
+  // DB_CLOSE_DELAY=-1: By default the content of an in-memory H2 database is lost
+  // at the moment the last connection is closed. This option keeps the content as
+  // long as the VM lives.
   @VisibleForTesting
   public static final String TEST_IN_MEMORY_URL =
       "jdbc:h2:mem:account_patch_reviews;DB_CLOSE_DELAY=-1";
@@ -64,6 +65,7 @@
   private static final String MARIADB = "mariadb";
   private static final String MYSQL = "mysql";
   private static final String POSTGRESQL = "postgresql";
+  private static final String CLOUDSPANNER = "cloudspanner";
   private static final String URL = "url";
 
   public static class JdbcAccountPatchReviewStoreModule extends LifecycleModule {
@@ -85,6 +87,8 @@
         impl = MysqlAccountPatchReviewStore.class;
       } else if (url.contains(MARIADB)) {
         impl = MariaDBAccountPatchReviewStore.class;
+      } else if (url.contains(CLOUDSPANNER)) {
+        impl = CloudSpannerAccountPatchReviewStore.class;
       } else {
         throw new IllegalArgumentException(
             "unsupported driver type for account patch reviews db: " + url);
@@ -111,6 +115,9 @@
     if (url.contains(MARIADB)) {
       return new MariaDBAccountPatchReviewStore(cfg, sitePaths, threadSettingsConfig);
     }
+    if (url.contains(CLOUDSPANNER)) {
+      return new CloudSpannerAccountPatchReviewStore(cfg, sitePaths, threadSettingsConfig);
+    }
     throw new IllegalArgumentException(
         "unsupported driver type for account patch reviews db: " + url);
   }
@@ -164,6 +171,9 @@
     if (url.contains(MARIADB)) {
       return "org.mariadb.jdbc.Driver";
     }
+    if (url.contains(CLOUDSPANNER)) {
+      return "com.google.cloud.spanner.jdbc.JdbcDriver";
+    }
     return "org.h2.Driver";
   }
 
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 39e3a59..e7d8337 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -104,8 +104,8 @@
           "  defaultValue = 0",
           "  copyMinScore = true",
           "  copyAllScoresOnTrivialRebase = true",
-          "  value = -2 This shall not be merged",
-          "  value = -1 I would prefer this is not merged as is",
+          "  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/securestore/SecureStoreProvider.java b/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
index 4e43b2e..547b6dc 100644
--- a/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
+++ b/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.securestore;
 
 import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.SiteLibraryLoaderUtil;
 import com.google.gerrit.server.config.SitePaths;
@@ -27,8 +26,6 @@
 
 @Singleton
 public class SecureStoreProvider implements Provider<SecureStore> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final Path libdir;
   private final Injector injector;
   private final String className;
@@ -56,9 +53,7 @@
     try {
       return (Class<? extends SecureStore>) Class.forName(className);
     } catch (ClassNotFoundException e) {
-      String msg = String.format("Cannot load secure store class: %s", className);
-      logger.atSevere().withCause(e).log(msg);
-      throw new RuntimeException(msg, e);
+      throw new RuntimeException(String.format("Cannot load secure store class: %s", className), e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index a09ba63..b218347 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
@@ -45,9 +44,10 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = CodeReviewCommit.ORDER.sortedCopy(toMerge);
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
     boolean first = true;
     while (!sorted.isEmpty()) {
       CodeReviewCommit n = sorted.remove(0);
@@ -62,7 +62,7 @@
       }
       first = false;
     }
-    return ops;
+    return ops.build();
   }
 
   private class CherryPickRootOp extends SubmitStrategyOp {
@@ -102,8 +102,7 @@
       args.rw.parseBody(mergeTip);
       String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
 
-      PersonIdent committer =
-          args.caller.newCommitterIdent(ctx.getWhen(), args.serverIdent.getTimeZone());
+      PersonIdent committer = ctx.newCommitterIdent(args.caller);
       try {
         newCommit =
             args.mergeUtil.createCherryPickFromCommit(
@@ -196,7 +195,7 @@
           && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
         mergeTip.moveTipTo(toMerge, toMerge);
       } else {
-        PersonIdent myIdent = new PersonIdent(args.serverIdent, ctx.getWhen());
+        PersonIdent myIdent = ctx.newPersonIdent(args.serverIdent);
         CodeReviewCommit result =
             args.mergeUtil.mergeOneCommit(
                 myIdent,
diff --git a/java/com/google/gerrit/server/submit/FastForwardOnly.java b/java/com/google/gerrit/server/submit/FastForwardOnly.java
index 8a30898..ee8fec8 100644
--- a/java/com/google/gerrit/server/submit/FastForwardOnly.java
+++ b/java/com/google/gerrit/server/submit/FastForwardOnly.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.update.RepoContext;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
@@ -30,7 +29,7 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
 
     Map<BranchNameKey, CodeReviewCommit> branchToCommit = new HashMap<>();
@@ -47,7 +46,8 @@
       }
     }
 
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
     CodeReviewCommit newTipCommit =
         args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
     if (!newTipCommit.equals(args.mergeTip.getInitialTip())) {
@@ -57,7 +57,7 @@
         ops.add(new NotFastForwardOp(c));
       }
     }
-    return ops;
+    return ops.build();
   }
 
   private class NotFastForwardOp extends SubmitStrategyOp {
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 85efcd2..86d6c674 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -280,7 +280,7 @@
   }
 
   private void logErrorAndThrow(String msg) {
-    logger.atSevere().log(msg);
+    logger.atSevere().log("%s", msg);
     throw new StorageException(msg);
   }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeAlways.java b/java/com/google/gerrit/server/submit/MergeAlways.java
index c3f186a..1118a29 100644
--- a/java/com/google/gerrit/server/submit/MergeAlways.java
+++ b/java/com/google/gerrit/server/submit/MergeAlways.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.submit;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.git.CodeReviewCommit;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
@@ -25,9 +25,10 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
     if (args.mergeTip.getInitialTip() == null && !sorted.isEmpty()) {
       // The branch is unborn. Take a fast-forward resolution to
       // create the branch.
@@ -38,7 +39,7 @@
       CodeReviewCommit n = sorted.remove(0);
       ops.add(new MergeOneOp(args, n));
     }
-    return ops;
+    return ops.build();
   }
 
   static boolean dryRun(
diff --git a/java/com/google/gerrit/server/submit/MergeIfNecessary.java b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
index c6877d2..b8417b8 100644
--- a/java/com/google/gerrit/server/submit/MergeIfNecessary.java
+++ b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.submit;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.git.CodeReviewCommit;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
@@ -25,9 +25,10 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
 
     if (args.mergeTip.getInitialTip() == null
         || !args.subscriptionGraph.hasSubscription(args.destBranch)) {
@@ -43,7 +44,7 @@
       CodeReviewCommit n = sorted.remove(0);
       ops.add(new MergeOneOp(args, n));
     }
-    return ops;
+    return ops.build();
   }
 
   static boolean dryRun(
diff --git a/java/com/google/gerrit/server/submit/MergeOneOp.java b/java/com/google/gerrit/server/submit/MergeOneOp.java
index f1b93e1..1840479 100644
--- a/java/com/google/gerrit/server/submit/MergeOneOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOneOp.java
@@ -29,9 +29,7 @@
 
   @Override
   public void updateRepoImpl(RepoContext ctx) throws IntegrationConflictException, IOException {
-    PersonIdent caller =
-        ctx.getIdentifiedUser()
-            .newCommitterIdent(args.serverIdent.getWhen(), args.serverIdent.getTimeZone());
+    PersonIdent caller = ctx.getIdentifiedUser().newCommitterIdent(args.serverIdent);
     if (args.mergeTip.getCurrentTip() == null) {
       throw new IllegalStateException(
           "cannot merge commit "
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 145bf1e..58db331 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
@@ -36,13 +37,14 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
-import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
-import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
+import com.google.gerrit.exceptions.MergeUpdateException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -70,6 +72,8 @@
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -88,7 +92,7 @@
 import com.google.inject.Provider;
 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.HashMap;
@@ -96,6 +100,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Collectors;
@@ -242,11 +247,12 @@
   private final RetryHelper retryHelper;
   private final ChangeData.Factory changeDataFactory;
   private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
+  private final ProjectCache projectCache;
 
   // Changes that were updated by this MergeOp.
   private final Map<Change.Id, Change> updatedChanges;
 
-  private Timestamp ts;
+  private Instant ts;
   private SubmissionId submissionId;
   private IdentifiedUser caller;
 
@@ -254,7 +260,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;
 
@@ -276,7 +282,8 @@
       TopicMetrics topicMetrics,
       RetryHelper retryHelper,
       ChangeData.Factory changeDataFactory,
-      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory,
+      ProjectCache projectCache) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.internalUserFactory = internalUserFactory;
@@ -294,6 +301,7 @@
     this.changeDataFactory = changeDataFactory;
     this.updatedChanges = new HashMap<>();
     this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
+    this.projectCache = projectCache;
   }
 
   @Override
@@ -303,43 +311,47 @@
     }
   }
 
-  public static void checkSubmitRule(ChangeData cd, boolean allowClosed)
-      throws ResourceConflictException {
+  public static void checkSubmitRequirements(ChangeData cd) throws ResourceConflictException {
     PatchSet patchSet = cd.currentPatchSet();
     if (patchSet == null) {
       throw new ResourceConflictException("missing current patch set for change " + cd.getId());
     }
-    List<SubmitRecord> results = getSubmitRecords(cd, allowClosed);
-    if (SubmitRecord.allRecordsOK(results)) {
-      // Rules supplied a valid solution.
+    Map<SubmitRequirement, SubmitRequirementResult> srResults =
+        cd.submitRequirementsIncludingLegacy();
+    if (srResults.values().stream().allMatch(SubmitRequirementResult::fulfilled)) {
       return;
-    } else if (results.isEmpty()) {
+    } else if (srResults.isEmpty()) {
       throw new IllegalStateException(
           String.format(
-              "SubmitRuleEvaluator.evaluate for change %s returned empty list for %s in %s",
+              "Submit requirement results for change '%s' and patchset '%s' "
+                  + "are empty in project '%s'",
               cd.getId(), patchSet.id(), cd.change().getProject().get()));
     }
 
-    for (SubmitRecord record : results) {
-      switch (record.status) {
-        case OK:
+    for (SubmitRequirementResult srResult : srResults.values()) {
+      switch (srResult.status()) {
+        case SATISFIED:
+        case NOT_APPLICABLE:
+        case OVERRIDDEN:
+        case FORCED:
           break;
 
-        case CLOSED:
-          throw new ResourceConflictException("change is closed");
+        case ERROR:
+          throw new ResourceConflictException(
+              String.format(
+                  "submit requirement '%s' has an error: %s",
+                  srResult.submitRequirement().name(), srResult.errorMessage().orElse("")));
 
-        case RULE_ERROR:
-          throw new ResourceConflictException("submit rule error: " + record.errorMessage);
+        case UNSATISFIED:
+          throw new ResourceConflictException(
+              String.format(
+                  "submit requirement '%s' is unsatisfied.", srResult.submitRequirement().name()));
 
-        case NOT_READY:
-          throw new ResourceConflictException(describeNotReady(cd, record));
-
-        case FORCED:
         default:
           throw new IllegalStateException(
               String.format(
-                  "Unexpected SubmitRecord status %s for %s in %s",
-                  record.status, patchSet.id().getId(), cd.change().getProject().get()));
+                  "Unexpected submit requirement status %s for %s in %s",
+                  srResult.status().name(), patchSet.id().getId(), cd.change().getProject().get()));
       }
     }
     throw new IllegalStateException();
@@ -349,56 +361,8 @@
     return allowClosed ? SUBMIT_RULE_OPTIONS_ALLOW_CLOSED : SUBMIT_RULE_OPTIONS;
   }
 
-  private static List<SubmitRecord> getSubmitRecords(ChangeData cd, boolean allowClosed) {
-    return cd.submitRecords(submitRuleOptions(allowClosed));
-  }
-
-  private static String describeNotReady(ChangeData cd, SubmitRecord record) {
-    List<String> blockingConditions = new ArrayList<>();
-    if (record.labels != null) {
-      blockingConditions.add(describeLabels(cd, record.labels));
-    }
-    if (record.requirements != null) {
-      record.requirements.stream()
-          .map(MergeOp::describeSubmitRequirement)
-          .forEach(blockingConditions::add);
-    }
-    return Joiner.on("; ").join(blockingConditions);
-  }
-
-  private static String describeLabels(ChangeData cd, List<SubmitRecord.Label> labels) {
-    List<String> labelResults = new ArrayList<>();
-    for (SubmitRecord.Label lbl : labels) {
-      switch (lbl.status) {
-        case OK:
-        case MAY:
-          break;
-
-        case REJECT:
-          labelResults.add("blocked by " + lbl.label);
-          break;
-
-        case NEED:
-          labelResults.add("needs " + lbl.label);
-          break;
-
-        case IMPOSSIBLE:
-          labelResults.add("needs " + lbl.label + " (check project access)");
-          break;
-
-        default:
-          throw new IllegalStateException(
-              String.format(
-                  "Unsupported SubmitRecord.Label %s for %s in %s",
-                  lbl, cd.change().currentPatchSetId(), cd.change().getProject()));
-      }
-    }
-    return Joiner.on("; ").join(labelResults);
-  }
-
-  private static String describeSubmitRequirement(LegacySubmitRequirement legacySubmitRequirement) {
-    return String.format(
-        "Submit requirement not fulfilled: %s", legacySubmitRequirement.fallbackText());
+  private static List<SubmitRecord> getSubmitRecords(ChangeData cd) {
+    return cd.submitRecords(submitRuleOptions(/* allowClosed= */ false));
   }
 
   private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
@@ -415,7 +379,7 @@
         } else if (cd.change().isWorkInProgress()) {
           commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
         } else {
-          checkSubmitRule(cd, allowMerged);
+          checkSubmitRequirements(cd);
         }
       } catch (ResourceConflictException e) {
         commitStatus.problem(cd.getId(), e.getMessage());
@@ -428,15 +392,32 @@
     commitStatus.maybeFailVerbose();
   }
 
-  private void bypassSubmitRules(ChangeSet cs, boolean allowClosed) {
+  private void bypassSubmitRulesAndRequirements(ChangeSet cs) {
     checkArgument(
         !cs.furtherHiddenChanges(), "cannot bypass submit rules for topic with hidden change");
     for (ChangeData cd : cs.changes()) {
-      List<SubmitRecord> records = new ArrayList<>(getSubmitRecords(cd, allowClosed));
+      Change change = cd.change();
+      if (change == null) {
+        throw new StorageException("Change not found");
+      }
+      if (change.isClosed()) {
+        // No need to check submit rules if the change is closed.
+        continue;
+      }
+      List<SubmitRecord> records = new ArrayList<>(getSubmitRecords(cd));
       SubmitRecord forced = new SubmitRecord();
       forced.status = SubmitRecord.Status.FORCED;
       records.add(forced);
-      cd.setSubmitRecords(submitRuleOptions(allowClosed), records);
+      cd.setSubmitRecords(submitRuleOptions(/* allowClosed= */ false), records);
+
+      // Also bypass submit requirements. Mark them as forced.
+      Map<SubmitRequirement, SubmitRequirementResult> forcedSRs =
+          cd.submitRequirementsIncludingLegacy().entrySet().stream()
+              .collect(
+                  Collectors.toMap(
+                      Map.Entry::getKey,
+                      entry -> entry.getValue().toBuilder().forced(Optional.of(true)).build()));
+      cd.setSubmitRequirements(forcedSRs);
     }
   }
 
@@ -470,7 +451,7 @@
             firstNonNull(submitInput.notify, NotifyHandling.ALL), submitInput.notifyDetails);
     this.dryrun = dryrun;
     this.caller = caller;
-    this.ts = TimeUtil.nowTs();
+    this.ts = TimeUtil.now();
     this.submissionId = new SubmissionId(change);
 
     try (TraceContext traceContext =
@@ -481,7 +462,9 @@
       logger.atFine().log("Beginning integration of %s", change);
       try {
         ChangeSet indexBackedChangeSet =
-            mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(change, caller);
+            mergeSuperSet
+                .setMergeOpRepoManager(orm)
+                .completeChangeSet(change, caller, /* includingTopicClosure= */ false);
         if (!indexBackedChangeSet.ids().contains(change.getId())) {
           // indexBackedChangeSet contains only open changes, if the change is missing in this set
           // it might be that the change was concurrently submitted in the meantime.
@@ -538,7 +521,7 @@
                   boolean isRetry = attempt > 1;
                   if (isRetry) {
                     logger.atFine().log("Retrying, attempt #%d; skipping merged changes", attempt);
-                    this.ts = TimeUtil.nowTs();
+                    this.ts = TimeUtil.now();
                     openRepoManager();
                   }
                   this.commitStatus = new CommitStatus(filteredNoteDbChangeSet, isRetry);
@@ -547,9 +530,10 @@
                     checkSubmitRulesAndState(filteredNoteDbChangeSet, isRetry);
                   } else {
                     logger.atFine().log("Bypassing submit rules");
-                    bypassSubmitRules(filteredNoteDbChangeSet, isRetry);
+                    bypassSubmitRulesAndRequirements(filteredNoteDbChangeSet);
                   }
-                  integrateIntoHistory(filteredNoteDbChangeSet, submissionExecutor);
+                  integrateIntoHistory(
+                      filteredNoteDbChangeSet, submissionExecutor, checkSubmitRules);
                   return null;
                 })
             .listener(retryTracker)
@@ -627,7 +611,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);
@@ -658,17 +643,23 @@
       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()));
       for (Map.Entry<Change.Id, ChangeData> entry : cs.changesById().entrySet()) {
         Project.NameKey project = entry.getValue().project();
         Change.Id changeId = entry.getKey();
+        ChangeData cd = entry.getValue();
+        Collection<SubmitRequirementResult> srResults =
+            cd.submitRequirementsIncludingLegacy().values();
         batchUpdatesByProject
             .get(project)
-            .addOp(changeId, storeSubmitRequirementsOpFactory.create());
+            .addOp(changeId, storeSubmitRequirementsOpFactory.create(srResults, cd));
+        crossCheckSubmitRequirementResults(cd, srResults, project);
       }
       try {
         submissionExecutor.setAdditionalBatchUpdateListeners(
@@ -682,7 +673,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) {
@@ -711,12 +702,12 @@
       if (e.getCause() instanceof IntegrationConflictException) {
         throw (IntegrationConflictException) e.getCause();
       }
-      throw new InternalServerWithUserMessageException(genericMergeError(cs), e);
+      throw new MergeUpdateException(genericMergeError(cs), e);
     }
   }
 
   public Set<Project.NameKey> getAllProjects() {
-    return allProjects;
+    return projects;
   }
 
   public MergeOpRepoManager getMergeOpRepoManager() {
@@ -1018,4 +1009,28 @@
         + " projects involved; some projects may have submitted successfully, but others may have"
         + " failed";
   }
+
+  /**
+   * Make sure that for every project config submit requirement there exists a corresponding result
+   * with the same name in {@code srResults}. If no result is found, log a warning message.
+   */
+  private void crossCheckSubmitRequirementResults(
+      ChangeData cd, Collection<SubmitRequirementResult> srResults, Project.NameKey project) {
+    ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
+    Map<String, SubmitRequirement> projectConfigRequirements = state.getSubmitRequirements();
+    for (String srName : projectConfigRequirements.keySet()) {
+      boolean hasResult = false;
+      for (SubmitRequirementResult srResult : srResults) {
+        if (!srResult.isLegacy() && srResult.submitRequirement().name().equals(srName)) {
+          hasResult = true;
+          break;
+        }
+      }
+      if (!hasResult) {
+        logger.atWarning().log(
+            "Change %d: No result found for project config submit requirement '%s'",
+            cd.getId().get(), srName);
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index 8981b07..0a74a07 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -37,7 +37,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -167,7 +167,7 @@
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
 
-  private Timestamp ts;
+  private Instant ts;
   private IdentifiedUser caller;
   private NotifyResolver.Result notify;
 
@@ -185,7 +185,7 @@
     openRepos = new HashMap<>();
   }
 
-  public void setContext(Timestamp ts, IdentifiedUser caller, NotifyResolver.Result notify) {
+  public void setContext(Instant ts, IdentifiedUser caller, NotifyResolver.Result notify) {
     this.ts = requireNonNull(ts);
     this.caller = requireNonNull(caller);
     this.notify = requireNonNull(notify);
@@ -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/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 67f2907..8581e20 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -92,7 +92,19 @@
     return this;
   }
 
-  public ChangeSet completeChangeSet(Change change, CurrentUser user)
+  /**
+   * Gets the ChangeSet of this {@code change} based on visiblity of the {@code user}. if
+   * change.submitWholeTopic is true, we return the topic closure as well as the dependent changes
+   * of the topic closure. Otherwise, we return just the dependent changes.
+   *
+   * @param change the change for which we get the dependent changes / topic closure.
+   * @param user the current user for visibility purposes.
+   * @param includingTopicClosure when true, return as if change.submitWholeTopic = true, so we
+   *     return the topic closure.
+   * @return {@link ChangeSet} object that represents the dependent changes and/or topic closure of
+   *     the requested change.
+   */
+  public ChangeSet completeChangeSet(Change change, CurrentUser user, boolean includingTopicClosure)
       throws IOException, PermissionBackendException {
     try {
       if (orm == null) {
@@ -113,7 +125,7 @@
       }
 
       ChangeSet changeSet = new ChangeSet(cd, visible);
-      if (wholeTopicEnabled(cfg)) {
+      if (wholeTopicEnabled(cfg) || includingTopicClosure) {
         return completeChangeSetIncludingTopics(changeSet, user);
       }
       try (TraceContext traceContext = PluginContext.newTrace(mergeSuperSetComputation)) {
diff --git a/java/com/google/gerrit/server/submit/RebaseSorter.java b/java/com/google/gerrit/server/submit/RebaseSorter.java
index e960284..3645d3f 100644
--- a/java/com/google/gerrit/server/submit/RebaseSorter.java
+++ b/java/com/google/gerrit/server/submit/RebaseSorter.java
@@ -40,7 +40,7 @@
   private final CurrentUser caller;
   private final CodeReviewRevWalk rw;
   private final RevFlag canMergeFlag;
-  private final Set<RevCommit> uninterestingBranchTips;
+  private final RevCommit initialTip;
   private final Set<RevCommit> alreadyAccepted;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Set<CodeReviewCommit> incoming;
@@ -48,7 +48,7 @@
   public RebaseSorter(
       CurrentUser caller,
       CodeReviewRevWalk rw,
-      Set<RevCommit> uninterestingBranchTips,
+      RevCommit initialTip,
       Set<RevCommit> alreadyAccepted,
       RevFlag canMergeFlag,
       Provider<InternalChangeQuery> queryProvider,
@@ -56,7 +56,7 @@
     this.caller = caller;
     this.rw = rw;
     this.canMergeFlag = canMergeFlag;
-    this.uninterestingBranchTips = uninterestingBranchTips;
+    this.initialTip = initialTip;
     this.alreadyAccepted = alreadyAccepted;
     this.queryProvider = queryProvider;
     this.incoming = incoming;
@@ -70,8 +70,8 @@
 
       rw.resetRetain(canMergeFlag);
       rw.markStart(n);
-      for (RevCommit uninterestingBranchTip : uninterestingBranchTips) {
-        rw.markUninteresting(uninterestingBranchTip);
+      if (initialTip != null) {
+        rw.markUninteresting(initialTip);
       }
 
       CodeReviewCommit c;
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 355d25f..cfb2f88 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.submit;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.submit.CommitMergeStatus.EMPTY_COMMIT;
 import static com.google.gerrit.server.submit.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
-import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.BooleanProjectConfig;
@@ -38,7 +38,6 @@
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
@@ -56,7 +55,7 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted;
     try {
       sorted = args.rebaseSorter.sort(toMerge);
@@ -92,12 +91,13 @@
         // found a merge commit that depends on a normal change, this means we are required to merge
         // the whole series at once
         sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted);
-        return sorted.stream().map(n -> new MergeIfNecessaryOp(n)).collect(toList());
+        return sorted.stream().map(n -> new MergeIfNecessaryOp(n)).collect(toImmutableList());
       }
       foundNonMerge = true;
     }
 
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
     boolean first = true;
     while (!sorted.isEmpty()) {
       CodeReviewCommit n = sorted.remove(0);
@@ -114,7 +114,7 @@
       }
       first = false;
     }
-    return ops;
+    return ops.build();
   }
 
   private class RebaseRootOp extends SubmitStrategyOp {
@@ -166,8 +166,7 @@
         RevCommit mergeTip = args.mergeTip.getCurrentTip();
         args.rw.parseBody(mergeTip);
         String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
-        PersonIdent committer =
-            args.caller.newCommitterIdent(ctx.getWhen(), args.serverIdent.getTimeZone());
+        PersonIdent committer = ctx.newCommitterIdent(args.caller);
         try {
           newCommit =
               args.mergeUtil.createCherryPickFromCommit(
@@ -304,8 +303,7 @@
           && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
         mergeTip.moveTipTo(toMerge, toMerge);
       } else {
-        PersonIdent caller =
-            ctx.getIdentifiedUser().newCommitterIdent(ctx.getWhen(), ctx.getTimeZone());
+        PersonIdent caller = ctx.newCommitterIdent();
         CodeReviewCommit newTip =
             args.mergeUtil.mergeOneCommit(
                 caller,
diff --git a/java/com/google/gerrit/server/submit/SubmitDryRun.java b/java/com/google/gerrit/server/submit/SubmitDryRun.java
index bcd7923..cee0ad9 100644
--- a/java/com/google/gerrit/server/submit/SubmitDryRun.java
+++ b/java/com/google/gerrit/server/submit/SubmitDryRun.java
@@ -152,7 +152,7 @@
       case INHERIT:
       default:
         String errorMsg = "No submit strategy for: " + submitType;
-        logger.atSevere().log(errorMsg);
+        logger.atSevere().log("%s", errorMsg);
         throw new StorageException(errorMsg);
     }
   }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 3b0ad00..83c6634 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -18,9 +18,9 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
-import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.SubmissionId;
@@ -213,18 +213,11 @@
           projectCache.get(destBranch.project()).orElseThrow(illegalState(destBranch.project()));
       this.mergeSorter =
           new MergeSorter(caller, rw, alreadyAccepted, canMergeFlag, queryProvider, incoming);
-      Set<RevCommit> uninterestingBranchTips;
-      if (project.is(BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET)) {
-        RevCommit initialTip = mergeTip.getInitialTip();
-        uninterestingBranchTips = initialTip == null ? Set.of() : Set.of(initialTip);
-      } else {
-        uninterestingBranchTips = alreadyAccepted;
-      }
       this.rebaseSorter =
           new RebaseSorter(
               caller,
               rw,
-              uninterestingBranchTips,
+              mergeTip.getInitialTip(),
               alreadyAccepted,
               canMergeFlag,
               queryProvider,
@@ -301,5 +294,5 @@
     }
   }
 
-  protected abstract List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge);
+  protected abstract ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge);
 }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
index 2e66ae2..3bd26dc 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
@@ -90,7 +90,7 @@
       case INHERIT:
       default:
         String errorMsg = "No submit strategy for: " + submitType;
-        logger.atSevere().log(errorMsg);
+        logger.atSevere().log("%s", errorMsg);
         throw new StorageException(errorMsg);
     }
   }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index f26ec17..d06940c 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -286,7 +286,7 @@
       setMerged(ctx, commit, message(ctx, commit, s));
     } catch (StorageException err) {
       String msg = "Error updating change status for " + id;
-      logger.atSevere().withCause(err).log(msg);
+      logger.atSevere().withCause(err).log("%s", msg);
       args.commitStatus.logProblem(id, msg);
       // It's possible this happened before updating anything in the db, but
       // it's hard to know for sure, so just return true below to be safe.
@@ -327,7 +327,7 @@
         ctx.getRevWalk(), ctx.getUpdate(psId), psId, alreadyMergedCommit, groups, null, null);
   }
 
-  private void setApproval(ChangeContext ctx, IdentifiedUser user) throws IOException {
+  private void setApproval(ChangeContext ctx, IdentifiedUser user) {
     Change.Id id = ctx.getChange().getId();
     List<SubmitRecord> records = args.commitStatus.getSubmitRecords(id);
     PatchSet.Id oldPsId = toMerge.getPatchsetId();
@@ -348,13 +348,10 @@
     }
   }
 
-  private LabelNormalizer.Result approve(ChangeContext ctx, ChangeUpdate update)
-      throws IOException {
+  private LabelNormalizer.Result approve(ChangeContext ctx, ChangeUpdate update) {
     PatchSet.Id psId = update.getPatchSetId();
     Map<PatchSetApproval.Key, PatchSetApproval> byKey = new HashMap<>();
-    for (PatchSetApproval psa :
-        args.approvalsUtil.byPatchSet(
-            ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
+    for (PatchSetApproval psa : args.approvalsUtil.byPatchSet(ctx.getNotes(), psId)) {
       byKey.put(psa.key(), psa);
     }
 
@@ -521,19 +518,29 @@
     }
   }
 
-  /** See {@link #updateRepo(RepoContext)} */
+  /**
+   * See {@link #updateRepo(RepoContext)}
+   *
+   * @param ctx context for the repository update
+   */
   protected void updateRepoImpl(RepoContext ctx) throws Exception {}
 
   /**
    * Returns a new patch set if one was created by the submit strategy, or null if not
    *
    * <p>See {@link #updateChange(ChangeContext)}
+   *
+   * @param ctx context for the change update
    */
   protected PatchSet updateChangeImpl(ChangeContext ctx) throws Exception {
     return null;
   }
 
-  /** See {@link #postUpdate(PostUpdateContext)} */
+  /**
+   * See {@link #postUpdate(PostUpdateContext)}
+   *
+   * @param ctx context for the post update
+   */
   protected void postUpdateImpl(PostUpdateContext ctx) throws Exception {}
 
   /** Amend the commit with gitlink update */
diff --git a/java/com/google/gerrit/server/submit/SubmoduleCommits.java b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
index 1312a4b..37df66b 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleCommits.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
@@ -36,7 +36,7 @@
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
 import org.eclipse.jgit.dircache.DirCacheEditor;
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/tools/ToolsCatalog.java b/java/com/google/gerrit/server/tools/ToolsCatalog.java
index 9c1483f..015b8f1 100644
--- a/java/com/google/gerrit/server/tools/ToolsCatalog.java
+++ b/java/com/google/gerrit/server/tools/ToolsCatalog.java
@@ -31,7 +31,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
-import java.util.SortedMap;
+import java.util.NavigableMap;
 import java.util.TreeMap;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -46,7 +46,7 @@
 public class ToolsCatalog {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final SortedMap<String, Entry> toc;
+  private final NavigableMap<String, Entry> toc;
 
   @Inject
   ToolsCatalog() throws IOException {
@@ -73,8 +73,8 @@
     return toc.get(name);
   }
 
-  private static SortedMap<String, Entry> readToc() throws IOException {
-    SortedMap<String, Entry> toc = new TreeMap<>();
+  private static NavigableMap<String, Entry> readToc() throws IOException {
+    NavigableMap<String, Entry> toc = new TreeMap<>();
     final BufferedReader br =
         new BufferedReader(new InputStreamReader(new ByteArrayInputStream(read("TOC")), UTF_8));
     String line;
@@ -108,7 +108,7 @@
     }
     toc.put(top.getPath(), top);
 
-    return Collections.unmodifiableSortedMap(toc);
+    return Collections.unmodifiableNavigableMap(toc);
   }
 
   @Nullable
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index b88d0c0..6dcd82d 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -68,7 +68,8 @@
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -76,7 +77,6 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.TreeMap;
 import java.util.function.Function;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -122,7 +122,7 @@
   }
 
   public interface Factory {
-    BatchUpdate create(Project.NameKey project, CurrentUser user, Timestamp when);
+    BatchUpdate create(Project.NameKey project, CurrentUser user, Instant when);
   }
 
   public static void execute(
@@ -252,13 +252,13 @@
     }
 
     @Override
-    public Timestamp getWhen() {
+    public Instant getWhen() {
       return when;
     }
 
     @Override
-    public TimeZone getTimeZone() {
-      return tz;
+    public ZoneId getZoneId() {
+      return zoneId;
     }
 
     @Override
@@ -311,14 +311,9 @@
 
     @Override
     public ChangeUpdate getUpdate(PatchSet.Id psId) {
-      return getUpdate(psId, when);
-    }
-
-    @Override
-    public ChangeUpdate getUpdate(PatchSet.Id psId, Timestamp whenOverride) {
       ChangeUpdate u = defaultUpdates.get(psId);
       if (u == null) {
-        u = getNewChangeUpdate(psId, whenOverride);
+        u = getNewChangeUpdate(psId);
         defaultUpdates.put(psId, u);
       }
       return u;
@@ -326,18 +321,13 @@
 
     @Override
     public ChangeUpdate getDistinctUpdate(PatchSet.Id psId) {
-      return getDistinctUpdate(psId, when);
-    }
-
-    @Override
-    public ChangeUpdate getDistinctUpdate(PatchSet.Id psId, Timestamp whenOverride) {
-      ChangeUpdate u = getNewChangeUpdate(psId, whenOverride);
+      ChangeUpdate u = getNewChangeUpdate(psId);
       distinctUpdates.put(psId, u);
       return u;
     }
 
-    private ChangeUpdate getNewChangeUpdate(PatchSet.Id psId, Timestamp whenOverride) {
-      ChangeUpdate u = changeUpdateFactory.create(notes, user, whenOverride);
+    private ChangeUpdate getNewChangeUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = changeUpdateFactory.create(notes, user, when);
       if (newChanges.containsKey(notes.getChangeId())) {
         u.setAllowWriteToNewRef(true);
       }
@@ -386,8 +376,8 @@
 
   private final Project.NameKey project;
   private final CurrentUser user;
-  private final Timestamp when;
-  private final TimeZone tz;
+  private final Instant when;
+  private final ZoneId zoneId;
 
   private final ListMultimap<Change.Id, BatchUpdateOp> ops =
       MultimapBuilder.linkedHashKeys().arrayListValues().build();
@@ -415,7 +405,7 @@
       GitReferenceUpdated gitRefUpdated,
       @Assisted Project.NameKey project,
       @Assisted CurrentUser user,
-      @Assisted Timestamp when) {
+      @Assisted Instant when) {
     this.repoManager = repoManager;
     this.changeDataFactory = changeDataFactory;
     this.changeNotesFactory = changeNotesFactory;
@@ -426,7 +416,7 @@
     this.project = project;
     this.user = user;
     this.when = when;
-    tz = serverIdent.getTimeZone();
+    zoneId = serverIdent.getZoneId();
   }
 
   @Override
@@ -444,11 +434,6 @@
     execute(ImmutableList.of(this), ImmutableList.of(), false);
   }
 
-  public BatchRefUpdate prepareRefUpdates() throws Exception {
-    ChangesHandle handle = executeChangeOps(ImmutableList.of(), false);
-    return handle.prepare();
-  }
-
   public boolean isExecuted() {
     return executed;
   }
@@ -625,21 +610,18 @@
       checkArgument(old == null, "result for change %s already set: %s", id, old);
     }
 
-    public BatchRefUpdate prepare() throws IOException {
-      return manager.prepare();
-    }
-
     void execute() throws IOException {
       BatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
       BatchUpdate.this.executed = manager.isExecuted();
     }
 
-    List<ListenableFuture<ChangeData>> startIndexFutures() {
+    ImmutableList<ListenableFuture<ChangeData>> startIndexFutures() {
       if (dryrun) {
         return ImmutableList.of();
       }
       logDebug("Reindexing %d changes", results.size());
-      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>(results.size());
+      ImmutableList.Builder<ListenableFuture<ChangeData>> indexFutures =
+          ImmutableList.builderWithExpectedSize(results.size());
       for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
         Change.Id id = e.getKey();
         switch (e.getValue()) {
@@ -655,7 +637,7 @@
             throw new IllegalStateException("unexpected result: " + e.getValue());
         }
       }
-      return indexFutures;
+      return indexFutures.build();
     }
   }
 
@@ -678,7 +660,7 @@
                     repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
             dryrun);
     if (user.isIdentifiedUser()) {
-      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, zoneId));
     }
     handle.manager.setRefLogMessage(refLogMessage);
     handle.manager.setPushCertificate(pushCert);
@@ -755,7 +737,7 @@
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
     if (RequestId.isSet()) {
-      logger.atFine().log(msg);
+      logger.atFine().log("%s", msg);
     }
   }
 
diff --git a/java/com/google/gerrit/server/update/ChangeContext.java b/java/com/google/gerrit/server/update/ChangeContext.java
index feba541..aeabde4 100644
--- a/java/com/google/gerrit/server/update/ChangeContext.java
+++ b/java/com/google/gerrit/server/update/ChangeContext.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import java.sql.Timestamp;
 
 /**
  * Context for performing the {@link BatchUpdateOp#updateChange} phase.
@@ -44,15 +43,6 @@
   ChangeUpdate getUpdate(PatchSet.Id psId);
 
   /**
-   * Same as {@link ChangeContext#getUpdate}, but allows to override the commit timestamp.
-   *
-   * @param psId patch set ID.
-   * @param whenOverride commit timestamp.
-   * @return handle for change updates.
-   */
-  ChangeUpdate getUpdate(PatchSet.Id psId, Timestamp whenOverride);
-
-  /**
    * Gets a new ChangeUpdate for this change at a given patch set.
    *
    * <p>To get the current patch set ID, use {@link com.google.gerrit.server.PatchSetUtil#current}.
@@ -63,15 +53,6 @@
   ChangeUpdate getDistinctUpdate(PatchSet.Id psId);
 
   /**
-   * Same as {@link ChangeContext#getDistinctUpdate}, but allows to override the commit timestamp.
-   *
-   * @param psId patch set ID.
-   * @param whenOverride commit timestamp.
-   * @return handle for change updates.
-   */
-  ChangeUpdate getDistinctUpdate(PatchSet.Id psId, Timestamp whenOverride);
-
-  /**
    * Get the up-to-date notes for this change.
    *
    * <p>The change data is read within the same transaction that {@link
diff --git a/java/com/google/gerrit/server/update/Context.java b/java/com/google/gerrit/server/update/Context.java
index 9947168..aa41d90 100644
--- a/java/com/google/gerrit/server/update/Context.java
+++ b/java/com/google/gerrit/server/update/Context.java
@@ -24,8 +24,9 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
 import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.TimeZone;
+import java.time.Instant;
+import java.time.ZoneId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
@@ -66,16 +67,16 @@
    *
    * @return timestamp.
    */
-  Timestamp getWhen();
+  Instant getWhen();
 
   /**
-   * Get the time zone in which this update takes place.
+   * Get the time zone ID in which this update takes place.
    *
-   * <p>In the current implementation, this is always the time zone of the server.
+   * <p>In the current implementation, this is always the time zone ID of the server.
    *
-   * @return time zone.
+   * @return zone ID.
    */
-  TimeZone getTimeZone();
+  ZoneId getZoneId();
 
   /**
    * Get the user performing the update.
@@ -134,4 +135,33 @@
   default Account.Id getAccountId() {
     return getIdentifiedUser().getAccountId();
   }
+
+  /**
+   * Creates a new {@link PersonIdent} with {@link #getWhen()} as timestamp.
+   *
+   * @param personIdent {@link PersonIdent} to be copied
+   * @return copied {@link PersonIdent} with {@link #getWhen()} as timestamp
+   */
+  default PersonIdent newPersonIdent(PersonIdent personIdent) {
+    return new PersonIdent(personIdent, getWhen().toEpochMilli(), personIdent.getTimeZoneOffset());
+  }
+
+  /**
+   * Creates a committer {@link PersonIdent} for {@link #getIdentifiedUser()}.
+   *
+   * @return the created committer {@link PersonIdent}
+   */
+  default PersonIdent newCommitterIdent() {
+    return newCommitterIdent(getIdentifiedUser());
+  }
+
+  /**
+   * Creates a committer {@link PersonIdent} for the given user.
+   *
+   * @param user user for which a committer {@link PersonIdent} should be created
+   * @return the created committer {@link PersonIdent}
+   */
+  default PersonIdent newCommitterIdent(IdentifiedUser user) {
+    return user.newCommitterIdent(getWhen(), getZoneId());
+  }
 }
diff --git a/java/com/google/gerrit/server/util/AccountTemplateUtil.java b/java/com/google/gerrit/server/util/AccountTemplateUtil.java
index c552ce8..93d7086 100644
--- a/java/com/google/gerrit/server/util/AccountTemplateUtil.java
+++ b/java/com/google/gerrit/server/util/AccountTemplateUtil.java
@@ -24,9 +24,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.HashSet;
 import java.util.Optional;
-import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -62,7 +60,7 @@
       return ImmutableSet.of();
     }
     Matcher matcher = ACCOUNT_TEMPLATE_PATTERN.matcher(textTemplate);
-    Set<Account.Id> accountsInTemplate = new HashSet<>();
+    ImmutableSet.Builder<Account.Id> accountsInTemplate = ImmutableSet.builder();
     while (matcher.find()) {
       String accountId = matcher.group(1);
       Optional<Account.Id> parsedAccountId = Account.Id.tryParse(accountId);
@@ -72,7 +70,7 @@
         logger.atFine().log("Failed to parse accountId from template %s", matcher.group());
       }
     }
-    return ImmutableSet.copyOf(accountsInTemplate);
+    return accountsInTemplate.build();
   }
 
   public static String getAccountTemplate(Account.Id accountId) {
@@ -82,7 +80,7 @@
   /** Builds user-readable text from text, that might contain {@link #ACCOUNT_TEMPLATE}. */
   public String replaceTemplates(String messageTemplate) {
     Matcher matcher = ACCOUNT_TEMPLATE_PATTERN.matcher(messageTemplate);
-    StringBuffer out = new StringBuffer();
+    StringBuilder out = new StringBuilder();
     while (matcher.find()) {
       String accountId = matcher.group(1);
       String unrecognizedAccount = "Unrecognized Gerrit Account " + accountId;
diff --git a/java/com/google/gerrit/server/util/AttentionSetUtil.java b/java/com/google/gerrit/server/util/AttentionSetUtil.java
index 9238b44..26c8f47 100644
--- a/java/com/google/gerrit/server/util/AttentionSetUtil.java
+++ b/java/com/google/gerrit/server/util/AttentionSetUtil.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.Collection;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -43,6 +42,14 @@
         .collect(ImmutableSet.toImmutableSet());
   }
 
+  /** Returns only updates where the user was removed. */
+  public static ImmutableSet<AttentionSetUpdate> removalsOnly(
+      Collection<AttentionSetUpdate> updates) {
+    return updates.stream()
+        .filter(u -> u.operation() == Operation.REMOVE)
+        .collect(ImmutableSet.toImmutableSet());
+  }
+
   /**
    * Validates the input for AttentionSetInput. This must be called for all inputs that relate to
    * adding or removing attention set entries, except for {@link
@@ -115,7 +122,7 @@
             : null;
     return new AttentionSetInfo(
         accountLoader.get(attentionSetUpdate.account()),
-        Timestamp.from(attentionSetUpdate.timestamp()),
+        attentionSetUpdate.timestamp(),
         attentionSetUpdate.reason(),
         reasonAccount);
   }
diff --git a/java/com/google/gerrit/server/util/IdGenerator.java b/java/com/google/gerrit/server/util/IdGenerator.java
index d4c2dc4..1534ef3 100644
--- a/java/com/google/gerrit/server/util/IdGenerator.java
+++ b/java/com/google/gerrit/server/util/IdGenerator.java
@@ -62,7 +62,7 @@
   private static short hi16(int in) {
     return (short)
         ( //
-        ((in >>> 24 & 0xff))
+        (in >>> 24 & 0xff)
             | //
             ((in >>> 16 & 0xff) << 8) //
         );
@@ -71,7 +71,7 @@
   private static short lo16(int in) {
     return (short)
         ( //
-        ((in >>> 8 & 0xff))
+        (in >>> 8 & 0xff)
             | //
             ((in & 0xff) << 8) //
         );
diff --git a/java/com/google/gerrit/server/util/MostSpecificComparator.java b/java/com/google/gerrit/server/util/MostSpecificComparator.java
index ac33902..ef5e67f 100644
--- a/java/com/google/gerrit/server/util/MostSpecificComparator.java
+++ b/java/com/google/gerrit/server/util/MostSpecificComparator.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.server.project.RefPattern;
 import java.util.Comparator;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.text.similarity.LevenshteinDistance;
 
 /**
  * Order the Ref Pattern by the most specific. This sort is done by:
@@ -89,7 +89,7 @@
     } else {
       return Math.max(pattern.length(), refName.length());
     }
-    return StringUtils.getLevenshteinDistance(example, refName);
+    return LevenshteinDistance.getDefaultInstance().apply(example, refName);
   }
 
   private boolean finite(String pattern) {
diff --git a/java/com/google/gerrit/server/util/TreeFormatter.java b/java/com/google/gerrit/server/util/TreeFormatter.java
index 49d4a55..5a898d5 100644
--- a/java/com/google/gerrit/server/util/TreeFormatter.java
+++ b/java/com/google/gerrit/server/util/TreeFormatter.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.util;
 
 import java.io.PrintWriter;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 
 public class TreeFormatter {
 
@@ -24,7 +24,7 @@
 
     boolean isVisible();
 
-    SortedSet<? extends TreeNode> getChildren();
+    NavigableSet<? extends TreeNode> getChildren();
   }
 
   public static final String NOT_VISIBLE_NODE = "(x)";
@@ -40,7 +40,7 @@
     this.stdout = stdout;
   }
 
-  public void printTree(SortedSet<? extends TreeNode> rootNodes) {
+  public void printTree(NavigableSet<? extends TreeNode> rootNodes) {
     if (rootNodes.isEmpty()) {
       return;
     }
@@ -66,7 +66,7 @@
 
   private void printTree(TreeNode node, int level, boolean isLast) {
     printNode(node, level, isLast);
-    final SortedSet<? extends TreeNode> childNodes = node.getChildren();
+    final NavigableSet<? extends TreeNode> childNodes = node.getChildren();
     int i = 0;
     final int size = childNodes.size();
     for (TreeNode childNode : childNodes) {
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/server/util/time/TimeUtil.java b/java/com/google/gerrit/server/util/time/TimeUtil.java
index 54ef305..f89324b 100644
--- a/java/com/google/gerrit/server/util/time/TimeUtil.java
+++ b/java/com/google/gerrit/server/util/time/TimeUtil.java
@@ -15,10 +15,7 @@
 package com.google.gerrit.server.util.time;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.server.util.git.DelegateSystemReader;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.concurrent.TimeUnit;
 import java.util.function.LongSupplier;
@@ -44,23 +41,8 @@
     return Instant.ofEpochMilli(nowMs());
   }
 
-  public static Timestamp nowTs() {
-    return new Timestamp(nowMs());
-  }
-
-  /**
-   * Returns the magic timestamp representing no specific time.
-   *
-   * <p>This "null object" is helpful in contexts where using {@code null} directly is not possible.
-   */
-  @UsedAt(Project.PLUGIN_CHECKS)
-  public static Timestamp never() {
-    // Always create a new object as timestamps are mutable.
-    return new Timestamp(0);
-  }
-
-  public static Timestamp truncateToSecond(Timestamp t) {
-    return new Timestamp((t.getTime() / 1000) * 1000);
+  public static Instant truncateToSecond(Instant t) {
+    return Instant.ofEpochMilli(t.getEpochSecond() * 1000);
   }
 
   @VisibleForTesting
diff --git a/java/com/google/gerrit/sshd/AliasCommand.java b/java/com/google/gerrit/sshd/AliasCommand.java
index bf0dd91..17ea463 100644
--- a/java/com/google/gerrit/sshd/AliasCommand.java
+++ b/java/com/google/gerrit/sshd/AliasCommand.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import java.io.IOException;
-import java.util.LinkedList;
+import java.util.ArrayDeque;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
@@ -120,8 +120,8 @@
     }
   }
 
-  private static LinkedList<String> chain(CommandName command) {
-    LinkedList<String> chain = new LinkedList<>();
+  private static Iterable<String> chain(CommandName command) {
+    ArrayDeque<String> chain = new ArrayDeque<>();
     while (command != null) {
       chain.addFirst(command.value());
       command = Commands.parentOf(command);
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index f679138..af7078d 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -4,7 +4,6 @@
     name = "sshd",
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
-    runtime_deps = ["//lib:jsch"],
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
@@ -31,7 +30,6 @@
         "//lib:jgit",
         "//lib:jgit-archive",
         "//lib:jgit-ssh-apache",
-        "//lib:jgit-ssh-jsch",
         "//lib:servlet-api",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index bb5494b..92de012 100644
--- a/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -191,6 +191,12 @@
                 }
 
                 @Override
+                public void onExit(int rc, String exitMessage) {
+                  exit.onExit(translateExit(rc), exitMessage);
+                  log(rc, exitMessage);
+                }
+
+                @Override
                 public void onExit(int rc) {
                   exit.onExit(translateExit(rc));
                   log(rc);
diff --git a/java/com/google/gerrit/sshd/DispatchCommandProvider.java b/java/com/google/gerrit/sshd/DispatchCommandProvider.java
index 2a65ed0..acf2df9 100644
--- a/java/com/google/gerrit/sshd/DispatchCommandProvider.java
+++ b/java/com/google/gerrit/sshd/DispatchCommandProvider.java
@@ -91,7 +91,7 @@
     return m;
   }
 
-  private static final TypeLiteral<Command> type = new TypeLiteral<Command>() {};
+  private static final TypeLiteral<Command> type = new TypeLiteral<>() {};
 
   private List<Binding<Command>> allCommands() {
     return injector.findBindingsByType(type);
diff --git a/java/com/google/gerrit/sshd/SshLogJsonLayout.java b/java/com/google/gerrit/sshd/SshLogJsonLayout.java
index fca0a5a..321cf56 100644
--- a/java/com/google/gerrit/sshd/SshLogJsonLayout.java
+++ b/java/com/google/gerrit/sshd/SshLogJsonLayout.java
@@ -26,11 +26,14 @@
 import static com.google.gerrit.sshd.SshLog.P_USER_NAME;
 import static com.google.gerrit.sshd.SshLog.P_WAIT;
 
+import com.google.common.base.Splitter;
 import com.google.gerrit.util.logging.JsonLayout;
 import com.google.gerrit.util.logging.JsonLogEntry;
+import java.util.List;
 import org.apache.log4j.spi.LoggingEvent;
 
 public class SshLogJsonLayout extends JsonLayout {
+  private static final Splitter SPLITTER = Splitter.on(" ");
 
   @Override
   public JsonLogEntry toJsonLogEntry(LoggingEvent event) {
@@ -81,18 +84,18 @@
 
       String metricString = getMdcString(event, P_MESSAGE);
       if (metricString != null && !metricString.isEmpty()) {
-        String[] ssh_metrics = metricString.split(" ");
-        this.timeNegotiating = ssh_metrics[0];
-        this.timeSearchReuse = ssh_metrics[1];
-        this.timeSearchSizes = ssh_metrics[2];
-        this.timeCounting = ssh_metrics[3];
-        this.timeCompressing = ssh_metrics[4];
-        this.timeWriting = ssh_metrics[5];
-        this.timeTotal = ssh_metrics[6];
-        this.bitmapIndexMisses = ssh_metrics[7];
-        this.deltasTotal = ssh_metrics[8];
-        this.objectsTotal = ssh_metrics[9];
-        this.bytesTotal = ssh_metrics[10];
+        List<String> ssh_metrics = SPLITTER.splitToList(metricString);
+        this.timeNegotiating = ssh_metrics.get(0);
+        this.timeSearchReuse = ssh_metrics.get(1);
+        this.timeSearchSizes = ssh_metrics.get(2);
+        this.timeCounting = ssh_metrics.get(3);
+        this.timeCompressing = ssh_metrics.get(4);
+        this.timeWriting = ssh_metrics.get(5);
+        this.timeTotal = ssh_metrics.get(6);
+        this.bitmapIndexMisses = ssh_metrics.get(7);
+        this.deltasTotal = ssh_metrics.get(8);
+        this.objectsTotal = ssh_metrics.get(9);
+        this.bytesTotal = ssh_metrics.get(10);
       }
     }
   }
diff --git a/java/com/google/gerrit/sshd/SshLogLayout.java b/java/com/google/gerrit/sshd/SshLogLayout.java
index a1f2c40..bb7edfa 100644
--- a/java/com/google/gerrit/sshd/SshLogLayout.java
+++ b/java/com/google/gerrit/sshd/SshLogLayout.java
@@ -32,7 +32,7 @@
 import org.eclipse.jgit.util.QuotedString;
 
 public final class SshLogLayout extends Layout {
-  protected final LogTimestampFormatter timestampFormatter;
+  private final LogTimestampFormatter timestampFormatter;
 
   public SshLogLayout() {
     timestampFormatter = new LogTimestampFormatter();
@@ -40,7 +40,7 @@
 
   @Override
   public String format(LoggingEvent event) {
-    final StringBuffer buf = new StringBuffer(128);
+    final StringBuilder buf = new StringBuilder(128);
 
     buf.append('[');
     buf.append(timestampFormatter.format(event.getTimeStamp()));
@@ -75,7 +75,7 @@
     return buf.toString();
   }
 
-  private void req(String key, StringBuffer buf, LoggingEvent event) {
+  private void req(String key, StringBuilder buf, LoggingEvent event) {
     Object val = event.getMDC(key);
     buf.append(' ');
     if (val != null) {
diff --git a/java/com/google/gerrit/sshd/SshScope.java b/java/com/google/gerrit/sshd/SshScope.java
index b85cf6d..f19c395 100644
--- a/java/com/google/gerrit/sshd/SshScope.java
+++ b/java/com/google/gerrit/sshd/SshScope.java
@@ -223,7 +223,7 @@
       new Scope() {
         @Override
         public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<T>() {
+          return new Provider<>() {
             @Override
             public T get() {
               return requireContext().get(key, creator);
diff --git a/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java b/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java
index d8e968a..de91b68 100644
--- a/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java
+++ b/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java
@@ -14,29 +14,18 @@
 
 package com.google.gerrit.sshd;
 
-import static com.google.gerrit.server.config.SshClientImplementation.APACHE;
-
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.transport.SshSessionFactory;
-import org.eclipse.jgit.transport.ssh.jsch.JschConfigSessionFactory;
 import org.eclipse.jgit.transport.sshd.DefaultProxyDataFactory;
 import org.eclipse.jgit.transport.sshd.JGitKeyCache;
 import org.eclipse.jgit.transport.sshd.SshdSessionFactory;
 import org.eclipse.jgit.util.FS;
 
 public class SshSessionFactoryInitializer {
-  public static void init(Config config) {
-    switch (config.getEnum("ssh", null, "clientImplementation", APACHE)) {
-      case APACHE:
-        SshdSessionFactory factory =
-            new SshdSessionFactory(new JGitKeyCache(), new DefaultProxyDataFactory());
-        factory.setHomeDirectory(FS.DETECTED.userHome());
-        SshSessionFactory.setInstance(factory);
-        break;
-
-      case JSCH:
-        SshSessionFactory.setInstance(new JschConfigSessionFactory());
-    }
+  public static void init() {
+    SshdSessionFactory factory =
+        new SshdSessionFactory(new JGitKeyCache(), new DefaultProxyDataFactory());
+    factory.setHomeDirectory(FS.DETECTED.userHome());
+    SshSessionFactory.setInstance(factory);
   }
 
   private SshSessionFactoryInitializer() {}
diff --git a/java/com/google/gerrit/sshd/commands/CopyApprovalsCommand.java b/java/com/google/gerrit/sshd/commands/CopyApprovalsCommand.java
deleted file mode 100644
index eacec28..0000000
--- a/java/com/google/gerrit/sshd/commands/CopyApprovalsCommand.java
+++ /dev/null
@@ -1,93 +0,0 @@
-// 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.sshd.commands;
-
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.server.approval.RecursiveApprovalCopier;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.concurrent.atomic.AtomicInteger;
-import org.kohsuke.args4j.Argument;
-import org.kohsuke.args4j.Option;
-
-@CommandMetaData(
-    name = "copy-approvals",
-    description = "Copy inferred approvals labels to the latest patch-set")
-@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
-public class CopyApprovalsCommand extends SshCommand {
-
-  private final Set<Project.NameKey> projects = new HashSet<>();
-  private final RecursiveApprovalCopier recursiveApprovalCopier;
-  private final GitRepositoryManager repositoryManager;
-
-  @Argument(
-      index = 0,
-      required = false,
-      multiValued = true,
-      metaVar = "PROJECT",
-      usage = "list of projects to scan for approvals (default: all projects)")
-  void addProject(String project) {
-    projects.add(Project.nameKey(project));
-  }
-
-  @Option(
-      name = "--verbose",
-      aliases = "-v",
-      usage = "display projects/changes impacted by the label copy operation",
-      metaVar = "VERBOSE")
-  private boolean verbose;
-
-  @Inject
-  public CopyApprovalsCommand(
-      RecursiveApprovalCopier recursiveApprovalCopier, GitRepositoryManager repositoryManager) {
-    this.recursiveApprovalCopier = recursiveApprovalCopier;
-    this.repositoryManager = repositoryManager;
-  }
-
-  @Override
-  protected void run() throws Exception {
-    AtomicInteger changesCounter = new AtomicInteger();
-    stdout.println(
-        "Copying inferred approvals labels on " + (projects.isEmpty() ? "all projects" : projects));
-
-    Set<Project.NameKey> projectsList = projects.isEmpty() ? repositoryManager.list() : projects;
-
-    for (Project.NameKey project : projectsList) {
-      stdout.print("> " + project + " : ");
-      recursiveApprovalCopier.persist(
-          project,
-          c -> {
-            if (verbose) {
-              stdout.println("  [" + c.getProject() + "," + c.getChangeId() + "] updated");
-            }
-            changesCounter.incrementAndGet();
-          });
-      stdout.println("DONE");
-    }
-
-    stdout.println(
-        "Labels copied for "
-            + projectsList.size()
-            + " project(s) have impacted "
-            + changesCounter.get()
-            + " change(s)");
-  }
-}
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index 1a08c43..e7fe22f 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -109,7 +109,6 @@
 
     command(gerrit, RenameGroupCommand.class);
     command(gerrit, ReviewCommand.class);
-    command(gerrit, CopyApprovalsCommand.class);
     command(gerrit, SetProjectCommand.class);
     command(gerrit, SetReviewersCommand.class);
     command(gerrit, SetTopicCommand.class);
diff --git a/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java b/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java
index 29cc1cf..aa147f0 100644
--- a/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java
@@ -41,10 +41,13 @@
 
     if (!isUserNameCaseInsensitive || !isUserNameCaseInsensitiveMigrationMode) {
       die(
-          "External IDs online migration requires auth.userNameCaseInsensitive and auth.userNameCaseInsensitiveMigrationMode to be set to true. Cannot start migration!");
+          "External IDs online migration requires auth.userNameCaseInsensitive and"
+              + " auth.userNameCaseInsensitiveMigrationMode to be set to true. Cannot start"
+              + " migration!");
     }
     onlineExternalIdCaseSensivityMigrator.migrate();
     stdout.println(
-        "External ids case insensitivity migration started. To check if it's completed look for \"External IDs migration completed!\" message in the Gerrit server logs");
+        "External ids case insensitivity migration started. To check if it's completed look for"
+            + " \"External IDs migration completed!\" message in the Gerrit server logs");
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
index 1a7be32..742536c 100644
--- a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import java.util.Enumeration;
+import java.util.Collections;
 import java.util.Map;
 import java.util.TreeMap;
 import org.apache.log4j.LogManager;
@@ -37,19 +37,22 @@
   @Argument(index = 0, required = false, metaVar = "NAME", usage = "used to match loggers")
   private String name;
 
-  @SuppressWarnings("unchecked")
   @Override
   protected void run() {
     enableGracefulStop();
     Map<String, String> logs = new TreeMap<>();
-    for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger.hasMoreElements(); ) {
-      Logger log = logger.nextElement();
-      if (name == null || log.getName().contains(name)) {
-        logs.put(log.getName(), log.getEffectiveLevel().toString());
+    for (Logger logger : getCurrentLoggers()) {
+      if (name == null || logger.getName().contains(name)) {
+        logs.put(logger.getName(), logger.getEffectiveLevel().toString());
       }
     }
     for (Map.Entry<String, String> e : logs.entrySet()) {
       stdout.println(e.getKey() + ": " + e.getValue());
     }
   }
+
+  @SuppressWarnings({"unchecked", "JdkObsolete"})
+  private static Iterable<Logger> getCurrentLoggers() {
+    return Collections.list(LogManager.getCurrentLoggers());
+  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
index 3faf598..4d16da6 100644
--- a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.sshd.SshCommand;
 import java.net.MalformedURLException;
 import java.net.URL;
-import java.util.Enumeration;
+import java.util.Collections;
 import org.apache.log4j.Level;
 import org.apache.log4j.LogManager;
 import org.apache.log4j.Logger;
@@ -58,27 +58,23 @@
   @Argument(index = 1, required = false, metaVar = "NAME", usage = "used to match loggers")
   private String name;
 
-  @SuppressWarnings("unchecked")
   @Override
   protected void run() throws MalformedURLException {
     enableGracefulStop();
     if (level == LevelOption.RESET) {
       reset();
     } else {
-      for (Enumeration<Logger> logger = LogManager.getCurrentLoggers();
-          logger.hasMoreElements(); ) {
-        Logger log = logger.nextElement();
-        if (name == null || log.getName().contains(name)) {
-          log.setLevel(Level.toLevel(level.name()));
+      for (Logger logger : getCurrentLoggers()) {
+        if (name == null || logger.getName().contains(name)) {
+          logger.setLevel(Level.toLevel(level.name()));
         }
       }
     }
   }
 
-  @SuppressWarnings("unchecked")
   private static void reset() throws MalformedURLException {
-    for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger.hasMoreElements(); ) {
-      logger.nextElement().setLevel(null);
+    for (Logger logger : getCurrentLoggers()) {
+      logger.setLevel(null);
     }
 
     String path = System.getProperty(JAVA_OPTIONS_LOG_CONFIG);
@@ -88,4 +84,9 @@
       PropertyConfigurator.configure(new URL(path));
     }
   }
+
+  @SuppressWarnings({"unchecked", "JdkObsolete"})
+  private static Iterable<Logger> getCurrentLoggers() {
+    return Collections.list(LogManager.getCurrentLoggers());
+  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 02956f7..5b89228 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -43,9 +43,10 @@
 import com.google.gerrit.sshd.SshDaemon;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.Collection;
-import java.util.Date;
 import java.util.Map;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
@@ -114,13 +115,16 @@
   protected void run() throws Failure {
     enableGracefulStop();
     nw = columns - 50;
-    Date now = new Date();
+    Instant now = Instant.now();
+    DateTimeFormatter fmt =
+        DateTimeFormatter.ofPattern("HH:mm:ss   zzz").withZone(ZoneId.of("UTC"));
     stdout.format(
         "%-25s %-20s      now  %16s\n",
         "Gerrit Code Review",
         Version.getVersion() != null ? Version.getVersion() : "",
-        new SimpleDateFormat("HH:mm:ss   zzz").format(now));
-    stdout.format("%-25s %-20s   uptime %16s\n", "", "", uptime(now.getTime() - serverStarted));
+        fmt.format(now));
+    stdout.format(
+        "%-25s %-20s   uptime %16s\n", "", "", uptime(now.toEpochMilli() - serverStarted));
     stdout.print('\n');
 
     try {
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index 7eeb770..5efeb42 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -34,8 +34,9 @@
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
-import java.text.SimpleDateFormat;
-import java.util.Date;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.Optional;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
@@ -171,10 +172,13 @@
   }
 
   private static String time(long now, long time) {
+    Instant instant = Instant.ofEpochMilli(time);
     if (now - time < 24 * 60 * 60 * 1000L) {
-      return new SimpleDateFormat("HH:mm:ss").format(new Date(time));
+      return DateTimeFormatter.ofPattern("HH:mm:ss")
+          .withZone(ZoneId.systemDefault())
+          .format(instant);
     }
-    return new SimpleDateFormat("MMM-dd").format(new Date(time));
+    return DateTimeFormatter.ofPattern("MMM-dd").withZone(ZoneId.systemDefault()).format(instant);
   }
 
   private static String age(long age) {
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 779f2df..4254e5b 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -35,8 +35,9 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.text.SimpleDateFormat;
-import java.util.Date;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.List;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import org.apache.sshd.server.Environment;
@@ -154,7 +155,7 @@
         stdout.print(
             String.format(
                 "%8s %-12s %-12s %-4s %s\n",
-                task.id, start, startTime(task.startTime), "", command));
+                task.id, start, startTime(task.startTime.toInstant()), "", command));
       } else {
         String remoteName =
             task.remoteName != null ? task.remoteName + "/" + task.projectName : task.projectName;
@@ -164,7 +165,7 @@
                 "%8s %-12s %-4s %s\n",
                 task.id,
                 start,
-                startTime(task.startTime),
+                startTime(task.startTime.toInstant()),
                 MoreObjects.firstNonNull(remoteName, "n/a")));
       }
     }
@@ -178,19 +179,23 @@
   }
 
   private static String time(long now, long delay) {
-    Date when = new Date(now + delay);
+    Instant when = Instant.ofEpochMilli(now + delay);
     return format(when, delay);
   }
 
-  private static String startTime(Date when) {
-    return format(when, TimeUtil.nowMs() - when.getTime());
+  private static String startTime(Instant when) {
+    return format(when, TimeUtil.nowMs() - when.toEpochMilli());
   }
 
-  private static String format(Date when, long timeFromNow) {
+  private static String format(Instant when, long timeFromNow) {
     if (timeFromNow < 24 * 60 * 60 * 1000L) {
-      return new SimpleDateFormat("HH:mm:ss.SSS").format(when);
+      return DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
+          .withZone(ZoneId.systemDefault())
+          .format(when);
     }
-    return new SimpleDateFormat("MMM-dd HH:mm").format(when);
+    return DateTimeFormatter.ofPattern("MMM-dd HH:mm")
+        .withZone(ZoneId.systemDefault())
+        .format(when);
   }
 
   private static String format(Task.State state) {
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 05987bb..e5234fe 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -51,7 +51,6 @@
         "//lib/guice",
         "//lib/guice:guice-servlet",
         "//lib/log:impl-log4j",
-        "//lib/log:log4j",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/testing/FakeAccountCache.java b/java/com/google/gerrit/testing/FakeAccountCache.java
index ab3348b..49a8d71 100644
--- a/java/com/google/gerrit/testing/FakeAccountCache.java
+++ b/java/com/google/gerrit/testing/FakeAccountCache.java
@@ -40,7 +40,7 @@
       return state;
     }
     return newState(
-        Account.builder(accountId, TimeUtil.nowTs())
+        Account.builder(accountId, TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build());
   }
diff --git a/java/com/google/gerrit/testing/FloggerInitializer.java b/java/com/google/gerrit/testing/FloggerInitializer.java
index 1972107..7793de1 100644
--- a/java/com/google/gerrit/testing/FloggerInitializer.java
+++ b/java/com/google/gerrit/testing/FloggerInitializer.java
@@ -25,7 +25,7 @@
   public static void initBackend() {
     System.setProperty(
         FLOGGER_BACKEND_PROPERTY,
-        "com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance");
+        "com.google.common.flogger.backend.system.SimpleBackendFactory#getInstance");
     System.setProperty(FLOGGER_LOGGING_CONTEXT, LoggingContext.class.getName() + "#getInstance");
   }
 }
diff --git a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
index 362e23c..2051ae3 100644
--- a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
+++ b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
@@ -14,16 +14,16 @@
 
 package com.google.gerrit.testing;
 
-import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
 import com.google.inject.Inject;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -111,12 +111,12 @@
   }
 
   @Override
-  public synchronized SortedSet<Project.NameKey> list() {
-    SortedSet<Project.NameKey> names = Sets.newTreeSet();
+  public synchronized NavigableSet<Project.NameKey> list() {
+    NavigableSet<Project.NameKey> names = Sets.newTreeSet();
     for (DfsRepository repo : repos.values()) {
       names.add(Project.nameKey(repo.getDescription().getRepositoryName()));
     }
-    return ImmutableSortedSet.copyOf(names);
+    return Collections.unmodifiableNavigableSet(names);
   }
 
   public synchronized void deleteRepository(Project.NameKey name) {
diff --git a/java/com/google/gerrit/testing/IndexVersions.java b/java/com/google/gerrit/testing/IndexVersions.java
index 3281ffc..3810707 100644
--- a/java/com/google/gerrit/testing/IndexVersions.java
+++ b/java/com/google/gerrit/testing/IndexVersions.java
@@ -27,7 +27,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.SortedMap;
+import java.util.NavigableMap;
 import org.eclipse.jgit.lib.Config;
 
 public class IndexVersions {
@@ -90,13 +90,13 @@
       value = value.trim();
     }
 
-    SortedMap<Integer, Schema<V>> schemas = schemaDef.getSchemas();
+    NavigableMap<Integer, Schema<V>> schemas = schemaDef.getSchemas();
     if (!Strings.isNullOrEmpty(value)) {
       if (ALL.equals(value)) {
         return ImmutableList.copyOf(schemas.keySet());
       }
 
-      List<Integer> versions = new ArrayList<>();
+      ImmutableList.Builder<Integer> versions = ImmutableList.builder();
       for (String s : Splitter.on(',').trimResults().split(value)) {
         if (CURRENT.equals(s)) {
           versions.add(schemaDef.getLatest().getVersion());
@@ -115,15 +115,15 @@
           versions.add(version);
         }
       }
-      return ImmutableList.copyOf(versions);
+      return versions.build();
     }
 
-    List<Integer> schemaVersions = new ArrayList<>(2);
+    ImmutableList.Builder<Integer> schemaVersions = ImmutableList.builderWithExpectedSize(2);
     if (schemaDef.getPrevious() != null) {
       schemaVersions.add(schemaDef.getPrevious().getVersion());
     }
     schemaVersions.add(schemaDef.getLatest().getVersion());
-    return ImmutableList.copyOf(schemaVersions);
+    return schemaVersions.build();
   }
 
   public static <V> Map<String, Config> asConfigMap(
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index b795c5b..4a97bc5 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Injector;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -58,7 +58,7 @@
             changeId,
             userId,
             BranchNameKey.create(project, "master"),
-            TimeUtil.nowTs());
+            TimeUtil.now());
     incrementPatchSet(c);
     return c;
   }
@@ -72,7 +72,7 @@
         .id(id)
         .commitId(ObjectId.fromString(revision))
         .uploader(userId)
-        .createdOn(TimeUtil.nowTs())
+        .createdOn(TimeUtil.now())
         .build();
   }
 
@@ -94,7 +94,7 @@
                         injector.getInstance(AbstractChangeNotes.Args.class), c, shouldExist, null)
                     .load(),
                 user,
-                TimeUtil.nowTs(),
+                TimeUtil.now(),
                 Ordering.natural());
 
     ChangeNotes notes = update.getNotes();
@@ -109,7 +109,7 @@
     try (Repository repo = repoManager.openRepository(c.getProject());
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
       PersonIdent ident =
-          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), TimeZone.getDefault());
+          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), ZoneId.systemDefault());
       TestRepository<Repository>.CommitBuilder cb =
           tr.commit()
               .author(ident)
diff --git a/java/com/google/gerrit/testing/TestCommentHelper.java b/java/com/google/gerrit/testing/TestCommentHelper.java
index 5865a3c..400b559 100644
--- a/java/com/google/gerrit/testing/TestCommentHelper.java
+++ b/java/com/google/gerrit/testing/TestCommentHelper.java
@@ -33,6 +33,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 
 /** Test helper for dealing with comments/drafts. */
 public class TestCommentHelper {
@@ -64,7 +65,7 @@
     gApi.changes().id(changeId).current().createDraft(in);
   }
 
-  public Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
+  public List<CommentInfo> getPublishedComments(String changeId) throws Exception {
     return gApi.changes().id(changeId).commentsRequest().get().values().stream()
         .flatMap(Collection::stream)
         .collect(toList());
diff --git a/java/com/google/gerrit/testing/TestLoggingActivator.java b/java/com/google/gerrit/testing/TestLoggingActivator.java
index ee1a525..c7e22c9 100644
--- a/java/com/google/gerrit/testing/TestLoggingActivator.java
+++ b/java/com/google/gerrit/testing/TestLoggingActivator.java
@@ -14,15 +14,12 @@
 
 package com.google.gerrit.testing;
 
-import static org.apache.log4j.Logger.getLogger;
-
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
-import org.apache.log4j.ConsoleAppender;
-import org.apache.log4j.Level;
-import org.apache.log4j.LogManager;
-import org.apache.log4j.Logger;
-import org.apache.log4j.PatternLayout;
+import java.util.logging.ConsoleHandler;
+import java.util.logging.Level;
+import java.util.logging.LogManager;
+import java.util.logging.Logger;
 
 public class TestLoggingActivator {
   private static final ImmutableMap<String, Level> LOG_LEVELS =
@@ -30,38 +27,38 @@
           .put("com.google.gerrit", getGerritLogLevel())
 
           // Silence non-critical messages from MINA SSHD.
-          .put("org.apache.mina", Level.WARN)
-          .put("org.apache.sshd.client", Level.WARN)
-          .put("org.apache.sshd.common", Level.WARN)
-          .put("org.apache.sshd.server", Level.WARN)
+          .put("org.apache.mina", Level.WARNING)
+          .put("org.apache.sshd.client", Level.WARNING)
+          .put("org.apache.sshd.common", Level.WARNING)
+          .put("org.apache.sshd.server", Level.WARNING)
           .put("org.apache.sshd.common.keyprovider.FileKeyPairProvider", Level.INFO)
-          .put("com.google.gerrit.sshd.GerritServerSession", Level.WARN)
+          .put("com.google.gerrit.sshd.GerritServerSession", Level.WARNING)
 
           // Silence non-critical messages from mime-util.
-          .put("eu.medsea.mimeutil", Level.WARN)
+          .put("eu.medsea.mimeutil", Level.WARNING)
 
           // Silence non-critical messages from openid4java.
-          .put("org.apache.xml", Level.WARN)
-          .put("org.openid4java", Level.WARN)
-          .put("org.openid4java.consumer.ConsumerManager", Level.FATAL)
-          .put("org.openid4java.discovery.Discovery", Level.ERROR)
-          .put("org.openid4java.server.RealmVerifier", Level.ERROR)
-          .put("org.openid4java.message.AuthSuccess", Level.ERROR)
+          .put("org.apache.xml", Level.WARNING)
+          .put("org.openid4java", Level.WARNING)
+          .put("org.openid4java.consumer.ConsumerManager", Level.SEVERE)
+          .put("org.openid4java.discovery.Discovery", Level.SEVERE)
+          .put("org.openid4java.server.RealmVerifier", Level.SEVERE)
+          .put("org.openid4java.message.AuthSuccess", Level.SEVERE)
 
           // Silence non-critical messages from apache.http.
-          .put("org.apache.http", Level.WARN)
+          .put("org.apache.http", Level.WARNING)
 
           // Silence non-critical messages from Jetty.
-          .put("org.eclipse.jetty", Level.WARN)
+          .put("org.eclipse.jetty", Level.WARNING)
 
           // Silence non-critical messages from JGit.
-          .put("org.eclipse.jgit.transport.PacketLineIn", Level.WARN)
-          .put("org.eclipse.jgit.transport.PacketLineOut", Level.WARN)
-          .put("org.eclipse.jgit.internal.transport.sshd", Level.WARN)
-          .put("org.eclipse.jgit.util.FileUtils", Level.WARN)
-          .put("org.eclipse.jgit.internal.storage.file.FileSnapshot", Level.WARN)
-          .put("org.eclipse.jgit.util.FS", Level.WARN)
-          .put("org.eclipse.jgit.util.SystemReader", Level.WARN)
+          .put("org.eclipse.jgit.transport.PacketLineIn", Level.WARNING)
+          .put("org.eclipse.jgit.transport.PacketLineOut", Level.WARNING)
+          .put("org.eclipse.jgit.internal.transport.sshd", Level.WARNING)
+          .put("org.eclipse.jgit.util.FileUtils", Level.WARNING)
+          .put("org.eclipse.jgit.internal.storage.file.FileSnapshot", Level.WARNING)
+          .put("org.eclipse.jgit.util.FS", Level.WARNING)
+          .put("org.eclipse.jgit.util.SystemReader", Level.WARNING)
           .build();
 
   private static Level getGerritLogLevel() {
@@ -69,27 +66,44 @@
     if (value.isEmpty()) {
       value = Strings.nullToEmpty(System.getProperty("gerrit.logLevel"));
     }
-    return Level.toLevel(value, Level.INFO);
+
+    try {
+      return Level.parse(value);
+    } catch (IllegalArgumentException e) {
+      // for backwards compatibility handle log4j log levels
+      if (value.equalsIgnoreCase("FATAL") || value.equalsIgnoreCase("ERROR")) {
+        return Level.SEVERE;
+      }
+      if (value.equalsIgnoreCase("WARN")) {
+        return Level.WARNING;
+      }
+      if (value.equalsIgnoreCase("DEBUG")) {
+        return Level.FINE;
+      }
+      if (value.equalsIgnoreCase("TRACE")) {
+        return Level.FINEST;
+      }
+
+      return Level.INFO;
+    }
   }
 
   public static void configureLogging() {
-    LogManager.resetConfiguration();
+    LogManager.getLogManager().reset();
     FloggerInitializer.initBackend();
 
-    PatternLayout layout = new PatternLayout();
-    layout.setConversionPattern("%-5p %c %x: %m%n");
+    ConsoleHandler dst = new ConsoleHandler();
+    dst.setLevel(Level.FINEST);
 
-    ConsoleAppender dst = new ConsoleAppender();
-    dst.setLayout(layout);
-    dst.setTarget("System.err");
-    dst.setThreshold(Level.DEBUG);
-    dst.activateOptions();
+    Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).addHandler(dst);
 
-    Logger root = LogManager.getRootLogger();
-    root.removeAllAppenders();
-    root.addAppender(dst);
-
-    LOG_LEVELS.entrySet().stream().forEach(e -> getLogger(e.getKey()).setLevel(e.getValue()));
+    LOG_LEVELS.entrySet().stream()
+        .forEach(
+            e -> {
+              Logger logger = Logger.getLogger(e.getKey());
+              logger.setLevel(e.getValue());
+              logger.addHandler(dst);
+            });
   }
 
   private TestLoggingActivator() {}
diff --git a/java/com/google/gerrit/util/http/BUILD b/java/com/google/gerrit/util/http/BUILD
index fbd1379..afb4e25 100644
--- a/java/com/google/gerrit/util/http/BUILD
+++ b/java/com/google/gerrit/util/http/BUILD
@@ -4,5 +4,8 @@
     name = "http",
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
-    deps = ["//lib:servlet-api"],
+    deps = [
+        "//lib:guava",
+        "//lib:servlet-api",
+    ],
 )
diff --git a/java/com/google/gerrit/util/http/RequestUtil.java b/java/com/google/gerrit/util/http/RequestUtil.java
index f64ce5a..51ffe0c 100644
--- a/java/com/google/gerrit/util/http/RequestUtil.java
+++ b/java/com/google/gerrit/util/http/RequestUtil.java
@@ -14,10 +14,14 @@
 
 package com.google.gerrit.util.http;
 
+import com.google.common.base.Splitter;
+import java.util.List;
 import javax.servlet.http.HttpServletRequest;
 
 /** Utilities for manipulating HTTP request objects. */
 public class RequestUtil {
+  private static final Splitter SPLITTER = Splitter.on("/");
+
   /** HTTP request attribute for storing the Throwable that caused an error condition. */
   private static final String ATTRIBUTE_ERROR_TRACE =
       RequestUtil.class.getName() + "/ErrorTraceThrowable";
@@ -80,11 +84,11 @@
       encodedPathInfo = encodedPathInfo.substring(2);
     }
 
-    String[] parts = encodedPathInfo.split("/");
-    StringBuilder result = new StringBuilder(parts.length);
-    for (int i = 0; i < parts.length; i = i + 2) {
+    List<String> parts = SPLITTER.splitToList(encodedPathInfo);
+    StringBuilder result = new StringBuilder(parts.size());
+    for (int i = 0; i < parts.size(); i = i + 2) {
       result.append("/");
-      result.append(parts[i]);
+      result.append(parts.get(i));
     }
     return result.toString();
   }
diff --git a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
index 7758be6..2ea2a55 100644
--- a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
@@ -126,7 +126,7 @@
 
   private static String getMessageId(FakeEmailSender sender) {
     return ((StringEmailHeader)
-            (Iterables.getOnlyElement(sender.getMessages()).headers().get("Message-ID")))
+            Iterables.getOnlyElement(sender.getMessages()).headers().get("Message-ID"))
         .getString();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index 7d04558..b6e5b74 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -335,7 +335,7 @@
   private ObjectId createCommit(Repository repo) throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent ident =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
       cb.setCommitter(ident);
diff --git a/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java b/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java
index ecfe3f5..9d689ba 100644
--- a/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java
+++ b/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.server.util.time.TimeUtil;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.concurrent.TimeUnit;
 import org.junit.Test;
@@ -52,6 +51,6 @@
   @Test
   @UseClockStep(startAtEpoch = true)
   public void useClockStepWithStartAtEpoch() {
-    assertThat(TimeUtil.nowTs()).isEqualTo(Timestamp.from(Instant.EPOCH));
+    assertThat(TimeUtil.now()).isEqualTo(Instant.EPOCH);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 915e759..9f47925 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -499,7 +499,7 @@
       assertThat(ref).isNotNull();
       RevCommit c = rw.parseCommit(ref.getObjectId());
       long timestampDiffMs =
-          Math.abs(c.getCommitTime() * 1000L - getAccount(accountId).registeredOn().getTime());
+          Math.abs(c.getCommitTime() * 1000L - getAccount(accountId).registeredOn().toEpochMilli());
       assertThat(timestampDiffMs).isAtMost(SECONDS.toMillis(1));
 
       // Check the 'account.config' file.
@@ -980,7 +980,8 @@
     assertThat(detail.email).isEqualTo(email);
     assertThat(detail.secondaryEmails).containsExactly(secondaryEmail);
     assertThat(detail.status).isEqualTo(status);
-    assertThat(detail.registeredOn).isEqualTo(getAccount(foo.id()).registeredOn());
+    assertThat(detail.registeredOn.getTime())
+        .isEqualTo(getAccount(foo.id()).registeredOn().toEpochMilli());
     assertThat(detail.inactive).isNull();
     assertThat(detail._moreAccounts).isNull();
   }
@@ -2477,7 +2478,7 @@
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
 
-      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.nowTs());
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(commit.getTree());
       cb.setCommitter(ident);
@@ -2497,7 +2498,7 @@
     // stale.
     try (Repository repo = repoManager.openRepository(allUsers)) {
       ExternalIdNotes extIdNotes =
-          ExternalIdNotes.loadNoCacheUpdate(
+          ExternalIdNotes.load(
               allUsers,
               repo,
               externalIdFactory,
@@ -2511,7 +2512,7 @@
       assertStaleAccountAndReindex(accountId);
 
       extIdNotes =
-          ExternalIdNotes.loadNoCacheUpdate(
+          ExternalIdNotes.load(
               allUsers,
               repo,
               externalIdFactory,
@@ -2523,7 +2524,7 @@
       assertStaleAccountAndReindex(accountId);
 
       extIdNotes =
-          ExternalIdNotes.loadNoCacheUpdate(
+          ExternalIdNotes.load(
               allUsers,
               repo,
               externalIdFactory,
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index c7aac96..86691c6 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.api.change;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -31,6 +31,8 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.extensions.client.ChangeStatus.ABANDONED;
+import static com.google.gerrit.extensions.client.ChangeStatus.MERGED;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
@@ -69,7 +71,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.MoreCollectors;
 import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ChangeIndexedCounter;
@@ -85,6 +86,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;
@@ -99,16 +101,10 @@
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
-import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
-import com.google.gerrit.entities.SubmitRequirementExpression;
-import com.google.gerrit.entities.SubmitRequirementExpressionResult;
-import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
@@ -150,12 +146,7 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.common.SubmitRecordInfo;
-import com.google.gerrit.extensions.common.SubmitRequirementInput;
-import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
-import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -174,13 +165,15 @@
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.ChangeMessageModifier;
+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.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.IntraLineDiff;
@@ -189,7 +182,6 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeOperatorFactory;
 import com.google.gerrit.server.restapi.change.PostReview;
-import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -203,9 +195,11 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.text.MessageFormat;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -214,6 +208,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -225,7 +220,6 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
-import org.junit.Ignore;
 import org.junit.Test;
 
 @NoHttpd
@@ -240,6 +234,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @Inject
   @Named("diff_intraline")
@@ -1026,6 +1021,31 @@
   }
 
   @Test
+  public void rebaseWithValidationOptions() 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());
+    revision.submit();
+
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.validationOptions = ImmutableMap.of("key", "value");
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      // Rebase the second change
+      gApi.changes().id(r2.getChangeId()).current().rebase(rebaseInput);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   public void deleteNewChangeAsAdmin() throws Exception {
     deleteChangeAsUser(admin, admin);
   }
@@ -1925,7 +1945,7 @@
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+    Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     addReviewer.call(r.getChangeId(), user.email());
 
@@ -2036,7 +2056,7 @@
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+    Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "ab" with one user: testUser
     String email = "abcd@example.com";
@@ -2085,7 +2105,7 @@
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+    Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "kobe" with one user: lee
     String testUserFullname = "kobebryant";
@@ -2186,7 +2206,7 @@
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+    Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
@@ -2830,6 +2850,53 @@
   }
 
   @Test
+  public void labelPermissionsChange_doesNotAffectCurrentVotes() throws Exception {
+    String heads = "refs/heads/*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(LabelId.CODE_REVIEW).ref(heads).group(REGISTERED_USERS).range(-2, +2))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Approve the change as user
+    requestScopeOperations.setApiUser(user.id());
+    approve(changeId);
+    assertThat(
+            gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get("Code-Review").all.stream()
+                .collect(toImmutableMap(vote -> Account.id(vote._accountId), vote -> vote.value)))
+        .isEqualTo(ImmutableMap.of(user.id(), 2));
+
+    // Remove permissions for CODE_REVIEW. The user still has [-1,+1], inherited from All-Projects.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(labelPermissionKey(LabelId.CODE_REVIEW).ref(heads).group(REGISTERED_USERS))
+        .update();
+
+    // No permissions to vote +2
+    assertThrows(AuthException.class, () -> approve(changeId));
+
+    assertThat(
+            get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
+                .map(vote -> vote.value))
+        .containsExactly(2);
+
+    // The change is still submittable
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(changeId).current().submit();
+    assertThat(info(changeId).status).isEqualTo(MERGED);
+
+    // The +2 vote out of permissions range is still present.
+    assertThat(
+            get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
+                .collect(toImmutableMap(vote -> Account.id(vote._accountId), vote -> vote.value)))
+        .isEqualTo(ImmutableMap.of(user.id(), 2, admin.id(), 0));
+  }
+
+  @Test
   public void createEmptyChange() throws Exception {
     ChangeInput in = new ChangeInput();
     in.branch = Constants.MASTER;
@@ -3091,17 +3158,14 @@
   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());
 
     gApi.changes().id(r.getChangeId()).current().submit();
-    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(MERGED);
   }
 
   @Test
@@ -3127,7 +3191,7 @@
         .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(MERGED);
   }
 
   @Test
@@ -3373,7 +3437,7 @@
       assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
       PersonIdent expectedAuthor =
           changeNoteUtil.newAccountIdIdent(
-              getAccount(admin.id()).id(), c.updated, serverIdent.get());
+              getAccount(admin.id()).id(), c.updated.toInstant(), serverIdent.get());
       assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitPatchSetCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
@@ -3383,7 +3447,7 @@
       assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
       expectedAuthor =
           changeNoteUtil.newAccountIdIdent(
-              getAccount(admin.id()).id(), c.created, serverIdent.get());
+              getAccount(admin.id()).id(), c.created.toInstant(), serverIdent.get());
       assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitChangeCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
@@ -3495,6 +3559,55 @@
     r2.assertOkStatus();
   }
 
+  private void assertLabelDescription(ChangeInfo changeInfo, String labelName, String description) {
+    assertThat(changeInfo.labels.get(labelName).description).isEqualTo(description);
+  }
+
+  @Test
+  public void checkLabelVotesForUnsubmittedChange() throws Exception {
+    List<ListChangesOption> options =
+        EnumSet.complementOf(
+                EnumSet.of(
+                    ListChangesOption.CHECK,
+                    ListChangesOption.SKIP_DIFFSTAT,
+                    ListChangesOption.DETAILED_LABELS))
+            .stream()
+            .collect(Collectors.toList());
+    PushOneCommit.Result r = createChange();
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get(options);
+    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.labels.get(LabelId.CODE_REVIEW).all).isNull();
+
+    voteLabel(r.getChangeId(), LabelId.CODE_REVIEW, +1);
+    change = gApi.changes().id(r.getChangeId()).get(options);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    List<ApprovalInfo> codeReviewApprovals = change.labels.get(LabelId.CODE_REVIEW).all;
+    assertThat(codeReviewApprovals).hasSize(1);
+    ApprovalInfo codeReviewApproval = codeReviewApprovals.get(0);
+    // permittedVotingRange is not served if DETAILED_LABELS is not requested.
+    assertThat(codeReviewApproval.permittedVotingRange).isNull();
+    assertThat(codeReviewApproval.value).isEqualTo(1);
+    assertThat(codeReviewApproval.username).isEqualTo(admin.username());
+
+    // Add another +1 vote as user
+    requestScopeOperations.setApiUser(user.id());
+    voteLabel(r.getChangeId(), LabelId.CODE_REVIEW, +1);
+    change = gApi.changes().id(r.getChangeId()).get(options);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.labels.get(LabelId.CODE_REVIEW).all).hasSize(2);
+    // All available label votes and their meanings are also served if DETAILED_LABELS is not
+    // requested.
+    assertThat(change.labels.get(LabelId.CODE_REVIEW).values).isNotNull();
+    codeReviewApprovals = change.labels.get(LabelId.CODE_REVIEW).all;
+    assertThat(codeReviewApprovals.stream().map(a -> a.permittedVotingRange).collect(toList()))
+        .containsExactly(null, null);
+    assertThat(codeReviewApprovals.stream().map(a -> a.value).collect(toList()))
+        .containsExactly(1, 1);
+    assertThat(codeReviewApprovals.stream().map(a -> a.username).collect(toList()))
+        .containsExactly(admin.username(), user.username());
+  }
+
   @Test
   public void checkLabelsForUnsubmittedChange() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -3524,6 +3637,7 @@
         .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
     assertPermitted(change, LabelId.CODE_REVIEW, -2, -1, 0, 1, 2);
     assertPermitted(change, LabelId.VERIFIED, -1, 0, 1);
+    assertLabelDescription(change, LabelId.VERIFIED, TestLabels.VERIFIED_LABEL_DESCRIPTION);
 
     // add an approval on the new label
     gApi.changes()
@@ -3560,13 +3674,63 @@
   }
 
   @Test
+  public void checkLabelVotesForMergedChange() throws Exception {
+    List<ListChangesOption> options =
+        EnumSet.complementOf(
+                EnumSet.of(
+                    ListChangesOption.CHECK,
+                    ListChangesOption.SKIP_DIFFSTAT,
+                    ListChangesOption.DETAILED_LABELS))
+            .stream()
+            .collect(Collectors.toList());
+    PushOneCommit.Result r = createChange();
+    voteLabel(r.getChangeId(), LabelId.CODE_REVIEW, +2);
+
+    // Add another label for 'Verified'
+    LabelType verified = TestLabels.verified();
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(verified);
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(registeredUsers).range(-1, 1))
+        .update();
+
+    // Submit the change
+    voteLabel(r.getChangeId(), TestLabels.verified().getName(), 1);
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Make sure label votes are available if DETAILED_LABELS is not requested.
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get(options);
+    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.labels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, TestLabels.verified().getName());
+    List<ApprovalInfo> codeReviewApprovals = change.labels.get(LabelId.CODE_REVIEW).all;
+    List<ApprovalInfo> verifiedApprovals = change.labels.get(TestLabels.verified().getName()).all;
+
+    assertThat(codeReviewApprovals).hasSize(1);
+    assertThat(codeReviewApprovals.get(0).value).isEqualTo(2);
+    assertThat(codeReviewApprovals.get(0).username).isEqualTo(admin.username());
+    assertThat(codeReviewApprovals.get(0).permittedVotingRange).isNull();
+
+    assertThat(verifiedApprovals).hasSize(1);
+    assertThat(verifiedApprovals.get(0).value).isEqualTo(1);
+    assertThat(verifiedApprovals.get(0).username).isEqualTo(admin.username());
+    assertThat(codeReviewApprovals.get(0).permittedVotingRange).isNull();
+  }
+
+  @Test
   public void checkLabelsForMergedChange() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.status).isEqualTo(MERGED);
     assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
@@ -3593,9 +3757,10 @@
         .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
     assertPermitted(change, LabelId.CODE_REVIEW, 2);
     assertPermitted(change, LabelId.VERIFIED, 0, 1);
+    assertLabelDescription(change, LabelId.VERIFIED, TestLabels.VERIFIED_LABEL_DESCRIPTION);
 
-    // ignore the new label by Prolog submit rule and assert that the label is
-    // no longer returned
+    // Ignore the new label by Prolog submit rule. Permitted ranges are still going to be
+    // returned for the label.
     GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
     testRepo.reset("config");
     PushOneCommit push2 =
@@ -3609,11 +3774,10 @@
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertPermitted(change, LabelId.CODE_REVIEW, 2);
-    assertPermitted(change, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.VERIFIED, 0, 1);
 
-    // add an approval on the new label and assert that the label is now
-    // returned although it is ignored by the Prolog submit rule and hence not
-    // included in the submit records
+    // add an approval on the new label. The label can still be voted +1 although it is ignored
+    // in Prolog. 0 is not permitted because votes cannot be decreased.
     gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().name())
@@ -3622,7 +3786,7 @@
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
     assertPermitted(change, LabelId.CODE_REVIEW, 2);
-    assertPermitted(change, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.VERIFIED, 1);
 
     // remove label and assert that it's no longer returned for existing
     // changes, even if there is an approval for it
@@ -3643,6 +3807,74 @@
   }
 
   @Test
+  @GerritConfig(name = "change.skipCurrentRulesEvaluationOnClosedChanges", value = "true")
+  public void checkLabelsNotUpdatedForMergedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.status).isEqualTo(MERGED);
+    assertThat(change.submissionId).isNotNull();
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
+
+    LabelType verified = TestLabels.verified();
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+
+    // add new label and assert that it's returned for existing changes
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(verified);
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(registeredUsers).range(-1, 1))
+        .update();
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.permittedLabels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.CODE_REVIEW, 2);
+  }
+
+  @Test
+  @GerritConfig(name = "change.skipCurrentRulesEvaluationOnClosedChanges", value = "true")
+  public void checkLabelsNotUpdatedForAbandonedChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).abandon();
+
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.status).isEqualTo(ABANDONED);
+    assertThat(change.labels.keySet()).isEmpty();
+    assertThat(change.submitRecords).isEmpty();
+
+    LabelType verified = TestLabels.verified();
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+
+    // add new label and assert that it's returned for existing changes
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(verified);
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(registeredUsers).range(-1, 1))
+        .update();
+
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertThat(change.labels.keySet()).isEmpty();
+    assertThat(change.permittedLabels.keySet()).isEmpty();
+    assertThat(change.submitRecords).isEmpty();
+  }
+
+  @Test
   public void notifyConfigForDirectoryTriggersEmail() throws Exception {
     // Configure notifications on project level.
     RevCommit oldHead = projectOperations.project(project).getHead("master");
@@ -3730,7 +3962,7 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.status).isEqualTo(MERGED);
     assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet())
         .containsExactly(LabelId.CODE_REVIEW, "Non-Author-Code-Review");
@@ -3747,7 +3979,7 @@
     result.assertOkStatus();
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.status).isEqualTo(MERGED);
     assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
@@ -3764,11 +3996,11 @@
     result.assertOkStatus();
 
     ChangeInfo firstChange = gApi.changes().id(first.getChangeId()).get();
-    assertThat(firstChange.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(firstChange.status).isEqualTo(MERGED);
     assertThat(firstChange.submissionId).isNotNull();
 
     ChangeInfo secondChange = gApi.changes().id(second.getChangeId()).get();
-    assertThat(secondChange.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(secondChange.status).isEqualTo(MERGED);
     assertThat(secondChange.submissionId).isNotNull();
 
     assertThat(secondChange.submissionId).isEqualTo(firstChange.submissionId);
@@ -3924,7 +4156,9 @@
     assertThat(thrown)
         .hasMessageThat()
         .contains("Failed to submit 1 change due to the following problems");
-    assertThat(thrown).hasMessageThat().contains("needs All-Comments-Resolved");
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement 'All-Comments-Resolved' is unsatisfied");
   }
 
   @Test
@@ -4050,938 +4284,6 @@
   }
 
   @Test
-  public void submitRecords() throws Exception {
-    PushOneCommit.Result r = createChange();
-    TestSubmitRule testSubmitRule = new TestSubmitRule();
-    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
-      String changeId = r.getChangeId();
-
-      ChangeInfo change = gApi.changes().id(changeId).get();
-      assertThat(change.submitRecords).hasSize(2);
-      // Check the default submit record for the code-review label
-      SubmitRecordInfo codeReviewRecord = Iterables.get(change.submitRecords, 0);
-      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
-      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.NOT_READY);
-      assertThat(codeReviewRecord.labels).hasSize(1);
-      SubmitRecordInfo.Label label = Iterables.getOnlyElement(codeReviewRecord.labels);
-      assertThat(label.label).isEqualTo("Code-Review");
-      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.NEED);
-      assertThat(label.appliedBy).isNull();
-      // Check the custom test record created by the TestSubmitRule
-      SubmitRecordInfo testRecord = Iterables.get(change.submitRecords, 1);
-      assertThat(testRecord.ruleName).isEqualTo("gerrit~TestSubmitRule");
-      assertThat(testRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
-      assertThat(testRecord.requirements)
-          .containsExactly(new LegacySubmitRequirementInfo("OK", "fallback text", "type"));
-      assertThat(testRecord.labels).hasSize(1);
-      SubmitRecordInfo.Label testLabel = Iterables.getOnlyElement(testRecord.labels);
-      assertThat(testLabel.label).isEqualTo("label");
-      assertThat(testLabel.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
-      assertThat(testLabel.appliedBy).isNull();
-
-      voteLabel(changeId, "code-review", 2);
-      // Code review record is satisfied after voting +2
-      change = gApi.changes().id(changeId).get();
-      assertThat(change.submitRecords).hasSize(2);
-      codeReviewRecord = Iterables.get(change.submitRecords, 0);
-      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
-      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
-      assertThat(codeReviewRecord.labels).hasSize(1);
-      label = Iterables.getOnlyElement(codeReviewRecord.labels);
-      assertThat(label.label).isEqualTo("Code-Review");
-      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
-      assertThat(label.appliedBy._accountId).isEqualTo(admin.id().get());
-    }
-  }
-
-  @Test
-  public void checkSubmitRequirement_satisfied() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    SubmitRequirementInput in =
-        createSubmitRequirementInput(
-            "Code-Review", /* submittabilityExpression= */ "label:Code-Review=+2");
-
-    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
-
-    voteLabel(changeId, "Code-Review", 2);
-    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
-  }
-
-  @Test
-  public void checkSubmitRequirement_notApplicable() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    SubmitRequirementInput in =
-        createSubmitRequirementInput(
-            "Code-Review",
-            /* applicableIf= */ "branch:non-existent",
-            /* submittableIf= */ "label:Code-Review=+2",
-            /* overrideIf= */ null);
-
-    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
-
-    voteLabel(changeId, "Code-Review", 2);
-    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
-  }
-
-  @Test
-  public void checkSubmitRequirement_overridden() throws Exception {
-    configLabel("Override-Label", LabelFunction.NO_OP); // label function has no effect
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("Override-Label")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    SubmitRequirementInput in =
-        createSubmitRequirementInput(
-            "Code-Review",
-            /* applicableIf= */ null,
-            /* submittableIf= */ "label:Code-Review=+2",
-            /* overrideIf= */ "label:Override-Label=+1");
-
-    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
-
-    voteLabel(changeId, "Code-Review", 2);
-    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
-
-    voteLabel(changeId, "Override-Label", 1);
-    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.OVERRIDDEN);
-  }
-
-  @Test
-  public void checkSubmitRequirement_error() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    SubmitRequirementInput in =
-        createSubmitRequirementInput("Code-Review", /* submittabilityExpression= */ "!!!");
-
-    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.ERROR);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withLabelEqualsMax() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:code-review=MAX"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withLabelEqualsMax_fromNonUploader() throws Exception {
-    configLabel("my-label", LabelFunction.NO_OP); // label function has no effect
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
-        .update();
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("my-label")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:my-label=MAX,user=non_uploader"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    // The second requirement is coming from the legacy code-review label function
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    // Voting with a max vote as the uploader will not satisfy the submit requirement.
-    voteLabel(changeId, "my-label", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    // Voting as a non-uploader will satisfy the submit requirement.
-    requestScopeOperations.setApiUser(user.id());
-    voteLabel(changeId, "my-label", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("-label:code-review=MIN"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    // Requirement is satisfied because there are no votes
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-    // Legacy requirement (coming from the label function definition) is not satisfied. We return
-    // both legacy and non-legacy requirements in this case since their statuses are not identical.
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-
-    voteLabel(changeId, "code-review", -1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    // Requirement is still satisfied because -1 is not the max negative value
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-
-    voteLabel(changeId, "code-review", -2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    // Requirement is now unsatisfied because -2 is the max negative value
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withMaxWithBlock_ignoringSelfApproval() throws Exception {
-    configLabel("my-label", LabelFunction.MAX_WITH_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
-        .update();
-
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("my-label")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create(
-                    "label:my-label=MAX,user=non_uploader -label:my-label=MIN"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // Create the change as admin
-    requestScopeOperations.setApiUser(admin.id());
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    // Admin (a.k.a uploader) adds a -1 min vote. This is going to block submission.
-    voteLabel(changeId, "my-label", -1);
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    // The other requirement is coming from the code-review label function
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    // user (i.e. non_uploader) votes 1. Requirement is still blocking because of -1 of uploader.
-    requestScopeOperations.setApiUser(user.id());
-    voteLabel(changeId, "my-label", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    // Admin (a.k.a uploader) removes -1. Now requirement is fulfilled.
-    requestScopeOperations.setApiUser(admin.id());
-    voteLabel(changeId, "my-label", 0);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withLabelEqualsAny() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:code-review=ANY"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    // Legacy and non-legacy requirements have mismatching status. Both are returned from the API.
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
-      throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("verified")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:verified=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "verified", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 2);
-
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "verified", Status.UNSATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsNotFulfilled()
-      throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setApplicabilityExpression(SubmitRequirementExpression.of("project:foo"))
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirementIsOverridden_whenOverrideExpressionIsFulfilled() throws Exception {
-    configLabel("build-cop-override", LabelFunction.NO_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("build-cop-override")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "build-cop-override", 1);
-
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.OVERRIDDEN, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @Ignore("Test is flaky")
-  public void submitRequirement_overriddenInChildProject() throws Exception {
-    configSubmitRequirement(
-        allProjects,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(true)
-            .build());
-
-    // Override submit requirement in child project (requires code-review=+2 instead of +1)
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_inheritedFromParentProject() throws Exception {
-    configSubmitRequirement(
-        allProjects,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_ignoredInChildProject_ifParentDoesNotAllowOverride()
-      throws Exception {
-    configSubmitRequirement(
-        allProjects,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // Override submit requirement in child project (requires code-review=+2 instead of +1).
-    // Will have no effect since parent does not allow override.
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    // +1 was enough to fulfill the requirement: override in child project was ignored
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void submitRequirement_storedForClosedChanges() throws Exception {
-    for (SubmitType submitType : SubmitType.values()) {
-      Project.NameKey project = createProjectForPush(submitType);
-      TestRepository<InMemoryRepository> repo = cloneProject(project);
-      configSubmitRequirement(
-          project,
-          SubmitRequirement.builder()
-              .setName("code-review")
-              .setSubmittabilityExpression(
-                  SubmitRequirementExpression.create("label:code-review=+2"))
-              .setAllowOverrideInChildProjects(false)
-              .build());
-
-      PushOneCommit.Result r =
-          createChange(repo, "master", "Add a file", "foo", "content", "topic");
-      String changeId = r.getChangeId();
-
-      voteLabel(changeId, "code-review", 2);
-
-      ChangeInfo change = gApi.changes().id(changeId).get();
-      assertThat(change.submitRequirements).hasSize(1);
-      assertSubmitRequirementStatus(
-          change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-
-      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-      revision.review(ReviewInput.approve());
-      revision.submit();
-
-      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
-
-      SubmitRequirementResult result =
-          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
-      assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
-      assertThat(result.submittabilityExpressionResult().status())
-          .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
-      assertThat(result.submittabilityExpressionResult().expression().expressionString())
-          .isEqualTo("label:code-review=+2");
-    }
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void submitRequirement_retrievedFromNoteDbForClosedChanges() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "code-review", 2);
-
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-
-    gApi.changes().id(changeId).current().submit();
-
-    // Add new submit requirement
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("verified")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:verified=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // The new "verified" submit requirement is not returned, since this change is closed
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void
-      submitRequirements_returnOneEntryForMatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
-          throws Exception {
-    // Configure a legacy submit requirement: label with a max with block function
-    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("build-cop-override")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    // Configure a submit requirement with the same name.
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("build-cop-override")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create(
-                    "label:build-cop-override=MAX -label:build-cop-override=MIN"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // Create a change. Vote to fulfill all requirements.
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    voteLabel(changeId, "build-cop-override", 1);
-    voteLabel(changeId, "Code-Review", 2);
-
-    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
-    // Only non-legacy bco is returned.
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements,
-        "build-cop-override",
-        Status.SATISFIED,
-        /* isLegacy= */ false,
-        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
-
-    // Merge the change. Submit requirements are still the same.
-    gApi.changes().id(changeId).current().submit();
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements,
-        "build-cop-override",
-        Status.SATISFIED,
-        /* isLegacy= */ false,
-        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void
-      submitRequirements_returnTwoEntriesForMismatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
-          throws Exception {
-    // Configure a legacy submit requirement: label with a max with block function
-    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("build-cop-override")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    // Configure a submit requirement with the same name.
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("build-cop-override")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:build-cop-override=MIN"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // Create a change
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    voteLabel(changeId, "build-cop-override", 1);
-    voteLabel(changeId, "Code-Review", 2);
-
-    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
-    // Two instances of bco will be returned since their status is not matching.
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(3);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements,
-        "build-cop-override",
-        Status.SATISFIED,
-        /* isLegacy= */ true,
-        // MAX_WITH_BLOCK function was translated to a submittability expression.
-        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
-    assertSubmitRequirementStatus(
-        change.submitRequirements,
-        "build-cop-override",
-        Status.UNSATISFIED,
-        /* isLegacy= */ false,
-        /* submittabilityCondition= */ "label:build-cop-override=MIN");
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirements_returnForLegacySubmitRecords_ifEnabled() throws Exception {
-    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("build-cop-override")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    // 1. Project has two legacy requirements: Code-Review and bco. Both unsatisfied.
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "build-cop-override", Status.UNSATISFIED, /* isLegacy= */ true);
-
-    // 2. Vote +1 on bco. bco becomes satisfied
-    voteLabel(changeId, "build-cop-override", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
-
-    // 3. Vote +1 on Code-Review. Code-Review becomes satisfied
-    voteLabel(changeId, "Code-Review", 2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
-
-    // 4. Merge the change. Submit requirements status is presented from NoteDb.
-    gApi.changes().id(changeId).current().submit();
-    change = gApi.changes().id(changeId).get();
-    // Legacy submit records are returned as submit requirements.
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_backFilledFromIndexForActiveChanges() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    voteLabel(changeId, "code-review", 2);
-
-    // Query the change. ChangeInfo is back-filled from the change index.
-    List<ChangeInfo> changeInfos =
-        gApi.changes()
-            .query()
-            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
-            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
-            .get();
-    assertThat(changeInfos).hasSize(1);
-    assertSubmitRequirementStatus(
-        changeInfos.get(0).submitRequirements,
-        "code-review",
-        Status.SATISFIED,
-        /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void submitRequirement_backFilledFromIndexForClosedChanges() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    voteLabel(changeId, "code-review", 2);
-    gApi.changes().id(changeId).current().submit();
-
-    // Query the change. ChangeInfo is back-filled from the change index.
-    List<ChangeInfo> changeInfos =
-        gApi.changes()
-            .query()
-            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
-            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
-            .get();
-    assertThat(changeInfos).hasSize(1);
-    assertSubmitRequirementStatus(
-        changeInfos.get(0).submitRequirements,
-        "code-review",
-        Status.SATISFIED,
-        /* isLegacy= */ false);
-  }
-
-  @Test
-  public void submitRequirements_notServedIfExperimentNotEnabled() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-
-    voteLabel(changeId, "code-review", -1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-
-    voteLabel(changeId, "code-review", 2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-
-    gApi.changes().id(changeId).current().submit();
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-  }
-
-  @Test
   public void fourByteEmoji() throws Exception {
     // U+1F601 GRINNING FACE WITH SMILING EYES
     String smile = new String(Character.toChars(0x1f601));
@@ -5233,7 +4535,7 @@
 
   private void setChangeStatus(Change.Id id, Change.Status newStatus) throws Exception {
     try (BatchUpdate batchUpdate =
-        batchUpdateFactory.create(project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+        batchUpdateFactory.create(project, atrScope.get().getUser(), TimeUtil.now())) {
       batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
       batchUpdate.execute();
     }
@@ -5466,7 +4768,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());
     }
@@ -5523,99 +4825,14 @@
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
 
-  private void assertSubmitRequirementStatus(
-      Collection<SubmitRequirementResultInfo> results,
-      String requirementName,
-      SubmitRequirementResultInfo.Status status,
-      boolean isLegacy,
-      String submittabilityCondition) {
-    for (SubmitRequirementResultInfo result : results) {
-      if (result.name.equals(requirementName)
-          && result.status == status
-          && result.isLegacy == isLegacy
-          && result.submittabilityExpressionResult.expression.equals(submittabilityCondition)) {
-        return;
-      }
-    }
-    throw new AssertionError(
-        String.format(
-            "Could not find submit requirement %s with status %s (results = %s)",
-            requirementName,
-            status,
-            results.stream()
-                .map(r -> String.format("%s=%s", r.name, r.status))
-                .collect(toImmutableList())));
-  }
+  private static class TestCommitValidationListener implements CommitValidationListener {
+    public CommitReceivedEvent receiveEvent;
 
-  private void assertSubmitRequirementStatus(
-      Collection<SubmitRequirementResultInfo> results,
-      String requirementName,
-      SubmitRequirementResultInfo.Status status,
-      boolean isLegacy) {
-    for (SubmitRequirementResultInfo result : results) {
-      if (result.name.equals(requirementName)
-          && result.status == status
-          && result.isLegacy == isLegacy) {
-        return;
-      }
-    }
-    throw new AssertionError(
-        String.format(
-            "Could not find submit requirement %s with status %s (results = %s)",
-            requirementName,
-            status,
-            results.stream()
-                .map(r -> String.format("%s=%s", r.name, r.status))
-                .collect(toImmutableList())));
-  }
-
-  private Project.NameKey createProjectForPush(SubmitType submitType) throws Exception {
-    Project.NameKey project = projectOperations.newProject().submitType(submitType).create();
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
-        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
-        .update();
-    return project;
-  }
-
-  /** Returns a hard-coded submit record containing all fields. */
-  private static class TestSubmitRule implements SubmitRule {
     @Override
-    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
-      SubmitRecord record = new SubmitRecord();
-      record.ruleName = "testSubmitRule";
-      record.status = SubmitRecord.Status.OK;
-      SubmitRecord.Label label = new SubmitRecord.Label();
-      label.label = "label";
-      label.status = SubmitRecord.Label.Status.OK;
-      record.labels = Arrays.asList(label);
-      record.requirements =
-          Arrays.asList(
-              LegacySubmitRequirement.builder()
-                  .setType("type")
-                  .setFallbackText("fallback text")
-                  .build());
-      return Optional.of(record);
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.receiveEvent = receiveEvent;
+      return ImmutableList.of();
     }
   }
-
-  private static SubmitRequirementInput createSubmitRequirementInput(
-      String name, String submittabilityExpression) {
-    SubmitRequirementInput input = new SubmitRequirementInput();
-    input.name = name;
-    input.submittabilityExpression = submittabilityExpression;
-    return input;
-  }
-
-  private static SubmitRequirementInput createSubmitRequirementInput(
-      String name, String applicableIf, String submittableIf, String overrideIf) {
-    SubmitRequirementInput input = new SubmitRequirementInput();
-    input.name = name;
-    input.applicabilityExpression = applicableIf;
-    input.submittabilityExpression = submittableIf;
-    input.overrideExpression = overrideIf;
-    return input;
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java
deleted file mode 100644
index 4d1b032..0000000
--- a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsIT.java
+++ /dev/null
@@ -1,326 +0,0 @@
-// 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.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
-
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.Iterables;
-import com.google.common.util.concurrent.AtomicLongMap;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.PushOneCommit.Result;
-import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.entities.LabelId;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.server.approval.RecursiveApprovalCopier;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.junit.Test;
-
-public class CopyApprovalsIT extends AbstractDaemonTest {
-  @Inject private ProjectOperations projectOperations;
-  @Inject private RecursiveApprovalCopier recursiveApprovalCopier;
-
-  @Override
-  public Module createModule() {
-    return new AbstractModule() {
-      @Override
-      protected void configure() {
-        CopyApprovalsReferenceUpdateListener referenceUpdateListener =
-            new CopyApprovalsReferenceUpdateListener();
-
-        bind(CopyApprovalsReferenceUpdateListener.class).toInstance(referenceUpdateListener);
-        DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
-            .toInstance(referenceUpdateListener);
-      }
-    };
-  }
-
-  @Test
-  public void multipleProjects() throws Exception {
-    Project.NameKey secondProject = projectOperations.newProject().name("secondProject").create();
-    TestRepository<InMemoryRepository> secondRepo = cloneProject(secondProject, admin);
-
-    PushOneCommit.Result change1 = createChange();
-    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.recommend());
-    PushOneCommit.Result change2 = createChange(secondRepo);
-    gApi.changes().id(change2.getChangeId()).current().review(ReviewInput.dislike());
-
-    // these amends are reworks so votes will not be copied.
-    amendChange(change1.getChangeId());
-    amendChange(change1.getChangeId());
-    amendChange(change1.getChangeId());
-
-    amendChange(change2.getChangeId(), "refs/for/master", admin, secondRepo);
-    amendChange(change2.getChangeId(), "refs/for/master", admin, secondRepo);
-    amendChange(change2.getChangeId(), "refs/for/master", admin, secondRepo);
-
-    // votes don't exist on the new patch-set.
-    assertThat(gApi.changes().id(change1.getChangeId()).current().votes()).isEmpty();
-    assertThat(gApi.changes().id(change2.getChangeId()).current().votes()).isEmpty();
-
-    // change the project config to make the vote that was not copied to be copied once we do the
-    // schema upgrade.
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    recursiveApprovalCopier.persistStandalone();
-
-    ApprovalInfo vote1 =
-        Iterables.getOnlyElement(
-            gApi.changes().id(change1.getChangeId()).current().votes().values());
-    assertThat(vote1.value).isEqualTo(1);
-    assertThat(vote1._accountId).isEqualTo(admin.id().get());
-
-    ApprovalInfo vote2 =
-        Iterables.getOnlyElement(
-            gApi.changes().id(change2.getChangeId()).current().votes().values());
-    assertThat(vote2.value).isEqualTo(-1);
-    assertThat(vote2._accountId).isEqualTo(admin.id().get());
-  }
-
-  @Test
-  public void corruptChangeInOneProject_OtherProjectsProcessed() throws Exception {
-    Project.NameKey corruptProject = projectOperations.newProject().name("corruptProject").create();
-    TestRepository<InMemoryRepository> corruptRepo = cloneProject(corruptProject, admin);
-
-    PushOneCommit.Result change1 = createChange();
-    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.recommend());
-    PushOneCommit.Result change2 = createChange(corruptRepo);
-    gApi.changes().id(change2.getChangeId()).current().review(ReviewInput.dislike());
-
-    // these amends are reworks so votes will not be copied.
-    amendChange(change1.getChangeId());
-    amendChange(change2.getChangeId(), "refs/for/master", admin, corruptRepo);
-
-    // votes don't exist on the new patch-set.
-    assertThat(gApi.changes().id(change1.getChangeId()).current().votes()).isEmpty();
-    assertThat(gApi.changes().id(change2.getChangeId()).current().votes()).isEmpty();
-
-    // change the project config to make the vote that was not copied to be copied once we do the
-    // schema upgrade.
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    // make the meta-ref of change2 corrupt by updating it to the commit of the current patch-set
-    ObjectId correctMetaRefObjectId;
-    String metaRef = RefNames.changeMetaRef(change2.getChange().getId());
-    try (TestRepository<InMemoryRepository> serverSideCorruptRepo =
-        new TestRepository<>((InMemoryRepository) repoManager.openRepository(corruptProject))) {
-      RefUpdate ru = forceUpdate(serverSideCorruptRepo, metaRef, change2.getPatchSet().commitId());
-      correctMetaRefObjectId = ru.getOldObjectId();
-
-      try {
-        recursiveApprovalCopier.persistStandalone();
-        fail("Expected exception when a project contains corrupt change");
-      } catch (Exception e) {
-        assertThat(e.getMessage()).contains("check the logs");
-      } finally {
-        // fix the meta-ref by setting it back to its correct objectId
-        forceUpdate(serverSideCorruptRepo, metaRef, correctMetaRefObjectId);
-      }
-    }
-
-    ApprovalInfo vote1 =
-        Iterables.getOnlyElement(
-            gApi.changes().id(change1.getChangeId()).current().votes().values());
-    assertThat(vote1.value).isEqualTo(1);
-    assertThat(vote1._accountId).isEqualTo(admin.id().get());
-  }
-
-  private RefUpdate forceUpdate(
-      TestRepository<InMemoryRepository> repo, String ref, ObjectId newObjectId)
-      throws IOException {
-    RefUpdate ru = repo.getRepository().updateRef(ref);
-    ru.setNewObjectId(newObjectId);
-    ru.forceUpdate();
-    return ru;
-  }
-
-  @Test
-  public void changeWithPersistedVotesNotHarmed() throws Exception {
-    // change the project config to copy all votes
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    PushOneCommit.Result change = createChange();
-    gApi.changes().id(change.getChangeId()).current().review(ReviewInput.recommend());
-    amendChange(change.getChangeId());
-
-    // vote exists on new patch-set.
-    ApprovalInfo vote =
-        Iterables.getOnlyElement(
-            gApi.changes().id(change.getChangeId()).current().votes().values());
-
-    ChangeNotes notes = notesFactory.createChecked(project, change.getChange().getId()).load();
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> multimap1 = notes.getApprovalsWithCopied();
-
-    recursiveApprovalCopier.persist(change.getChange().change());
-
-    ChangeNotes notes2 = notesFactory.createChecked(project, change.getChange().getId()).load();
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> multimap2 =
-        notes2.getApprovalsWithCopied();
-    assertThat(multimap1).containsExactlyEntriesIn(multimap2);
-
-    // the vote hasn't changed.
-    assertThat(
-            Iterables.getOnlyElement(
-                gApi.changes().id(change.getChangeId()).current().votes().values()))
-        .isEqualTo(vote);
-  }
-
-  @Test
-  public void multipleChanges() throws Exception {
-    List<Result> changes = new ArrayList<>();
-
-    // The test also passes with 1000, but we replaced this number to 5 to speed up the test.
-    for (int i = 0; i < 5; i++) {
-      PushOneCommit.Result change = createChange();
-      gApi.changes().id(change.getChangeId()).current().review(ReviewInput.recommend());
-
-      // this amend is a rework so votes will not be copied.
-      amendChange(change.getChangeId());
-
-      changes.add(change);
-
-      // votes don't exist on the new patch-set for all changes.
-      assertThat(gApi.changes().id(change.getChangeId()).current().votes()).isEmpty();
-    }
-
-    // change the project config to make the vote that was not copied to be copied once we do the
-    // schema upgrade.
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    recursiveApprovalCopier.persist(project, null);
-
-    for (PushOneCommit.Result change : changes) {
-      ApprovalInfo vote1 =
-          Iterables.getOnlyElement(
-              gApi.changes().id(change.getChangeId()).current().votes().values());
-      assertThat(vote1.value).isEqualTo(1);
-      assertThat(vote1._accountId).isEqualTo(admin.id().get());
-    }
-  }
-
-  @Test
-  public void refUpdateNotified() throws Exception {
-    PushOneCommit.Result change = createChange();
-    gApi.changes().id(change.getChangeId()).current().review(ReviewInput.recommend());
-
-    // this amend is a rework so votes will not be copied.
-    amendChange(change.getChangeId());
-
-    // votes don't exist on the new patch-set for all changes.
-    assertThat(gApi.changes().id(change.getChangeId()).current().votes()).isEmpty();
-
-    // change the project config to make the vote that was not copied to be copied once we do the
-    // schema upgrade.
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    ObjectId metaId = change.getChange().notes().getMetaId();
-    recursiveApprovalCopier.persist(project, null);
-
-    ApprovalInfo vote1 =
-        Iterables.getOnlyElement(
-            gApi.changes().id(change.getChangeId()).current().votes().values());
-    assertThat(vote1.value).isEqualTo(1);
-    assertThat(vote1._accountId).isEqualTo(admin.id().get());
-
-    CopyApprovalsReferenceUpdateListener testListener = testListener();
-    assertThat(testListener.refUpdateFor(metaId)).isTrue();
-  }
-
-  @Test
-  public void oneCorruptChange_otherChangesProcessed() throws Exception {
-    PushOneCommit.Result good = createChange();
-    gApi.changes().id(good.getChangeId()).current().review(ReviewInput.recommend());
-    // this amend is a rework so votes will not be copied.
-    amendChange(good.getChangeId());
-
-    PushOneCommit.Result corrupt = createChange();
-
-    // change the project config to make the vote that was not copied to be copied once we do the
-    // schema upgrade.
-    try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
-    // make the meta-ref corrupt by updating it to the commit of the current patch-set
-    String metaRef = RefNames.changeMetaRef(corrupt.getChange().getId());
-    try (TestRepository<InMemoryRepository> serverSideTestRepo =
-        new TestRepository<>((InMemoryRepository) repoManager.openRepository(project))) {
-      RefUpdate ru = forceUpdate(serverSideTestRepo, metaRef, corrupt.getPatchSet().commitId());
-      try {
-        recursiveApprovalCopier.persist(project, null);
-      } finally {
-        forceUpdate(serverSideTestRepo, metaRef, ru.getOldObjectId());
-      }
-    }
-
-    ApprovalInfo vote1 =
-        Iterables.getOnlyElement(gApi.changes().id(good.getChangeId()).current().votes().values());
-    assertThat(vote1.value).isEqualTo(1);
-    assertThat(vote1._accountId).isEqualTo(admin.id().get());
-  }
-
-  private CopyApprovalsReferenceUpdateListener testListener() {
-    return server.getTestInjector().getInstance(CopyApprovalsReferenceUpdateListener.class);
-  }
-
-  private static class CopyApprovalsReferenceUpdateListener implements GitReferenceUpdatedListener {
-    private final AtomicLongMap<String> countsByOldObjectId = AtomicLongMap.create();
-
-    @Override
-    public void onGitReferenceUpdated(Event event) {
-      String oldObjectId = event.getOldObjectId();
-      countsByOldObjectId.incrementAndGet(oldObjectId);
-    }
-
-    boolean refUpdateFor(ObjectId metaRef) {
-      return countsByOldObjectId.containsKey(metaRef.getName());
-    }
-  }
-}
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/PluginOperatorsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
index f4cf96d..3ffbda1 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -46,7 +47,11 @@
 
     String oddChangeId = createChange().getChangeId();
     String evenChangeId = createChange().getChangeId();
-    assertThat(getChanges(queryChanges)).hasSize(0);
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> getChanges(queryChanges));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("Unrecognized value: changeNumberEven_myplugin");
 
     try (AutoCloseable ignored = installPlugin("myplugin", IsOperatorModule.class)) {
       List<?> changes = getChanges(queryChanges);
@@ -57,8 +62,6 @@
       assertThat(outputChangeId).isEqualTo(evenChangeId);
       assertThat(outputChangeId).isNotEqualTo(oddChangeId);
     }
-
-    assertThat(getChanges(queryChanges)).hasSize(0);
   }
 
   protected static class IsOperatorModule extends AbstractModule {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 96bc65d..dcd8f77f 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;
@@ -83,6 +84,8 @@
 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;
@@ -117,6 +120,7 @@
           COMMENT_TEXT.length());
 
   @Captor private ArgumentCaptor<ImmutableList<CommentForValidation>> captor;
+  @Captor private ArgumentCaptor<CommentValidationContext> captorCtx;
 
   private static final Correspondence<CommentForValidation, CommentForValidation>
       COMMENT_CORRESPONDENCE =
@@ -152,7 +156,7 @@
   @Test
   public void validateCommentsInInput_commentOK() throws Exception {
     PushOneCommit.Result r = createChange();
-    when(mockCommentValidator.validateComments(eq(contextFor(r)), captor.capture()))
+    when(mockCommentValidator.validateComments(captorCtx.capture(), captor.capture()))
         .thenReturn(ImmutableList.of());
 
     ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
@@ -165,6 +169,7 @@
 
     assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION, FILE_COMMENT_FOR_VALIDATION);
     assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1);
+    assertThat(captorCtx.getAllValues()).containsExactly(contextFor(r));
   }
 
   @Test
@@ -631,8 +636,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);
@@ -985,7 +997,9 @@
 
   private static CommentValidationContext contextFor(PushOneCommit.Result result) {
     return CommentValidationContext.create(
-        result.getChange().getId().get(), result.getChange().project().get());
+        result.getChange().getId().get(),
+        result.getChange().project().get(),
+        result.getChange().change().getDest().branch());
   }
 
   private void assertValidatorCalledWith(CommentForValidation... commentsForValidation) {
@@ -1082,4 +1096,10 @@
           .collect(toImmutableSet());
     }
   }
+
+  private static void assertAttentionSet(
+      ImmutableSet<AttentionSetUpdate> attentionSet, Account.Id... accounts) {
+    assertThat(attentionSet.stream().map(AttentionSetUpdate::account).collect(Collectors.toList()))
+        .containsExactly(accounts);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
index 97b7148..267f5a7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
@@ -251,7 +251,7 @@
   private void markMergedChangePrivate(Change.Id changeId) throws Exception {
     try (BatchUpdate u =
         batchUpdateFactory.create(
-            project, identifiedUserFactory.create(admin.id()), TimeUtil.nowTs())) {
+            project, identifiedUserFactory.create(admin.id()), TimeUtil.now())) {
       u.addOp(
               changeId,
               new BatchUpdateOp() {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 31381dd..3bfb573 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -371,7 +371,7 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    for (AccessSection s : ImmutableList.copyOf(cfg.getAccessSections())) {
+    for (AccessSection s : cfg.getAccessSections()) {
       if (s.getName().startsWith("refs/heads/")
           || s.getName().startsWith("refs/for/")
           || s.getName().equals("refs/*")) {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 1f8ea65..407d04e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -80,7 +80,9 @@
     assertThat(thrown)
         .hasMessageThat()
         .contains("Failed to submit 1 change due to the following problems");
-    assertThat(thrown).hasMessageThat().contains("needs Is-Pure-Revert");
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement 'Is-Pure-Revert' is unsatisfied.");
   }
 
   @Test
@@ -101,7 +103,9 @@
     assertThat(thrown)
         .hasMessageThat()
         .contains("Failed to submit 1 change due to the following problems");
-    assertThat(thrown).hasMessageThat().contains("needs Is-Pure-Revert");
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement 'Is-Pure-Revert' is unsatisfied.");
   }
 
   @Test
@@ -705,6 +709,43 @@
   }
 
   @Test
+  public void revertSubmissionSuppressNotifications() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    approve(firstResult);
+    gApi.changes().id(firstResult).addReviewer(user.email());
+    String secondResult = createChange("second change", "b.txt", "other").getChangeId();
+    approve(secondResult);
+    gApi.changes().id(secondResult).addReviewer(user.email());
+
+    gApi.changes().id(secondResult).current().submit();
+
+    sender.clear();
+    RevertInput revertInput = new RevertInput();
+    revertInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(secondResult).revertSubmission(revertInput);
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void revertSubmissionSuppressNotificationsWithWip() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    approve(firstResult);
+    gApi.changes().id(firstResult).addReviewer(user.email());
+    String secondResult = createChange("second change", "b.txt", "other").getChangeId();
+    approve(secondResult);
+    gApi.changes().id(secondResult).addReviewer(user.email());
+
+    gApi.changes().id(secondResult).current().submit();
+
+    sender.clear();
+    RevertInput revertInput = new RevertInput();
+    revertInput.workInProgress = true;
+    revertInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(secondResult).revertSubmission(revertInput);
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void revertSubmissionWipNotificationsWithNotifyHandlingAll() throws Exception {
     String changeId1 = createChange("first change", "a.txt", "message").getChangeId();
     approve(changeId1);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 3888679..8dbef88 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -29,42 +29,60 @@
 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;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.MoreCollectors;
 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;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelFunction;
 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.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.client.ChangeKind;
 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;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
+import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 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;
@@ -72,9 +90,11 @@
 @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;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Inject
   @Named("change_kind")
@@ -91,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());
 
@@ -127,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"));
@@ -149,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"));
@@ -214,36 +213,18 @@
   }
 
   @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();
-    }
-
-    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);
-
-      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 2, 0, changeKind);
-      assertVotes(c, user, -2, 0, changeKind);
-    }
+  public void stickyOnMaxScore_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyMaxScore(true));
+    testStickyOnMaxScore();
   }
 
   @Test
-  public void stickyOnMaxScore() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
-      u.save();
-    }
+  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"));
@@ -260,15 +241,19 @@
   }
 
   @Test
-  public void stickyOnCopyValues() throws Exception {
-    TestAccount user2 = accountCreator.user2();
+  public void stickyOnCopyValues_withoutCopyCondition() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
+    testStickyOnCopyValues();
+  }
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
-      u.save();
-    }
+  @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)) {
@@ -288,13 +273,18 @@
   }
 
   @Test
-  public void stickyOnTrivialRebase() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnTrivialRebase(true));
-      u.save();
-    }
+  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);
@@ -336,12 +326,18 @@
   }
 
   @Test
-  public void stickyOnNoCodeChange() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
+  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);
@@ -360,14 +356,19 @@
   }
 
   @Test
-  public void stickyOnMergeFirstParentUpdate() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
-      u.save();
-    }
+  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);
@@ -386,12 +387,51 @@
   }
 
   @Test
-  public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfNoChange(true));
-      u.save();
-    }
+  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);
@@ -406,27 +446,18 @@
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
   }
 
   @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();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
   }
 
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
+  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
       throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
@@ -450,29 +481,19 @@
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
   }
 
   @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();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
   }
 
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
+  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
       throws Exception {
     // create "existing file" and submit it.
     String existingFile = "existing file";
@@ -508,28 +529,19 @@
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
   }
 
   @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();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
   }
 
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
+  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
       throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
@@ -548,25 +560,51 @@
   public void
       stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
+    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 {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
+    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");
@@ -598,59 +636,26 @@
   }
 
   @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();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
   }
 
+  @Test
   @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();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
   }
 
-  private void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
-      throws Exception {
+  private void
+      testStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
+          throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -669,29 +674,20 @@
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
   }
 
   @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();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
   }
 
   private void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset()
+      testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset()
           throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
@@ -716,28 +712,19 @@
   public void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withoutCopyCondition()
           throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
   }
 
   @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();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
   }
 
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
+  private void testNotStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
       throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
@@ -753,156 +740,18 @@
   }
 
   @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();
+    updateCodeReviewLabel(b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+    testCopyWithListOfFilesUnchanged();
   }
 
   @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();
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+    testCopyWithListOfFilesUnchanged();
   }
 
-  private void copyWithListOfFilesUnchanged() throws Exception {
+  private void testCopyWithListOfFilesUnchanged() throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -949,33 +798,36 @@
     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 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();
+  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");
 
-    // Make a Code-Review vote that should be sticky.
-    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    // Add a Code-Review+2 vote.
+    approve(changeId.toString());
 
+    // Create a new patch set with a non-merge commit.
     changeOperations
         .change(changeId)
         .newPatchset()
@@ -983,25 +835,310 @@
         .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
 
-    // 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);
+    // 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 deleteStickyVote() throws Exception {
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyMaxScore(true));
-      u.save();
+  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);
@@ -1016,14 +1153,18 @@
   }
 
   @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();
-    }
+  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.
@@ -1043,14 +1184,18 @@
   }
 
   @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();
-    }
+  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);
@@ -1084,14 +1229,18 @@
   }
 
   @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();
-    }
+  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");
@@ -1119,7 +1268,18 @@
   }
 
   @Test
-  public void stickyVoteStoredOnUploadWithRealAccount() throws Exception {
+  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)
@@ -1132,13 +1292,6 @@
                 .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
@@ -1173,7 +1326,19 @@
   }
 
   @Test
-  public void stickyVoteStoredOnUploadWithRealAccountAndTag() throws Exception {
+  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)
@@ -1186,13 +1351,6 @@
                 .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
@@ -1230,14 +1388,18 @@
   }
 
   @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();
-    }
+  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
@@ -1246,7 +1408,7 @@
 
     // Make a new patchset, keeping the Code-Review +2 vote.
     amendChange(r.getChangeId());
-    assertVotes(detailedChange(r.getChangeId()), admin, label, 2, null);
+    assertVotes(detailedChange(r.getChangeId()), admin, LabelId.CODE_REVIEW, 2, null);
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
 
@@ -1265,6 +1427,162 @@
     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().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");
+  }
+
+  @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)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, -2, -1);
+
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 0, changeKind);
+      assertVotes(c, user, -2, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void sticky_copiedToLatestPatchSetFromSubmitRecords() throws Exception {
+    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
+    // verifies that for such changes copied approvals are returned from the API even if the copied
+    // votes were not persisted as Copied-Label footers.
+    //
+    // In other words, this test verifies that given a change that was approved by a copied vote and
+    // then submitted and for which the copied approval is not persisted as a Copied-Label footer in
+    // NoteDb the copied approval is backfilled from the corresponding Submitted-With footer that
+    // got written to NoteDb on submit.
+    //
+    // Creating such a change would be possible by running the old Gerrit code from before Gerrit
+    // persisted copied labels as Copied-Label footers. However since this old Gerrit code is no
+    // longer available, the test needs to apply a trick to create a change in this state. It
+    // configures a fake submit rule, that pretends that an approval for a non-sticky label from an
+    // old patch set is still present on the current patch set and allows to submit the change.
+    // Since the label is non-sticky no Copied-Label footer is written for it. On submit the fake
+    // submit rule results in a Submitted-With footer that records the label as approved (although
+    // the label is actually not present on the current patch set). This is exactly the change state
+    // that we would have had by running the old code if submit was based on a copied label. As
+    // result of the backfilling logic we expect that this "copied" label (the label that is
+    // mentioned in the Submitted-With footer) is returned from the API.
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(new TestSubmitRule(user.id()))) {
+      // We want to add a vote on PS1, then not copy it to PS2, but include it in submit records
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Vote on patch-set 1
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, -1);
+
+      // Upload patch-set 2. Change user's "Verified" vote on PS2.
+      changeOperations
+          .change(Change.id(r.getChange().getId().get()))
+          .newPatchset()
+          .file("new_file")
+          .content("content")
+          .commitMessage("Upload PS2")
+          .create();
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, 1);
+
+      // Upload patch-set 3
+      changeOperations
+          .change(Change.id(r.getChange().getId().get()))
+          .newPatchset()
+          .file("another_file")
+          .content("content")
+          .commitMessage("Upload PS3")
+          .create();
+      vote(admin, changeId, 2, 1);
+
+      List<PatchSetApproval> patchSetApprovals =
+          notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
+              .stream()
+              .sorted(comparing(a -> a.patchSetId().get()))
+              .collect(toImmutableList());
+
+      // There's no verified approval on PS#3.
+      assertThat(
+              patchSetApprovals.stream()
+                  .filter(
+                      a ->
+                          a.accountId().equals(user.id())
+                              && a.label().equals(TestLabels.verified().getName())
+                              && a.patchSetId().get() == 3)
+                  .collect(Collectors.toList()))
+          .isEmpty();
+
+      // Submit the change. The TestSubmitRule will store a "submit record" containing a label
+      // voted by user, but the latest patch-set does not have an approval for this user, hence
+      // it will be copied if we request approvals after the change is merged.
+      requestScopeOperations.setApiUser(admin.id());
+      gApi.changes().id(changeId).current().submit();
+
+      patchSetApprovals =
+          notesFactory.create(project, r.getChange().getId()).getApprovalsWithCopied().values()
+              .stream()
+              .sorted(comparing(a -> a.patchSetId().get()))
+              .collect(toImmutableList());
+
+      // Get the copied approval for user on PS3 for the "Verified" label.
+      PatchSetApproval verifiedApproval =
+          patchSetApprovals.stream()
+              .filter(
+                  a ->
+                      a.accountId().equals(user.id())
+                          && a.label().equals(TestLabels.verified().getName())
+                          && a.patchSetId().get() == 3)
+              .collect(MoreCollectors.onlyElement());
+
+      assertCopied(
+          verifiedApproval,
+          /* psId= */ 3,
+          TestLabels.verified().getName(),
+          (short) 1,
+          /* copied= */ true);
+    }
+  }
+
   private void assertChangeKindCacheContains(ObjectId prior, ObjectId next) {
     ChangeKind kind =
         changeKindCache.getIfPresent(ChangeKindCacheImpl.Key.create(prior, next, "recursive"));
@@ -1352,4 +1670,44 @@
     assertThat(approval.value()).isEqualTo(value);
     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}.
+   */
+  private static class TestSubmitRule implements SubmitRule {
+    Account.Id userAccountId;
+
+    TestSubmitRule(Account.Id userAccountId) {
+      this.userAccountId = userAccountId;
+    }
+
+    @Override
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      SubmitRecord record = new SubmitRecord();
+      record.ruleName = "testSubmitRule";
+      record.status = SubmitRecord.Status.OK;
+      SubmitRecord.Label label = new SubmitRecord.Label();
+      label.label = "Verified";
+      label.status = SubmitRecord.Label.Status.OK;
+      label.appliedBy = userAccountId;
+      record.labels = Arrays.asList(label);
+      return Optional.of(record);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java
similarity index 84%
rename from javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
rename to javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java
index 96db71a..9ceb6bf 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -29,7 +28,6 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
@@ -41,7 +39,7 @@
 import java.util.concurrent.atomic.AtomicInteger;
 import org.junit.Test;
 
-public class ChangeSubmitRequirementIT extends AbstractDaemonTest {
+public class SubmitRequirementCustomRuleIT extends AbstractDaemonTest {
   private static final LegacySubmitRequirement req =
       LegacySubmitRequirement.builder()
           .setType("custom_rule")
@@ -203,37 +201,6 @@
     assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
   }
 
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD,
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS
-      })
-  public void submitRuleIsInvokedWhenQueryingChangeWithExperiment() throws Exception {
-    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
-    String changeId = r.getChangeId();
-
-    rule.numberOfEvaluations.set(0);
-    gApi.changes().query(changeId).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get();
-
-    // Submit rules are invoked
-    assertThat(rule.numberOfEvaluations.get()).isEqualTo(1);
-  }
-
-  @Test
-  public void submitRuleIsNotInvokedWhenQueryingChangeWithoutExperiment() throws Exception {
-    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
-    String changeId = r.getChangeId();
-
-    rule.numberOfEvaluations.set(0);
-    gApi.changes().query(changeId).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get();
-
-    // Submit rules are not invoked
-    assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
-  }
-
   private List<ChangeInfo> queryIsSubmittable() throws Exception {
     return gApi.changes().query("is:submittable project:" + project.get()).get();
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
new file mode 100644
index 0000000..865dd6c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -0,0 +1,2903 @@
+// 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.change;
+
+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.allow;
+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.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+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.collect.MoreCollectors;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseTimezone;
+import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
+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;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LegacySubmitRequirement;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+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;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRecordInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementExpressionInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+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.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.junit.Test;
+
+@NoHttpd
+@UseTimezone(timezone = "US/Eastern")
+@VerifyNoPiiInChangeNotes(true)
+public class SubmitRequirementIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private IndexOperations.Change changeIndexOperations;
+
+  @Test
+  public void submitRecords() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestSubmitRule testSubmitRule = new TestSubmitRule();
+    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
+      String changeId = r.getChangeId();
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRecords).hasSize(2);
+      // Check the default submit record for the code-review label
+      SubmitRecordInfo codeReviewRecord = Iterables.get(change.submitRecords, 0);
+      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
+      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.NOT_READY);
+      assertThat(codeReviewRecord.labels).hasSize(1);
+      SubmitRecordInfo.Label label = Iterables.getOnlyElement(codeReviewRecord.labels);
+      assertThat(label.label).isEqualTo("Code-Review");
+      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.NEED);
+      assertThat(label.appliedBy).isNull();
+      // Check the custom test record created by the TestSubmitRule
+      SubmitRecordInfo testRecord = Iterables.get(change.submitRecords, 1);
+      assertThat(testRecord.ruleName).isEqualTo("testSubmitRule");
+      assertThat(testRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
+      assertThat(testRecord.requirements)
+          .containsExactly(new LegacySubmitRequirementInfo("OK", "fallback text", "type"));
+      assertThat(testRecord.labels).hasSize(1);
+      SubmitRecordInfo.Label testLabel = Iterables.getOnlyElement(testRecord.labels);
+      assertThat(testLabel.label).isEqualTo("label");
+      assertThat(testLabel.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
+      assertThat(testLabel.appliedBy).isNull();
+
+      voteLabel(changeId, "Code-Review", 2);
+      // Code review record is satisfied after voting +2
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRecords).hasSize(2);
+      codeReviewRecord = Iterables.get(change.submitRecords, 0);
+      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
+      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
+      assertThat(codeReviewRecord.labels).hasSize(1);
+      label = Iterables.getOnlyElement(codeReviewRecord.labels);
+      assertThat(label.label).isEqualTo("Code-Review");
+      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
+      assertThat(label.appliedBy._accountId).isEqualTo(admin.id().get());
+    }
+  }
+
+  @Test
+  public void checkSubmitRequirement_satisfied() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review", /* submittabilityExpression= */ "label:Code-Review=+2");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
+  public void checkSubmitRequirement_notApplicable() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review",
+            /* applicableIf= */ "branch:non-existent",
+            /* submittableIf= */ "label:Code-Review=+2",
+            /* overrideIf= */ null);
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void checkSubmitRequirement_overridden() throws Exception {
+    configLabel("Override-Label", LabelFunction.NO_OP); // label function has no effect
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Override-Label")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review",
+            /* applicableIf= */ null,
+            /* submittableIf= */ "label:Code-Review=+2",
+            /* overrideIf= */ "label:Override-Label=+1");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+
+    voteLabel(changeId, "Override-Label", 1);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.OVERRIDDEN);
+  }
+
+  @Test
+  public void checkSubmitRequirement_error() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput("Code-Review", /* submittabilityExpression= */ "!!!");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.ERROR);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsMax() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsMax_fromNonUploader() throws Exception {
+    configLabel("my-label", LabelFunction.NO_OP); // label function has no effect
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
+        .update();
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("my-label")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:my-label=MAX,user=non_uploader"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    // The second requirement is coming from the legacy code-review label function
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // Voting with a max vote as the uploader will not satisfy the submit requirement.
+    voteLabel(changeId, "my-label", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // Voting as a non-uploader will satisfy the submit requirement.
+    requestScopeOperations.setApiUser(user.id());
+    voteLabel(changeId, "my-label", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("-label:Code-Review=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Requirement is satisfied because there are no votes
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement (coming from the label function definition) is not satisfied. We return
+    // both legacy and non-legacy requirements in this case since their statuses are not identical.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    voteLabel(changeId, "Code-Review", -1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Requirement is still satisfied because -1 is not the max negative value
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    voteLabel(changeId, "Code-Review", -2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Requirement is now unsatisfied because -2 is the max negative value
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_withMaxWithBlock_ignoringSelfApproval() throws Exception {
+    configLabel("my-label", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("my-label")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(
+                    "label:my-label=MAX,user=non_uploader -label:my-label=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Create the change as admin
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Admin (a.k.a uploader) adds a -1 min vote. This is going to block submission.
+    voteLabel(changeId, "my-label", -1);
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    // The other requirement is coming from the code-review label function
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // user (i.e. non_uploader) votes 1. Requirement is still blocking because of -1 of uploader.
+    requestScopeOperations.setApiUser(user.id());
+    voteLabel(changeId, "my-label", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // Admin (a.k.a uploader) removes -1. Now requirement is fulfilled.
+    requestScopeOperations.setApiUser(admin.id());
+    voteLabel(changeId, "my-label", 0);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsAny() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=ANY"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Legacy and non-legacy requirements have mismatching status. Both are returned from the API.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Verified")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Verified=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsNotFulfilled()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("project:foo"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirementIsOverridden_whenOverrideExpressionIsFulfilled() throws Exception {
+    configLabel("build-cop-override", LabelFunction.NO_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "build-cop-override", 1);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_overriddenInChildProjectWithStricterRequirement() throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+2 instead of +1)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_overriddenInChildProjectWithLessStrictRequirement()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+1 instead of +2)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement: override in child project applies
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement that is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_overriddenInChildProjectAsDisabled() throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Custom-Requirement")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+1 instead of +2)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Custom-Requirement")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("is:false"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:false"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "Custom-Requirement",
+        Status.NOT_APPLICABLE,
+        /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_inheritedFromParentProject() throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_overriddenSRInParentProjectIsInheritedByChildProject()
+      throws Exception {
+    // Define submit requirement in root project.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in parent project (requires Code-Review=+2 instead of +1).
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    Project.NameKey child = createProjectOverAPI("child", project, true, /* submitType= */ null);
+    TestRepository<InMemoryRepository> childRepo = cloneProject(child);
+    PushOneCommit.Result r = createChange(childRepo);
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_ignoredInChildProject_ifParentDoesNotAllowOverride()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+2 instead of +1).
+    // Will have no effect since parent does not allow override.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement: override in child project was ignored
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_ignoredInChildProject_ifParentAddsSRThatDoesNotAllowOverride()
+      throws Exception {
+    // Submit requirement in child project (requires Code-Review=+1)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement that is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    // Add stricter non-overridable submit requirement in parent project (requires Code-Review=+2,
+    // instead of Code-Review=+1)
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_ignoredInChildProject_ifParentMakesSRNonOverridable()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+1 instead of +2)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement: override in child project applies
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement that is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    // Disallow overriding the submit requirement in the parent project.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_ignoredInGrandChildProject_ifGrandParentDoesNotAllowOverride()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    Project.NameKey grandChild =
+        createProjectOverAPI("grandChild", project, true, /* submitType= */ null);
+
+    // Override submit requirement in grand child project (requires Code-Review=+2 instead of +1).
+    // Will have no effect since grand parent does not allow override.
+    configSubmitRequirement(
+        grandChild,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    TestRepository<InMemoryRepository> grandChildRepo = cloneProject(grandChild);
+    PushOneCommit.Result r = createChange(grandChildRepo);
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement: override in grand child project was ignored
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_overrideOverideExpression() throws Exception {
+    // Define submit requirement in root project.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Create Code-Review-Override label
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = "NoOp";
+    input.values = ImmutableMap.of("+1", "Override", " 0", "No Override");
+    gApi.projects().name(project.get()).label("Code-Review-Override").create(input).get();
+
+    // Allow to vote on the Code-Review-Override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Code-Review-Override")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    // Override submit requirement in project (requires Code-Review-Override+1 as override instead
+    // of build-cop-override+1).
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:Code-Review-Override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review-Override", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Code-Review-Override+1 was enough to fulfill the override expression of the requirement
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirementThatOverridesParentSubmitRequirementTakesEffectImmediately()
+      throws Exception {
+    // Define submit requirement in root project that ignores self approvals from the uploader.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX,user=non_uploader"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Apply a self approval from the uploader.
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Code-Review+2 is ignored since it's a self approval from the uploader
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Already satisfied since it
+    // doesn't ignore self approvals.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+    // since the change is not submittable we expect the submit action to be not returned
+    assertThat(gApi.changes().id(changeId).current().actions()).doesNotContainKey("submit");
+
+    // Override submit requirement in project (allow uploaders to self approve).
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // the self approval from the uploader is no longer ignored, hence the submit requirement is
+    // satisfied now
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    // since the change is submittable now we expect the submit action to be returned
+    Map<String, ActionInfo> actions = gApi.changes().id(changeId).current().actions();
+    assertThat(actions).containsKey("submit");
+    ActionInfo submitAction = actions.get("submit");
+    assertThat(submitAction.enabled).isTrue();
+  }
+
+  @Test
+  public void
+      submitRequirementThatOverridesParentSubmitRequirementTakesEffectImmediately_staleIndex()
+          throws Exception {
+    // Define submit requirement in root project that ignores self approvals from the uploader.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX,user=non_uploader"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Apply a self approval from the uploader.
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Code-Review+2 is ignored since it's a self approval from the uploader
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Already satisfied since it
+    // doesn't ignore self approvals.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+    // since the change is not submittable we expect the submit action to be not returned
+    assertThat(gApi.changes().id(changeId).current().actions()).doesNotContainKey("submit");
+
+    // disable change index writes so that the change in the index gets stale when the new submit
+    // requirement is added
+    try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
+      // Override submit requirement in project (allow uploaders to self approve).
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(
+                  SubmitRequirementExpression.create("label:Code-Review=MAX"))
+              .setAllowOverrideInChildProjects(true)
+              .build());
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      // the self approval from the uploader is no longer ignored, hence the submit requirement is
+      // satisfied now
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      // since the change is submittable now we expect the submit action to be returned
+      Map<String, ActionInfo> actions = gApi.changes().id(changeId).current().actions();
+      assertThat(actions).containsKey("submit");
+      ActionInfo submitAction = actions.get("submit");
+      assertThat(submitAction.enabled).isTrue();
+    }
+  }
+
+  @Test
+  public void submitRequirement_partiallyOverriddenSRIsIgnored() throws Exception {
+    // Create build-cop-override label
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = "NoOp";
+    input.values = ImmutableMap.of("+1", "Override", " 0", "No Override");
+    gApi.projects().name(project.get()).label("build-cop-override").create(input).get();
+
+    // Allow to vote on the build-cop-override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("build-cop-override")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    // Define submit requirement in root project.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Create Code-Review-Override label
+    gApi.projects().name(project.get()).label("Code-Review-Override").create(input).get();
+
+    // Allow to vote on the Code-Review-Override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Code-Review-Override")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    // Override submit requirement in project (requires Code-Review-Override+1 as override instead
+    // of build-cop-override+1), but do not set all required properties (submittability expression
+    // is missing). We update the project.config file directly in the remote repository, since
+    // trying to push such a submit requirement would be rejected by the commit validation.
+    projectOperations
+        .project(project)
+        .forInvalidation()
+        .addProjectConfigUpdater(
+            config ->
+                config.setString(
+                    ProjectConfig.SUBMIT_REQUIREMENT,
+                    "Code-Review",
+                    ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+                    "label:Code-Review-Override=+1"))
+        .invalidate();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review-Override", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // The override expression in the project is satisfied, but it's ignored since the SR is
+    // incomplete.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "build-cop-override", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // The submit requirement is overridden now (the override expression in the child project is
+    // ignored)
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_storedForClosedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      voteLabel(changeId, "Code-Review", 2);
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+
+      SubmitRequirementResult result =
+          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+      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);
+    }
+  }
+
+  @Test
+  public void submitRequirement_storedForAbandonedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      voteLabel(changeId, "Code-Review", 2);
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      gApi.changes().id(r.getChangeId()).abandon();
+      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+      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");
+    }
+  }
+
+  @Test
+  public void submitRequirement_loadedFromTheLatestRevisionNoteForClosedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Restore the change.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Upload a second patch-set, fulfill the CR submit requirement.
+      amendChange(changeId, "refs/for/master", user, repo);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.revisions).hasSize(2);
+      voteLabel(changeId, "Code-Review", 2);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      // Abandon the change.
+      gApi.changes().id(changeId).abandon();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void submitRequirement_abandonRestoreUpdateMerge() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Restore the change.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Update the change.
+      amendChange(changeId, "refs/for/master", user, repo);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.revisions).hasSize(2);
+      voteLabel(changeId, "Code-Review", 2);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      // Merge the change.
+      gApi.changes().id(changeId).current().submit();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void submitRequirement_returnsEmpty_forAbandonedChangeWithPreviouslyStoredSRs()
+      throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Clear SRs for the project and update code-review label to be non-blocking.
+      clearSubmitRequirements(project);
+      LabelType cr =
+          TestLabels.codeReview().toBuilder().setFunction(LabelFunction.NO_BLOCK).build();
+      try (ProjectConfigUpdate u = updateProject(project)) {
+        u.getConfig().upsertLabelType(cr);
+        u.save();
+      }
+
+      // Restore the change. No SRs apply.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).isEmpty();
+
+      // Abandon the change. Still, no SRs apply.
+      gApi.changes().id(changeId).abandon();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).isEmpty();
+    }
+  }
+
+  @Test
+  public void submitRequirement_returnsEmpty_forMergedChangeWithPreviouslyStoredSRs()
+      throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Clear SRs for the project and update code-review label to be non-blocking.
+      clearSubmitRequirements(project);
+      LabelType cr =
+          TestLabels.codeReview().toBuilder().setFunction(LabelFunction.NO_BLOCK).build();
+      try (ProjectConfigUpdate u = updateProject(project)) {
+        u.getConfig().upsertLabelType(cr);
+        u.save();
+      }
+
+      // Restore the change. No SRs apply.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).isEmpty();
+
+      // Merge the change. Still, no SRs apply.
+      gApi.changes().id(changeId).current().submit();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).isEmpty();
+    }
+  }
+
+  @Test
+  public void submitRequirement_withMultipleAbandonAndRestore() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Restore the change.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Abandon the change again.
+      gApi.changes().id(changeId).abandon();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Restore, vote CR=+2, and abandon again. Make sure the requirement is now satisfied.
+      gApi.changes().id(changeId).restore();
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).abandon();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void submitRequirement_retrievedFromNoteDbForAbandonedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).abandon();
+
+      // Add another submit requirement. This will not get returned for the abandoned change, since
+      // we return the state of the SR results when the change was abandoned.
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("New-Requirement")
+              .setSubmittabilityExpression(SubmitRequirementExpression.create("-has:unresolved"))
+              .setAllowOverrideInChildProjects(false)
+              .build());
+      ChangeInfo changeInfo =
+          gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+      assertThat(changeInfo.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "label:Code-Review=MAX");
+
+      // Restore the change, the new requirement will show up
+      gApi.changes().id(changeId).restore();
+      changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+      assertThat(changeInfo.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "label:Code-Review=MAX");
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "New-Requirement",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "-has:unresolved");
+
+      // Abandon again, make sure the new requirement was persisted
+      gApi.changes().id(changeId).abandon();
+      changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+      assertThat(changeInfo.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "label:Code-Review=MAX");
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "New-Requirement",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "-has:unresolved");
+    }
+  }
+
+  @Test
+  public void submitRequirement_retrievedFromNoteDbForClosedChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).current().submit();
+
+    // Add new submit requirement
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Verified")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Verified=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // The new "Verified" submit requirement is not returned, since this change is closed
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void
+      submitRequirements_returnOneEntryForMatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
+          throws Exception {
+    // Configure a legacy submit requirement: label with a max with block function
+    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Configure a submit requirement with the same name.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("build-cop-override")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(
+                    "label:build-cop-override=MAX -label:build-cop-override=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Create a change. Vote to fulfill all requirements.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    voteLabel(changeId, "build-cop-override", 1);
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
+    // Only non-legacy bco is returned.
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.SATISFIED,
+        /* isLegacy= */ false,
+        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
+    assertThat(change.submittable).isTrue();
+
+    // Merge the change. Submit requirements are still the same.
+    gApi.changes().id(changeId).current().submit();
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.SATISFIED,
+        /* isLegacy= */ false,
+        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
+  }
+
+  @Test
+  public void
+      submitRequirements_returnTwoEntriesForMismatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
+          throws Exception {
+    // Configure a legacy submit requirement: label with a max with block function
+    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Configure a submit requirement with the same name.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("build-cop-override")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:build-cop-override=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Create a change
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    voteLabel(changeId, "build-cop-override", 1);
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
+    // Two instances of bco will be returned since their status is not matching.
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(3);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.SATISFIED,
+        /* isLegacy= */ true,
+        // MAX_WITH_BLOCK function was translated to a submittability expression.
+        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false,
+        /* submittabilityCondition= */ "label:build-cop-override=MIN");
+    assertThat(change.submittable).isFalse();
+  }
+
+  @Test
+  public void submitRequirements_skippedIfLegacySRIsBasedOnOptionalLabel() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    SubmitRule r1 =
+        createSubmitRule("r1", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.MAY);
+    try (Registration registration = extensionRegistry.newRegistration().add(r1)) {
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirement_notSkippedIfLegacySRIsBasedOnNonOptionalLabel() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    SubmitRule r1 =
+        createSubmitRule("r1", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.OK);
+    try (Registration registration = extensionRegistry.newRegistration().add(r1)) {
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          submitRequirements, "CR", Status.SATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirements_returnForLegacySubmitRecords_ifEnabled() throws Exception {
+    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // 1. Project has two legacy requirements: Code-Review and bco. Both unsatisfied.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    // 2. Vote +1 on bco. bco becomes satisfied
+    voteLabel(changeId, "build-cop-override", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
+
+    // 3. Vote +1 on Code-Review. Code-Review becomes satisfied
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
+
+    // 4. Merge the change. Submit requirements status is presented from NoteDb.
+    gApi.changes().id(changeId).current().submit();
+    change = gApi.changes().id(changeId).get();
+    // Legacy submit records are returned as submit requirements.
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_backFilledFromIndexForActiveChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Query the change. ChangeInfo is back-filled from the change index.
+    List<ChangeInfo> changeInfos =
+        gApi.changes()
+            .query()
+            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
+            .get();
+    assertThat(changeInfos).hasSize(1);
+    assertSubmitRequirementStatus(
+        changeInfos.get(0).submitRequirements,
+        "Code-Review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_backFilledFromIndexForClosedChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+    gApi.changes().id(changeId).current().submit();
+
+    // Query the change. ChangeInfo is back-filled from the change index.
+    List<ChangeInfo> changeInfos =
+        gApi.changes()
+            .query()
+            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
+            .get();
+    assertThat(changeInfos).hasSize(1);
+    assertSubmitRequirementStatus(
+        changeInfos.get(0).submitRequirements,
+        "Code-Review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_applicabilityExpressionIsAlwaysHidden() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("branch:refs/heads/master"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    SubmitRequirementResultInfo requirement =
+        changeInfo.submitRequirements.stream().collect(MoreCollectors.onlyElement());
+    assertSubmitRequirementExpression(
+        requirement.applicabilityExpressionResult,
+        /* expression= */ null,
+        /* passingAtoms= */ null,
+        /* failingAtoms= */ null,
+        /* fulfilled= */ true);
+    assertThat(requirement.submittabilityExpressionResult).isNotNull();
+  }
+
+  @Test
+  public void submitRequirement_nonApplicable_submittabilityAndOverrideNotEvaluated()
+      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,
+        /* fulfilled= */ false);
+    assertThat(requirement.submittabilityExpressionResult).isNull();
+    assertThat(requirement.overrideExpressionResult).isNull();
+  }
+
+  @Test
+  public void submitRequirement_emptyApplicable_submittabilityAndOverrideEvaluated()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(Optional.empty())
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("project:non-existent"))
+            .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.SATISFIED, /* isLegacy= */ false);
+    SubmitRequirementResultInfo requirement =
+        changeInfo.submitRequirements.stream().collect(MoreCollectors.onlyElement());
+    assertThat(requirement.applicabilityExpressionResult).isNull();
+    assertSubmitRequirementExpression(
+        requirement.submittabilityExpressionResult,
+        /* expression= */ SubmitRequirementExpression.maxCodeReview().expressionString(),
+        /* passingAtoms= */ ImmutableList.of(
+            SubmitRequirementExpression.maxCodeReview().expressionString()),
+        /* failingAtoms= */ ImmutableList.of(),
+        /* fulfilled= */ true);
+    assertSubmitRequirementExpression(
+        requirement.overrideExpressionResult,
+        /* expression= */ "project:non-existent",
+        /* passingAtoms= */ ImmutableList.of(),
+        /* failingAtoms= */ ImmutableList.of("project:non-existent"),
+        /* fulfilled= */ false);
+  }
+
+  @Test
+  public void submitRequirement_overriden_submittabilityEvaluated() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(Optional.empty())
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("project:" + project.get()))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 1);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    SubmitRequirementResultInfo requirement =
+        changeInfo.submitRequirements.stream()
+            .filter(sr -> !sr.isLegacy)
+            .collect(MoreCollectors.onlyElement());
+    assertThat(requirement.applicabilityExpressionResult).isNull();
+    assertSubmitRequirementExpression(
+        requirement.submittabilityExpressionResult,
+        /* expression= */ SubmitRequirementExpression.maxCodeReview().expressionString(),
+        /* passingAtoms= */ ImmutableList.of(),
+        /* failingAtoms= */ ImmutableList.of(
+            SubmitRequirementExpression.maxCodeReview().expressionString()),
+        /* fulfilled= */ false);
+    assertSubmitRequirementExpression(
+        requirement.overrideExpressionResult,
+        /* expression= */ "project:" + project.get(),
+        /* passingAtoms= */ ImmutableList.of("project:" + project.get()),
+        /* failingAtoms= */ ImmutableList.of(),
+        /* fulfilled= */ true);
+  }
+
+  @Test
+  public void submitRequirements_eliminatesDuplicatesForLegacyNonMatchingSRs() throws Exception {
+    // If a custom/prolog submit rule emits the same label name multiple times, we merge these into
+    // a single submit requirement result: in this test, we have two different submit rules that
+    // return the same label name, one as "OK" and the other as "NEED". The submit requirements
+    // API favours the blocking entry and returns one SR result with status=UNSATISFIED.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    SubmitRule r1 =
+        createSubmitRule("r1", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.OK);
+    SubmitRule r2 =
+        createSubmitRule("r2", SubmitRecord.Status.NOT_READY, "CR", SubmitRecord.Label.Status.NEED);
+    try (Registration registration = extensionRegistry.newRegistration().add(r1).add(r2)) {
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          submitRequirements, "CR", Status.UNSATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirements_eliminatesDuplicatesForLegacyMatchingSRs() throws Exception {
+    // If a custom/prolog submit rule emits the same label name multiple times, we merge these into
+    // a single submit requirement result: in this test, we have two different submit rules that
+    // return the same label name, but both are fulfilled (i.e. they both allow submission). The
+    // submit requirements API returns one SR result with status=SATISFIED.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    SubmitRule r1 =
+        createSubmitRule("r1", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.OK);
+    SubmitRule r2 =
+        createSubmitRule("r2", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.MAY);
+    try (Registration registration = extensionRegistry.newRegistration().add(r1).add(r2)) {
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          submitRequirements, "CR", Status.SATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirements_eliminatesMultipleDuplicatesForLegacyMatchingSRs()
+      throws Exception {
+    // If a custom/prolog submit rule emits the same label name multiple times, we merge these into
+    // a single submit requirement result: in this test, we have five different submit rules that
+    // return the same label name, all with an "OK" status. The submit requirements API returns
+    // a single SR result with status=SATISFIED.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    try (Registration registration = extensionRegistry.newRegistration()) {
+      IntStream.range(0, 5)
+          .forEach(
+              i ->
+                  registration.add(
+                      createSubmitRule(
+                          "r" + i, SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.OK)));
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          submitRequirements, "CR", Status.SATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirement_duplicateSubmitRequirement_sameCase() throws Exception {
+    // Define 2 submit requirements with exact same name but different submittability expression.
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = repo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = repo.getRevWalk().parseCommit(ref.getObjectId());
+      RevObject blob = repo.get(head.getTree(), ProjectConfig.PROJECT_CONFIG);
+      byte[] data = repo.getRepository().open(blob).getCachedBytes(Integer.MAX_VALUE);
+      String projectConfig = RawParseUtils.decode(data);
+
+      repo.update(
+          RefNames.REFS_CONFIG,
+          repo.commit()
+              .parent(head)
+              .message("Set project config")
+              .add(
+                  ProjectConfig.PROJECT_CONFIG,
+                  projectConfig
+                      // JGit parses this as a list value:
+                      // submit-requirement.Code-Review.submittableIf =
+                      //     [label:Code-Review=+2, label:Code-Review=+1]
+                      // if getString is used to read submittableIf JGit returns the last value
+                      // (label:Code-Review=+1)
+                      + "[submit-requirement \"Code-Review\"]\n"
+                      + "    submittableIf = label:Code-Review=+2\n"
+                      + "[submit-requirement \"Code-Review\"]\n"
+                      + "    submittableIf = label:Code-Review=+1\n"));
+    }
+    projectCache.evict(project);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // The submit requirement is fulfilled now, since label:Code-Review=+1 applies as submittability
+    // expression (see comment above)
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_duplicateSubmitRequirement_differentCase() throws Exception {
+    // Define 2 submit requirements with same name but different case and different submittability
+    // expression.
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = repo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = repo.getRevWalk().parseCommit(ref.getObjectId());
+      RevObject blob = repo.get(head.getTree(), ProjectConfig.PROJECT_CONFIG);
+      byte[] data = repo.getRepository().open(blob).getCachedBytes(Integer.MAX_VALUE);
+      String projectConfig = RawParseUtils.decode(data);
+
+      repo.update(
+          RefNames.REFS_CONFIG,
+          repo.commit()
+              .parent(head)
+              .message("Set project config")
+              .add(
+                  ProjectConfig.PROJECT_CONFIG,
+                  projectConfig
+                      // ProjectConfig processes the submit requirements in the order in which they
+                      // appear (1. Code-Review, 2. code-review) and ignores any further submit
+                      // requirement if its name case-insensitively matches the name of a submit
+                      // requirement that has already been seen. This means the Code-Review submit
+                      // requirement applies and the code-review submit requirement is ignored.
+                      + "[submit-requirement \"Code-Review\"]\n"
+                      + "    submittableIf = label:Code-Review=+2\n"
+                      + "[submit-requirement \"code-review\"]\n"
+                      + "    submittableIf = label:Code-Review=+1\n"));
+    }
+    projectCache.evict(project);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Still not satisfied since the Code-Review submit requirement with label:Code-Review=+2 as
+    // submittability expression applies (see comment above).
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // The submit requirement is fulfilled now, since label:Code-Review=+2 applies as submittability
+    // expression (see comment above)
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_overrideInheritedSRWithDifferentNameCasing() throws Exception {
+    // Define submit requirement in root project and allow override.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Define a submit requirement with the same name in the child project that differs by case and
+    // has a different submittability expression (requires Code-Review=+1 instead of +2).
+    // This overrides the inherited submit requirement with the same name, although the case is
+    // different.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement since the override applies
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_cannotOverrideNonOverridableInheritedSRWithDifferentNameCasing()
+      throws Exception {
+    // Define submit requirement in root project and disallow override.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Define a submit requirement with the same name in the child project that differs by case and
+    // has a different submittability expression (requires Code-Review=+1 instead of +2).
+    // This is ignored since the inherited submit requirement with the same name (different case)
+    // disallows overriding.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Still not satisfied since the override is ignored.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void globalSubmitRequirement_storedForClosedChanges() throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("global-submit-requirement")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(false)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements,
+          "global-submit-requirement",
+          Status.UNSATISFIED,
+          /* isLegacy= */ false);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).topic("test");
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements,
+          "global-submit-requirement",
+          Status.SATISFIED,
+          /* isLegacy= */ false);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+      gApi.changes().id(changeId).current().submit();
+
+      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+      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("topic:test");
+    }
+  }
+
+  @Test
+  public void projectSubmitRequirementDuplicatesGlobal_overrideNotAllowed_globalEvaluated()
+      throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(false)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Vote does not satisfy submit requirement, because the global definition is evaluated.
+      voteLabel(changeId, "CoDe-reView", 2);
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+      // In addition, the legacy submit requirement is emitted, since the status mismatch
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+      // Setting the topic satisfies the global definition.
+      gApi.changes().id(changeId).topic("test");
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void projectSubmitRequirementDuplicatesGlobal_overrideAllowed_projectRequirementEvaluated()
+      throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(true)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Setting the topic does not satisfy submit requirement, because the project definition is
+      // evaluated.
+      gApi.changes().id(changeId).topic("test");
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      // There is no mismatch with legacy submit requirement, so the single result is emitted.
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Voting satisfies the project definition.
+      voteLabel(changeId, "Code-Review", 2);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void legacySubmitRequirementDuplicatesGlobal_statusMatches_globalReturned()
+      throws Exception {
+    // The behaviour does not depend on AllowOverrideInChildProject in global submit requirement.
+    testLegacySubmitRequirementDuplicatesGlobalStatusMatches(/*allowOverrideInChildProject=*/ true);
+    testLegacySubmitRequirementDuplicatesGlobalStatusMatches(
+        /*allowOverrideInChildProject=*/ false);
+  }
+
+  private void testLegacySubmitRequirementDuplicatesGlobalStatusMatches(
+      boolean allowOverrideInChildProject) throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(allowOverrideInChildProject)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Both are evaluated, but only the global is returned, since both are unsatisfied
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Both are evaluated, but only the global is returned, since both are satisfied
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).topic("test");
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void legacySubmitRequirementWithIgnoreSelfApproval() throws Exception {
+    LabelType verified =
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    verified = verified.toBuilder().setIgnoreSelfApproval(true).build();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(verified);
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(verified.getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // The DefaultSubmitRule emits an "OK" submit record for Verified, while the
+    // ignoreSelfApprovalRule emits a "NEED" submit record. The "submit requirements" adapter merges
+    // both results and returns the blocking one only.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.id().toString());
+
+    voteLabel(changeId, verified.getName(), +1);
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    Collection<SubmitRequirementResultInfo> submitRequirements = changeInfo.submitRequirements;
+    assertSubmitRequirementStatus(
+        submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void legacySubmitRequirementDuplicatesGlobal_statusDoesNotMatch_bothRecordsReturned()
+      throws Exception {
+    // The behaviour does not depend on AllowOverrideInChildProject in global submit requirement.
+    testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+        /*allowOverrideInChildProject=*/ true);
+    testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+        /*allowOverrideInChildProject=*/ false);
+  }
+
+  private void testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+      boolean allowOverrideInChildProject) throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(allowOverrideInChildProject)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Both are evaluated, but only the global is returned, since both are unsatisfied
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Both are evaluated and both are returned, since result mismatch
+      voteLabel(changeId, "Code-Review", 2);
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      gApi.changes().id(changeId).topic("test");
+      gApi.changes().id(changeId).reviewer(admin.id().toString()).deleteVote(LabelId.CODE_REVIEW);
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void submitRequirements_disallowsTheIsSubmittableOperator() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Wrong-Req")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:submittable"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    SubmitRequirementResultInfo srResult =
+        change.submitRequirements.stream()
+            .filter(sr -> sr.name.equals("Wrong-Req"))
+            .collect(MoreCollectors.onlyElement());
+    assertThat(srResult.status).isEqualTo(Status.ERROR);
+    assertThat(srResult.submittabilityExpressionResult.errorMessage)
+        .isEqualTo("Operator 'is:submittable' cannot be used in submit requirement expressions.");
+  }
+
+  @Test
+  public void submitRequirements_forcedByDirectSubmission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("My-Requirement")
+            // Submit requirement is always unsatisfied, but we are going to bypass it.
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:false"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    pushFactory.create(admin.newIdent(), testRepo, changeId).to("refs/for/master%submit");
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "My-Requirement", Status.FORCED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.FORCED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_evaluatedWithInternalUserCredentials() throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = "invisible-group";
+    in.visibleToAll = false;
+    in.ownerId = adminGroupUuid().get();
+    gApi.groups().create(in);
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("My-Requirement")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("ownerin:invisible-group"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    requestScopeOperations.setApiUser(user.id());
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    SubmitRequirementResultInfo srResult =
+        change.submitRequirements.stream()
+            .filter(sr -> sr.name.equals("My-Requirement"))
+            .collect(MoreCollectors.onlyElement());
+    assertThat(srResult.status).isEqualTo(Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void submitRequirements_submittedTogetherWithoutLegacySubmitRequirements()
+      throws Exception {
+    // Add a code review submit requirement and mark the 'Code-Review' label function to be
+    // non-blocking.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    LabelType cr = TestLabels.codeReview().toBuilder().setFunction(LabelFunction.NO_BLOCK).build();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(cr);
+      u.save();
+    }
+
+    // Create two changes in a chain.
+    createChange();
+    PushOneCommit.Result r2 = createChange();
+
+    // Make sure the CR requirement is unsatisfied.
+    String changeId = r2.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    List<ChangeInfo> changeInfos = gApi.changes().id(changeId).submittedTogether();
+    assertThat(changeInfos).hasSize(2);
+    assertThat(
+            changeInfos.stream()
+                .map(c -> c.submittable)
+                .distinct()
+                .collect(MoreCollectors.onlyElement()))
+        .isFalse();
+  }
+
+  @Test
+  public void queryChangesBySubmitRequirementResultUsingTheLabelPredicate() throws Exception {
+    // Create a non-blocking label and a submit-requirement that necessitates voting on this label.
+    configLabel("LC", LabelFunction.NO_OP);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("LC").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
+        .update();
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("LC")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:LC=MAX"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    List<ChangeInfo> changeInfos = gApi.changes().query("label:LC=NEED").get();
+    assertThat(changeInfos).hasSize(1);
+    assertThat(changeInfos.get(0).changeId).isEqualTo(changeId);
+    assertThat(gApi.changes().query("label:LC=OK").get()).isEmpty();
+    // case does not matter
+    changeInfos = gApi.changes().query("label:lc=NEED").get();
+    assertThat(changeInfos).hasSize(1);
+    assertThat(changeInfos.get(0).changeId).isEqualTo(changeId);
+
+    voteLabel(r.getChangeId(), "LC", +1);
+    changeInfos = gApi.changes().query("label:LC=OK").get();
+    assertThat(changeInfos.get(0).changeId).isEqualTo(changeId);
+    assertThat(gApi.changes().query("label:LC=NEED").get()).isEmpty();
+  }
+
+  @Test
+  public void queryingChangesWithSubmitRequirementOptionDoesNotTouchDatabase() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            // Always not submittable
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:false"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r1 = createChange();
+    gApi.changes()
+        .id(r1.getChangeId())
+        .revision(r1.getCommit().name())
+        .review(ReviewInput.approve());
+
+    ChangeInfo changeInfo = gApi.changes().id(r1.getChangeId()).get();
+    assertThat(changeInfo.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy = */ false);
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy = */ true);
+
+    requestScopeOperations.setApiUser(user.id());
+    try (AutoCloseable ignored = disableNoteDb()) {
+      List<ChangeInfo> changeInfos =
+          gApi.changes()
+              .query()
+              .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+              .withOptions(
+                  new ImmutableSet.Builder<ListChangesOption>()
+                      .addAll(IndexPreloadingUtil.DASHBOARD_OPTIONS)
+                      .add(ListChangesOption.SUBMIT_REQUIREMENTS)
+                      .build())
+              .get();
+      assertThat(changeInfos).hasSize(1);
+      assertSubmitRequirementStatus(
+          changeInfos.get(0).submitRequirements,
+          "Code-Review",
+          Status.UNSATISFIED,
+          /* isLegacy = */ false);
+      assertSubmitRequirementStatus(
+          changeInfos.get(0).submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy = */ true);
+    }
+  }
+
+  private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
+    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,
+      SubmitRequirementResultInfo.Status status,
+      boolean isLegacy,
+      String submittabilityCondition) {
+    for (SubmitRequirementResultInfo result : results) {
+      if (result.name.equals(requirementName)
+          && result.status == status
+          && result.isLegacy == isLegacy
+          && result.submittabilityExpressionResult.expression.equals(submittabilityCondition)) {
+        return;
+      }
+    }
+    throw new AssertionError(
+        String.format(
+            "Could not find submit requirement %s with status %s (results = %s)",
+            requirementName,
+            status,
+            results.stream()
+                .map(r -> String.format("%s=%s", r.name, r.status))
+                .collect(toImmutableList())));
+  }
+
+  private void assertSubmitRequirementStatus(
+      Collection<SubmitRequirementResultInfo> results,
+      String requirementName,
+      SubmitRequirementResultInfo.Status status,
+      boolean isLegacy) {
+    for (SubmitRequirementResultInfo result : results) {
+      if (result.name.equals(requirementName)
+          && result.status == status
+          && result.isLegacy == isLegacy) {
+        return;
+      }
+    }
+    throw new AssertionError(
+        String.format(
+            "Could not find submit requirement %s with status %s (results = %s)",
+            requirementName,
+            status,
+            results.stream()
+                .map(r -> String.format("%s=%s, legacy=%s", r.name, r.status, r.isLegacy))
+                .collect(toImmutableList())));
+  }
+
+  private void assertSubmitRequirementExpression(
+      SubmitRequirementExpressionInfo result,
+      @Nullable String expression,
+      @Nullable List<String> passingAtoms,
+      @Nullable List<String> failingAtoms,
+      boolean fulfilled) {
+    assertThat(result.expression).isEqualTo(expression);
+    if (passingAtoms == null) {
+      assertThat(result.passingAtoms).isNull();
+    } else {
+      assertThat(result.passingAtoms).containsExactlyElementsIn(passingAtoms);
+    }
+    if (failingAtoms == null) {
+      assertThat(result.failingAtoms).isNull();
+    } else {
+      assertThat(result.failingAtoms).containsExactlyElementsIn(failingAtoms);
+    }
+    assertThat(result.fulfilled).isEqualTo(fulfilled);
+  }
+
+  private Project.NameKey createProjectForPush(SubmitType submitType) throws Exception {
+    Project.NameKey project = projectOperations.newProject().submitType(submitType).create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
+        .update();
+    return project;
+  }
+
+  private static SubmitRule createSubmitRule(
+      String ruleName,
+      SubmitRecord.Status srStatus,
+      String labelName,
+      SubmitRecord.Label.Status labelStatus) {
+    return changeData -> {
+      SubmitRecord r = new SubmitRecord();
+      r.ruleName = ruleName;
+      r.status = srStatus;
+      SubmitRecord.Label label = new SubmitRecord.Label();
+      label.label = labelName;
+      label.status = labelStatus;
+      r.labels = Arrays.asList(label);
+      return Optional.of(r);
+    };
+  }
+
+  /** Returns a hard-coded submit record containing all fields. */
+  private static class TestSubmitRule implements SubmitRule {
+    @Override
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      SubmitRecord record = new SubmitRecord();
+      record.ruleName = "testSubmitRule";
+      record.status = SubmitRecord.Status.OK;
+      SubmitRecord.Label label = new SubmitRecord.Label();
+      label.label = "label";
+      label.status = SubmitRecord.Label.Status.OK;
+      record.labels = Arrays.asList(label);
+      record.requirements =
+          Arrays.asList(
+              LegacySubmitRequirement.builder()
+                  .setType("type")
+                  .setFallbackText("fallback text")
+                  .build());
+      return Optional.of(record);
+    }
+  }
+
+  private static SubmitRequirementInput createSubmitRequirementInput(
+      String name, String submittabilityExpression) {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = name;
+    input.submittabilityExpression = submittabilityExpression;
+    return input;
+  }
+
+  private static SubmitRequirementInput createSubmitRequirementInput(
+      String name, String applicableIf, String submittableIf, String overrideIf) {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = name;
+    input.applicabilityExpression = applicableIf;
+    input.submittabilityExpression = submittableIf;
+    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/SubmitRequirementPredicateIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
new file mode 100644
index 0000000..a443739
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
@@ -0,0 +1,216 @@
+// 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.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.ANONYMOUS_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseTimezone;
+import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+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.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseTimezone(timezone = "US/Eastern")
+@VerifyNoPiiInChangeNotes(true)
+public class SubmitRequirementPredicateIT extends AbstractDaemonTest {
+
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private SubmitRequirementsEvaluator submitRequirementsEvaluator;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private AccountOperations accountOperations;
+
+  private final LabelType label =
+      label("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+
+  private final LabelType pLabel =
+      label("Custom-Label2", value(1, "Positive"), value(0, "No score"));
+
+  @Before
+  public void setUp() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(label.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(pLabel.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(0, 1))
+        .update();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(label);
+      u.getConfig().upsertLabelType(pLabel);
+      u.save();
+    }
+  }
+
+  @Test
+  public void distinctVoters_sameUserVotesOnDifferentLabels_fails() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(admin.id());
+    approve(c1.toString());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+
+    // Same user votes on both labels
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_distinctUsersOnDifferentLabels_passes() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(admin.id());
+    approve(c1.toString());
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_onlyMaxVotesRespected() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+    requestScopeOperations.setApiUser(admin.id());
+    approve(c1.toString());
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_onlyMinVotesRespected() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", -1));
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MIN,count>1\"", c1);
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(c1.toString()).current().review(ReviewInput.reject());
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MIN,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_onlyExactValueRespected() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    requestScopeOperations.setApiUser(admin.id());
+    approve(c1.toString());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=1,count>1\"", c1);
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],value=1,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_valueIsOptional() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", -1));
+    requestScopeOperations.setApiUser(admin.id());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],count>1\"", c1);
+    recommend(c1.toString());
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_moreThanTwoLabels() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label2", 1));
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertMatching(
+        "distinctvoters:\"[Code-Review,Custom-Label,Custom-Label2],value=1,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_moreThanTwoLabels_moreThanTwoUsers() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label2", 1));
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertNotMatching(
+        "distinctvoters:\"[Code-Review,Custom-Label,Custom-Label2],value=1,count>2\"", c1);
+    Account.Id tester = accountOperations.newAccount().create();
+    requestScopeOperations.setApiUser(tester);
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    assertMatching(
+        "distinctvoters:\"[Code-Review,Custom-Label,Custom-Label2],value=1,count>2\"", c1);
+  }
+
+  private void assertMatching(String requirement, Change.Id change) {
+    assertThat(evaluate(requirement, change).status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  private void assertNotMatching(String requirement, Change.Id change) {
+    assertThat(evaluate(requirement, change).status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  private SubmitRequirementExpressionResult evaluate(String requirement, Change.Id change) {
+    ChangeData cd = changeDataFactory.create(project, change);
+    return submitRequirementsEvaluator.evaluateExpression(
+        SubmitRequirementExpression.create(requirement), cd);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
index 636b71d..af95e7e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.rules.DefaultSubmitRule;
 import com.google.inject.Inject;
 import java.util.List;
 import java.util.stream.Collectors;
@@ -44,7 +45,7 @@
             recordsBeforeSubmission.stream()
                 .map(record -> record.ruleName)
                 .collect(Collectors.toList()))
-        .containsExactly("gerrit~DefaultSubmitRule");
+        .containsExactly(DefaultSubmitRule.RULE_NAME);
     gApi.changes().id(r.getChangeId()).current().submit();
     // Add a new label that blocks submission if not granted. In case we reevaluate the rules,
     // this would show up as blocking submission.
@@ -67,7 +68,7 @@
             recordsBeforeSubmission.stream()
                 .map(record -> record.ruleName)
                 .collect(Collectors.toList()))
-        .containsExactly("gerrit~DefaultSubmitRule");
+        .containsExactly(DefaultSubmitRule.RULE_NAME);
     gApi.changes().id(r.getChangeId()).current().submit();
     // Add a new label that blocks submission if not granted. In case we reevaluate the rules,
     // this would show up as blocking submission.
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
index 5124d11..77582c6 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -19,6 +19,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 org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.collect.ImmutableList;
@@ -35,6 +36,7 @@
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import java.util.HashSet;
@@ -59,8 +61,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.setCopyAnyScore(true);
       u.getConfig().upsertLabelType(codeReview.build());
       u.save();
@@ -345,8 +347,8 @@
   }
 
   @Test
-  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
-  public void autoGeneratedPostSubmitDiffIsNotPartOfTheCommentSizeLimit() throws Exception {
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "10k")
+  public void autoGeneratedPostSubmitDiffIsPartOfTheCommentSizeLimit() throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
@@ -356,35 +358,69 @@
     // Post a submit diff that is almost the cumulativeCommentSizeLimit
     gApi.changes().id(changeId.get()).current().submit();
     assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
-        .doesNotContain("many unreviewed changes");
+        .doesNotContain("The diff is too large to show. Please review the diff");
 
-    // unrelated comment and change message posting works fine, since the post submit diff is not
+    // unrelated comment and change message posting doesn't work, since the post submit diff is
     // counted towards the cumulativeCommentSizeLimit for unrelated follow-up comments.
-    // 800 + 400 + 400 > 1k, but 400 + 400 < 1k, hence these comments are accepted (the original
-    // 800 is not counted).
-    String message = new String(new char[400]).replace("\0", "a");
+    // 800 + 9500 > 10k.
+    String message = new String(new char[9500]).replace("\0", "a");
     ReviewInput reviewInput = new ReviewInput().message(message);
     CommentInput commentInput = new CommentInput();
     commentInput.line = 1;
-    commentInput.message = message;
     commentInput.path = "file";
     reviewInput.comments = ImmutableMap.of("file", ImmutableList.of(commentInput));
 
-    gApi.changes().id(changeId.get()).current().review(reviewInput);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId.get()).current().review(reviewInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Exceeding maximum cumulative size of comments and change messages");
   }
 
   @Test
-  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
   public void postSubmitDiffCannotBeTooBig() throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
 
-    String content = new String(new char[1100]).replace("\0", "a");
+    // max post submit diff size is 300k
+    String content = new String(new char[320000]).replace("\0", "a");
 
     changeOperations.change(changeId).newPatchset().file("file").content(content).create();
 
-    // Post submit diff is over the cumulativeCommentSizeLimit, so we shorten the message.
+    // Post submit diff is over the postSubmitDiffSizeLimit (300k).
+    gApi.changes().id(changeId.get()).current().submit();
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .isEqualTo(
+            "Change has been successfully merged\n\n1 is the latest approved patch-set.\nThe "
+                + "change was submitted with unreviewed changes in the following "
+                + "files:\n\n```\nThe name of the file: file\nInsertions: 1, Deletions: 1.\n\nThe"
+                + " diff is too large to show. Please review the diff.\n```\n");
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "10k")
+  public void postSubmitDiffCannotBeTooBigWithLargeComments() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    // unrelated comment taking up most of the space, making post submit diff shorter.
+    String message = new String(new char[9700]).replace("\0", "a");
+    ReviewInput reviewInput = new ReviewInput().message(message);
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.path = "file";
+    reviewInput.comments = ImmutableMap.of("file", ImmutableList.of(commentInput));
+    gApi.changes().id(changeId.get()).current().review(reviewInput);
+
+    String content = new String(new char[500]).replace("\0", "a");
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post submit diff is over the cumulativeCommentSizeLimit, since the comment took most of
+    // the space (even though the post submit diff is not limited).
     gApi.changes().id(changeId.get()).current().submit();
     assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
         .isEqualTo(
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java b/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
index 079d43e..27f6111 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
@@ -42,7 +42,7 @@
     assertThat(toBoolean(info.options.visibleToAll)).isEqualTo(group.isVisibleToAll());
     assertThat(info.description).isEqualTo(group.getDescription());
     assertThat(Url.decode(info.ownerId)).isEqualTo(group.getOwnerGroupUUID().get());
-    assertThat(info.createdOn).isEqualTo(group.getCreatedOn());
+    assertThat(info.createdOn.toInstant()).isEqualTo(group.getCreatedOn());
   }
 
   public static boolean toBoolean(Boolean b) {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 61b7c0c..12f8506 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -109,6 +109,7 @@
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
@@ -557,14 +558,17 @@
     assertThrows(AuthException.class, () -> gApi.groups().create(name("newGroup")));
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void createdOnFieldIsPopulatedForNewGroup() throws Exception {
     // NoteDb allows only second precision.
-    Timestamp testStartTime = TimeUtil.truncateToSecond(TimeUtil.nowTs());
+    Instant testStartTime = TimeUtil.truncateToSecond(TimeUtil.now());
     String newGroupName = name("newGroup");
     GroupInfo group = gApi.groups().create(newGroupName).get();
 
-    assertThat(group.createdOn).isAtLeast(testStartTime);
+    assertThat(group.createdOn.toInstant()).isAtLeast(testStartTime);
   }
 
   @Test
@@ -606,7 +610,7 @@
     assertThat(group.name).isEqualTo(anonymousUsersGroup.getName());
 
     group = gApi.groups().id(anonymousUsersGroup.getName()).get();
-    assertThat(group.id).isEqualTo(Url.encode((anonymousUsersGroup.getUUID().get())));
+    assertThat(group.id).isEqualTo(Url.encode(anonymousUsersGroup.getUUID().get()));
   }
 
   @Test
@@ -614,7 +618,7 @@
     GroupReference anonymousUsersGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
     GroupInfo group = gApi.groups().id("Anonymous Users").get();
     assertThat(group.name).isEqualTo(anonymousUsersGroup.getName());
-    assertThat(group.id).isEqualTo(Url.encode((anonymousUsersGroup.getUUID().get())));
+    assertThat(group.id).isEqualTo(Url.encode(anonymousUsersGroup.getUUID().get()));
   }
 
   @Test
@@ -1619,7 +1623,7 @@
         treeId = oi.insert(Constants.OBJ_TREE, new byte[] {});
       }
 
-      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.nowTs());
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(ident);
diff --git a/javatests/com/google/gerrit/acceptance/api/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/AccessReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessReviewIT.java
new file mode 100644
index 0000000..553650a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessReviewIT.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AccessReviewIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  private Project.NameKey defaultMessageProject;
+  private Project.NameKey customMessageProject;
+
+  @Before
+  public void setUp() throws Exception {
+    defaultMessageProject = projectOperations.newProject().create();
+    customMessageProject = projectOperations.newProject().create();
+  }
+
+  @Test
+  public void createPermissionsChangeWithDefaultMessage() throws Exception {
+    ProjectAccessInput in = new ProjectAccessInput();
+    in.add = new HashMap<>();
+
+    AccessSectionInfo a = new AccessSectionInfo();
+    PermissionInfo p = new PermissionInfo(null, null);
+    p.rules =
+        ImmutableMap.of(
+            SystemGroupBackend.REGISTERED_USERS.get(),
+            new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false));
+    a.permissions = ImmutableMap.of("read", p);
+    in.add = ImmutableMap.of("refs/heads/*", a);
+
+    RestResponse rep =
+        adminRestSession.put("/projects/" + defaultMessageProject.get() + "/access:review", in);
+    rep.assertCreated();
+
+    List<ChangeInfo> result =
+        gApi.changes()
+            .query("project:" + defaultMessageProject.get() + " AND ref:refs/meta/config")
+            .get();
+    assertThat(Iterables.getOnlyElement(result).subject).isEqualTo("Review access change");
+  }
+
+  @Test
+  public void createPermissionsChangeWithCustomMessage() throws Exception {
+    ProjectAccessInput in = new ProjectAccessInput();
+    String customMessage = "UNIT-42: Allow registered users to read 'main' branch";
+    in.add = new HashMap<>();
+
+    AccessSectionInfo a = new AccessSectionInfo();
+    PermissionInfo p = new PermissionInfo(null, null);
+    p.rules =
+        ImmutableMap.of(
+            SystemGroupBackend.REGISTERED_USERS.get(),
+            new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false));
+    a.permissions = ImmutableMap.of("read", p);
+    in.add = ImmutableMap.of("refs/heads/main", a);
+    in.message = customMessage;
+
+    RestResponse rep =
+        adminRestSession.put("/projects/" + customMessageProject.get() + "/access:review", in);
+    rep.assertCreated();
+
+    List<ChangeInfo> result =
+        gApi.changes()
+            .query("project:" + customMessageProject.get() + " AND ref:refs/meta/config")
+            .get();
+
+    assertThat(Iterables.getOnlyElement(result).subject).isEqualTo(customMessage);
+  }
+}
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/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/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
index 7197425..ba45fb2 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -22,23 +22,31 @@
 import static com.google.gerrit.extensions.common.testing.CommentInfoSubject.assertThatList;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
+import static java.util.stream.Collectors.joining;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.change.TestCommentCreation;
 import com.google.gerrit.acceptance.testsuite.change.TestPatchset;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
@@ -47,11 +55,12 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Ignore;
 import org.junit.Test;
 
 public class PortedCommentsIT extends AbstractDaemonTest {
-
   @Inject private ChangeOperations changeOps;
   @Inject private AccountOperations accountOps;
   @Inject private RequestScopeOperations requestScopeOps;
@@ -143,6 +152,39 @@
   }
 
   @Test
+  public void commentsArePortedWhenAllEditsAreDueToRebase() throws Exception {
+    String fileName = "f.txt";
+    String baseContent =
+        IntStream.rangeClosed(1, 50)
+            .mapToObj(number -> String.format("Line %d\n", number))
+            .collect(joining());
+    ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
+    ObjectId baseCommit = addCommit(headCommit, fileName, baseContent);
+
+    // Create a change on top of baseCommit, modify line 1, then add comment on line 10.
+    PushOneCommit.Result r = createEmptyChange();
+    Change.Id changeId = r.getChange().getId();
+    addModifiedPatchSet(
+        changeId.toString(), fileName, baseContent.replace("Line 1\n", "Line one\n"));
+    PatchSet.Id ps2Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    newComment(ps2Id).message("Line comment").onLine(10).ofFile(fileName).create();
+
+    // Add a commit on top of baseCommit. Delete line 4. Rebase the change on top of this commit.
+    ObjectId newBase = addCommit(baseCommit, fileName, baseContent.replace("Line 4\n", ""));
+    rebaseChangeOn(changeId.toString(), newBase);
+    PatchSet.Id ps3Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(ps3Id));
+    assertThat(portedComments).hasSize(1);
+    int portedLine = portedComments.get(0).line;
+    BinaryResult fileContent = gApi.changes().id(changeId.get()).current().file(fileName).content();
+    List<String> lines = Splitter.on("\n").splitToList(fileContent.asString());
+    // Comment has shifted to L9 instead of L10 because of the deletion of line 4.
+    assertThat(portedLine).isEqualTo(9);
+    assertThat(lines.get(portedLine - 1)).isEqualTo("Line 10");
+  }
+
+  @Test
   public void resolvedAndUnresolvedDraftCommentsArePorted() throws Exception {
     Account.Id accountId = accountOps.newAccount().create();
     // Set up change and patchsets.
@@ -1803,7 +1845,7 @@
   }
 
   @Test
-  public void deletedCommentContentIsNotCachedInPortedComments() throws Exception {
+  public void deletedCommentContentIsNotPorted() throws Exception {
     // Set up change and patchsets.
     Change.Id changeId = changeOps.newChange().create();
     PatchSet.Id patchsetId1 = changeOps.change(changeId).currentPatchset().get().patchsetId();
@@ -1817,9 +1859,9 @@
         .revision(patchsetId1.get())
         .comment(commentUuid)
         .delete(new DeleteCommentInput());
-    CommentInfo portedComment = getPortedComment(patchsetId2, commentUuid);
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchsetId2));
 
-    assertThat(portedComment).message().doesNotContain("Confidential content");
+    assertThatList(portedComments).isEmpty();
   }
 
   @Test
@@ -1897,9 +1939,7 @@
    */
   private static ImmutableList<CommentInfo> flatten(
       Map<String, List<CommentInfo>> commentsPerFile) {
-    return commentsPerFile.values().stream()
-        .flatMap(Collection::stream)
-        .collect((toImmutableList()));
+    return commentsPerFile.values().stream().flatMap(Collection::stream).collect(toImmutableList());
   }
 
   // Unfortunately, we don't get an absolutely helpful error message when using this correspondence
@@ -1909,4 +1949,35 @@
   private static Correspondence<CommentInfo, String> hasUuid() {
     return NullAwareCorrespondence.transforming(comment -> comment.id, "hasUuid");
   }
+
+  private Result createEmptyChange() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Test change", ImmutableMap.of());
+    return push.to("refs/for/master");
+  }
+
+  private void rebaseChangeOn(String changeId, ObjectId newParent) throws Exception {
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.base = newParent.getName();
+    gApi.changes().id(changeId).current().rebase(rebaseInput);
+  }
+
+  private void addModifiedPatchSet(String changeId, String filePath, String content)
+      throws Exception {
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(content));
+    gApi.changes().id(changeId).edit().publish();
+  }
+
+  private ObjectId addCommit(ObjectId parentCommit, String fileName, String fileContent)
+      throws Exception {
+    testRepo.reset(parentCommit);
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Adjust files of repo",
+            ImmutableMap.of(fileName, fileContent));
+    PushOneCommit.Result result = push.to("refs/for/master");
+    return result.getCommit();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index e645ecf..f347e19 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -52,12 +52,13 @@
 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;
 import com.google.inject.Inject;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.text.SimpleDateFormat;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -149,7 +150,7 @@
 
     Map<String, FileDiffOutput> modifiedFiles =
         diffOperations.listModifiedFilesAgainstParent(
-            project, result.getCommit(), /* parentNum= */ 0);
+            project, result.getCommit(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
 
     assertThat(modifiedFiles.keySet()).containsExactly("/COMMIT_MSG", "f.txt");
     assertThat(
@@ -1034,7 +1035,7 @@
   public void intralineEditsAreIdentified() throws Exception {
     // In some corner cases, intra-line diffs produce wrong results. In this case, the algorithm
     // falls back to a single edit covering the whole range.
-    // See: bugs.chromium.org/p/gerrit/issues/detail?id=13563
+    // See: https://issues.gerritcodereview.com/issues/40013030
 
     assume().that(intraline).isTrue();
 
@@ -3022,16 +3023,18 @@
           abbreviateName(parentCommit, 8, testRepo.getRevWalk().getObjectReader());
       headers.add("Parent:     " + parentCommitId + " (" + parentCommit.getShortMessage() + ")");
 
-      SimpleDateFormat dtfmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
       PersonIdent author = c.getAuthorIdent();
-      dtfmt.setTimeZone(author.getTimeZone());
+      DateTimeFormatter fmt =
+          DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z")
+              .withLocale(Locale.US)
+              .withZone(author.getZoneId());
       headers.add("Author:     " + author.getName() + " <" + author.getEmailAddress() + ">");
-      headers.add("AuthorDate: " + dtfmt.format(author.getWhen().getTime()));
+      headers.add("AuthorDate: " + fmt.format(author.getWhenAsInstant()));
 
       PersonIdent committer = c.getCommitterIdent();
-      dtfmt.setTimeZone(committer.getTimeZone());
+      fmt = fmt.withZone(committer.getZoneId());
       headers.add("Commit:     " + committer.getName() + " <" + committer.getEmailAddress() + ">");
-      headers.add("CommitDate: " + dtfmt.format(committer.getWhen().getTime()));
+      headers.add("CommitDate: " + fmt.format(committer.getWhenAsInstant()));
       headers.add("");
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index d3fe83f..2c80333 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -95,12 +95,15 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
+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.query.change.ChangeData;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
-import java.sql.Timestamp;
 import java.text.DateFormat;
 import java.text.SimpleDateFormat;
 import java.util.Collection;
@@ -685,6 +688,26 @@
   }
 
   @Test
+  public void cherryPickWithValidationOptions() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = "Cherry Pick";
+    in.validationOptions = ImmutableMap.of("key", "value");
+
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      gApi.changes().id(r.getChangeId()).current().cherryPickAsInfo(in);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   public void cherryPickToExistingChangeUpdatesCherryPickOf() throws Exception {
     PushOneCommit.Result r1 =
         pushFactory
@@ -1683,10 +1706,13 @@
     }
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   private void assertPersonIdent(GitPerson gitPerson, PersonIdent expectedIdent) {
     assertThat(gitPerson.name).isEqualTo(expectedIdent.getName());
     assertThat(gitPerson.email).isEqualTo(expectedIdent.getEmailAddress());
-    assertThat(gitPerson.date).isEqualTo(new Timestamp(expectedIdent.getWhen().getTime()));
+    assertThat(gitPerson.date.getTime()).isEqualTo(expectedIdent.getWhenAsInstant().toEpochMilli());
     assertThat(gitPerson.tz).isEqualTo(expectedIdent.getTimeZoneOffset());
   }
 
@@ -2077,4 +2103,15 @@
   private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
     return Iterables.transform(r, a -> Account.id(a._accountId));
   }
+
+  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/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 2253202..ba1e1a7 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -1427,6 +1427,34 @@
   }
 
   @Test
+  public void pushToNonVisibleBranchIsRejected() throws Exception {
+    String master = "refs/heads/master";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(master).group(REGISTERED_USERS))
+        .update();
+
+    testRepo.branch("HEAD").commit().message("New Commit 1").insertChangeId().create();
+    // Since the branch is not visible to the caller, the command tries to create the ref resulting
+    // in the command being rejected because the ref already exists.
+    assertPushRejected(
+        pushHead(testRepo, master),
+        master,
+        "Cannot create ref 'refs/heads/master' because it already exists.");
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(master).group(REGISTERED_USERS))
+        .update();
+
+    testRepo.branch("HEAD").commit().message("New Commit 2").insertChangeId().create();
+    assertPushOk(pushHead(testRepo, master), master);
+  }
+
+  @Test
   public void pushSameCommitTwiceUsingMagicBranchBaseOption() throws Exception {
     projectOperations
         .project(project)
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/BUILD b/javatests/com/google/gerrit/acceptance/git/BUILD
index 13311e3..db73f3f 100644
--- a/javatests/com/google/gerrit/acceptance/git/BUILD
+++ b/javatests/com/google/gerrit/acceptance/git/BUILD
@@ -10,7 +10,7 @@
         ":submodule_util",
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/server/git/receive/testing",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
     ],
 ) for f in glob(["*IT.java"])]
 
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index a1974b9..f94aa12 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -406,7 +406,7 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    for (AccessSection s : ImmutableList.copyOf(cfg.getAccessSections())) {
+    for (AccessSection s : cfg.getAccessSections()) {
       if (s.getName().startsWith("refs/heads/")
           || s.getName().startsWith("refs/for/")
           || s.getName().equals("refs/*")) {
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 194f5f9..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;
@@ -125,7 +127,7 @@
     // Remove read permissions for all users besides admin.
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
 
-      for (AccessSection sec : ImmutableList.copyOf(u.getConfig().getAccessSections())) {
+      for (AccessSection sec : u.getConfig().getAccessSections()) {
         u.getConfig()
             .upsertAccessSection(
                 sec.getName(),
@@ -142,7 +144,7 @@
 
     // Remove all read permissions on All-Users.
     try (ProjectConfigUpdate u = updateProject(allUsers)) {
-      for (AccessSection sec : ImmutableList.copyOf(u.getConfig().getAccessSections())) {
+      for (AccessSection sec : u.getConfig().getAccessSections()) {
         u.getConfig()
             .upsertAccessSection(
                 sec.getName(),
@@ -1395,14 +1397,15 @@
   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)) {
-      Collection<Ref> singleRef = ImmutableList.of(repo.exactRef(patchSetRef));
-      Collection<Ref> filteredRefs =
-          permissionBackend
-              .user(user(admin))
-              .project(project)
-              .filter(singleRef, repo, RefFilterOptions.defaults());
+      ImmutableList<Ref> singleRef = ImmutableList.of(repo.exactRef(patchSetRef));
+      ImmutableList<Ref> filteredRefs =
+          ImmutableList.copyOf(
+              permissionBackend
+                  .user(user(admin))
+                  .project(project)
+                  .filter(singleRef, repo, RefFilterOptions.defaults()));
       assertThat(filteredRefs).isEqualTo(singleRef);
     }
   }
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/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 428d4f1..0d751f1 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -35,8 +34,7 @@
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.util.ArrayDeque;
-import java.util.Map;
-import org.apache.commons.lang.RandomStringUtils;
+import org.apache.commons.lang3.RandomStringUtils;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -144,7 +142,6 @@
     gApi.changes().id(id2).current().review(ReviewInput.approve());
     gApi.changes().id(id3).current().review(ReviewInput.approve());
 
-    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
     gApi.changes().id(id1).current().submit();
     ObjectId subRepoId =
         subRepo
@@ -156,25 +153,6 @@
             .getObjectId();
 
     expectToHaveSubmoduleState(superRepo, "master", subKey, subRepoId);
-
-    // As the submodules have changed commits, the superproject tree will be
-    // different, so we cannot directly compare the trees here, so make
-    // assumptions only about the changed branches:
-    assertThat(preview).containsKey(BranchNameKey.create(superKey, "refs/heads/master"));
-    assertThat(preview).containsKey(BranchNameKey.create(subKey, "refs/heads/master"));
-
-    if ((getSubmitType() == SubmitType.CHERRY_PICK)
-        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
-      // each change is updated and the respective target branch is updated:
-      assertThat(preview).hasSize(5);
-    } else if ((getSubmitType() == SubmitType.REBASE_IF_NECESSARY)) {
-      // Either the first is used first as is, then the second and third need
-      // rebasing, or those two stay as is and the first is rebased.
-      // add in 2 master branches, expect 3 or 4:
-      assertThat(preview.size()).isAnyOf(3, 4);
-    } else {
-      assertThat(preview).hasSize(2);
-    }
   }
 
   @Test
@@ -663,12 +641,6 @@
   }
 
   @Test
-  public void branchCircularSubscriptionPreview() throws Exception {
-    testBranchCircularSubscription(
-        changeId -> gApi.changes().id(changeId).current().submitPreview());
-  }
-
-  @Test
   public void projectCircularSubscriptionWholeTopic() throws Exception {
     allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
     allowMatchingSubmoduleSubscription(superKey, "refs/heads/dev", subKey, "refs/heads/dev");
diff --git a/javatests/com/google/gerrit/acceptance/pgm/CopyApprovalsPgmIT.java b/javatests/com/google/gerrit/acceptance/pgm/CopyApprovalsPgmIT.java
deleted file mode 100644
index ea359bf..0000000
--- a/javatests/com/google/gerrit/acceptance/pgm/CopyApprovalsPgmIT.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// 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.truth.Truth.assertThat;
-
-import com.google.gerrit.acceptance.StandaloneSiteTest;
-import com.google.gerrit.acceptance.UseLocalDisk;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.launcher.GerritLauncher;
-import org.junit.Test;
-
-@UseLocalDisk
-public class CopyApprovalsPgmIT extends StandaloneSiteTest {
-  @Test
-  public void programFinishesNormally() throws Exception {
-    // This test checks that we are able to set up the injector for the program and loop over
-    // changes. The actual migration logic is tested elsewhere.
-    Project.NameKey project = Project.nameKey("reindex-project-test");
-    try (ServerContext ctx = startServer()) {
-      GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
-
-      gApi.projects().create(project.get());
-
-      ChangeInput in = new ChangeInput(project.get(), "master", "Test change");
-      in.newBranch = true;
-      gApi.changes().create(in);
-    }
-
-    int exitCode =
-        GerritLauncher.mainImpl(
-            new String[] {
-              "CopyApprovals", "-d", sitePaths.site_path.toString(), "--show-stack-trace"
-            });
-    assertThat(exitCode).isEqualTo(0);
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java b/javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
index 9cdcb40..7d8794b 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
@@ -22,8 +22,6 @@
 import com.google.gerrit.server.index.OnlineUpgradeListener;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.concurrent.CountDownLatch;
 
 class IndexUpgradeController implements OnlineUpgradeListener {
@@ -45,18 +43,18 @@
   private final CountDownLatch started;
   private final CountDownLatch finished;
 
-  private final List<UpgradeAttempt> startedAttempts;
-  private final List<UpgradeAttempt> succeededAttempts;
-  private final List<UpgradeAttempt> failedAttempts;
+  private final ImmutableList.Builder<UpgradeAttempt> startedAttempts;
+  private final ImmutableList.Builder<UpgradeAttempt> succeededAttempts;
+  private final ImmutableList.Builder<UpgradeAttempt> failedAttempts;
 
   IndexUpgradeController(int numExpected) {
     this.numExpected = numExpected;
     readyToStart = new CountDownLatch(1);
     started = new CountDownLatch(numExpected);
     finished = new CountDownLatch(numExpected);
-    startedAttempts = new ArrayList<>();
-    succeededAttempts = new ArrayList<>();
-    failedAttempts = new ArrayList<>();
+    startedAttempts = ImmutableList.builder();
+    succeededAttempts = ImmutableList.builder();
+    failedAttempts = ImmutableList.builder();
   }
 
   Module module() {
@@ -93,7 +91,7 @@
     finish(UpgradeAttempt.create(name, oldVersion, newVersion), failedAttempts);
   }
 
-  private synchronized void finish(UpgradeAttempt a, List<UpgradeAttempt> out) {
+  private synchronized void finish(UpgradeAttempt a, ImmutableList.Builder<UpgradeAttempt> out) {
     checkState(readyToStart.getCount() == 0, "shouldn't be finishing upgrade before starting");
     checkState(
         finished.getCount() > 0, "already finished %s upgrades, can't finish %s", numExpected, a);
@@ -108,14 +106,14 @@
   }
 
   synchronized ImmutableList<UpgradeAttempt> getStartedAttempts() {
-    return ImmutableList.copyOf(startedAttempts);
+    return startedAttempts.build();
   }
 
   synchronized ImmutableList<UpgradeAttempt> getSucceededAttempts() {
-    return ImmutableList.copyOf(succeededAttempts);
+    return succeededAttempts.build();
   }
 
   synchronized ImmutableList<UpgradeAttempt> getFailedAttempts() {
-    return ImmutableList.copyOf(failedAttempts);
+    return failedAttempts.build();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
index ed5e559..c868d0b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
 import org.apache.http.message.BasicHeader;
@@ -194,6 +195,7 @@
 
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   public void abortIfServerDeadlineExceeded() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent()).isEqualTo("Server Deadline Exceeded\n\ntimeout=1ms");
@@ -203,6 +205,7 @@
   @GerritConfig(name = "deadline.foo.timeout", value = "1ms")
   @GerritConfig(name = "deadline.bar.timeout", value = "100ms")
   public void stricterDeadlineTakesPrecedence() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -213,6 +216,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.requestType", value = "REST")
   public void abortIfServerDeadlineExceeded_requestType() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -223,6 +227,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.requestUriPattern", value = "/projects/.*")
   public void abortIfServerDeadlineExceeded_requestUriPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -235,6 +240,7 @@
       name = "deadline.default.excludedRequestUriPattern",
       value = "/projects/non-matching")
   public void abortIfServerDeadlineExceeded_excludedRequestUriPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -249,6 +255,7 @@
       value = "/projects/non-matching")
   public void abortIfServerDeadlineExceeded_requestUriPatternAndExcludedRequestUriPattern()
       throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -259,6 +266,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.projectPattern", value = ".*new.*")
   public void abortIfServerDeadlineExceeded_projectPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -269,6 +277,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.account", value = "1000000")
   public void abortIfServerDeadlineExceeded_account() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -279,6 +288,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.requestType", value = "SSH")
   public void nonMatchingServerDeadlineIsIgnored_requestType() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -287,6 +297,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.requestUriPattern", value = "/changes/.*")
   public void nonMatchingServerDeadlineIsIgnored_requestUriPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -295,6 +306,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.excludedRequestUriPattern", value = "/projects/.*")
   public void nonMatchingServerDeadlineIsIgnored_excludedRequestUriPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -305,6 +317,7 @@
   @GerritConfig(name = "deadline.default.excludedRequestUriPattern", value = "/projects/.*new")
   public void nonMatchingServerDeadlineIsIgnored_requestUriPatternAndExcludedRequestUriPattern()
       throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -313,6 +326,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.projectPattern", value = ".*foo.*")
   public void nonMatchingServerDeadlineIsIgnored_projectPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -321,6 +335,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.account", value = "999")
   public void nonMatchingServerDeadlineIsIgnored_account() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -329,6 +344,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.isAdvisory", value = "true")
   public void advisoryServerDeadlineIsIgnored() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -338,6 +354,7 @@
   @GerritConfig(name = "deadline.test.isAdvisory", value = "true")
   @GerritConfig(name = "deadline.default.timeout", value = "2ms")
   public void nonAdvisoryDeadlineIsAppliedIfStricterAdvisoryDeadlineExists() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(4));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
     assertThat(response.getEntityContent())
@@ -347,6 +364,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1")
   public void invalidServerDeadlineIsIgnored_missingTimeUnit() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -354,6 +372,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1x")
   public void invalidServerDeadlineIsIgnored_invalidTimeUnit() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -369,6 +388,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.requestType", value = "INVALID")
   public void invalidServerDeadlineIsIgnored_invalidRequestType() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -377,6 +397,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.requestUriPattern", value = "][")
   public void invalidServerDeadlineIsIgnored_invalidRequestUriPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -385,6 +406,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.excludedRequestUriPattern", value = "][")
   public void invalidServerDeadlineIsIgnored_invalidExcludedRequestUriPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -393,6 +415,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.projectPattern", value = "][")
   public void invalidServerDeadlineIsIgnored_invalidProjectPattern() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -401,6 +424,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.account", value = "invalid")
   public void invalidServerDeadlineIsIgnored_invalidAccount() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -416,6 +440,7 @@
   @GerritConfig(name = "deadline.default.timeout", value = "0ms")
   @GerritConfig(name = "deadline.default.requestType", value = "REST")
   public void deadlineConfigWithZeroTimeoutIsIgnored() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
     response.assertCreated();
   }
@@ -449,6 +474,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   public void clientProvidedDeadlineOverridesServerDeadline() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response =
         adminRestSession.putWithHeaders(
             "/projects/" + name("new"), new BasicHeader(RestApiServlet.X_GERRIT_DEADLINE, "2ms"));
@@ -460,6 +486,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   public void clientCanDisableDeadlineBySettingZeroAsDeadline() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response =
         adminRestSession.putWithHeaders(
             "/projects/" + name("new"), new BasicHeader(RestApiServlet.X_GERRIT_DEADLINE, "0"));
@@ -574,6 +601,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   public void abortPushIfServerDeadlineExceeded() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertErrorStatus("Server Deadline Exceeded (default.timeout=1ms)");
@@ -582,6 +610,7 @@
   @Test
   @GerritConfig(name = "receive.timeout", value = "1ms")
   public void abortPushIfTimeoutExceeded() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertErrorStatus("Server Deadline Exceeded (receive.timeout=1ms)");
@@ -591,6 +620,7 @@
   @GerritConfig(name = "receive.timeout", value = "1ms")
   @GerritConfig(name = "deadline.default.timeout", value = "10s")
   public void receiveTimeoutTakesPrecedence() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
     r.assertErrorStatus("Server Deadline Exceeded (receive.timeout=1ms)");
@@ -649,6 +679,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   public void clientProvidedDeadlineOnPushOverridesServerDeadline() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     List<String> pushOptions = new ArrayList<>();
     pushOptions.add("deadline=2ms");
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
@@ -660,6 +691,7 @@
   @Test
   @GerritConfig(name = "receive.timeout", value = "1ms")
   public void clientProvidedDeadlineOnPushDoesntOverrideServerTimeout() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     List<String> pushOptions = new ArrayList<>();
     pushOptions.add("deadline=10m");
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
@@ -671,6 +703,7 @@
   @Test
   @GerritConfig(name = "deadline.default.timeout", value = "1ms")
   public void clientCanDisableDeadlineOnPushBySettingZeroAsDeadline() throws Exception {
+    testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     List<String> pushOptions = new ArrayList<>();
     pushOptions.add("deadline=0");
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
index be4fde0..e1e5b85 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
@@ -57,7 +57,7 @@
       RestResponse r = userRestSession.get("/accounts/self/capabilities");
       r.assertOK();
       CapabilityInfo info =
-          (new Gson()).fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
+          new Gson().fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
       for (String c : GlobalCapability.getAllNames()) {
         if (ADMINISTRATE_SERVER.equals(c)) {
           assertThat(info.administrateServer).isFalse();
@@ -87,7 +87,7 @@
     RestResponse r = adminRestSession.get("/accounts/self/capabilities");
     r.assertOK();
     CapabilityInfo info =
-        (new Gson()).fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
+        new Gson().fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
     for (String c : GlobalCapability.getAllNames()) {
       if (BATCH_CHANGES_LIMIT.equals(c)) {
         // It does not have default value for any user as it can override the
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 1e89a85..39a32af 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
@@ -66,7 +65,6 @@
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.testing.ConfigSuite;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -83,7 +81,6 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
@@ -106,20 +103,6 @@
   @Inject private ExternalIdKeyFactory externalIdKeyFactory;
   @Inject private ExternalIdFactory externalIdFactory;
 
-  @ConfigSuite.Default
-  public static Config partialCacheReloadingEnabled() {
-    Config cfg = new Config();
-    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", true);
-    return cfg;
-  }
-
-  @ConfigSuite.Config
-  public static Config partialCacheReloadingDisabled() {
-    Config cfg = new Config();
-    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", false);
-    return cfg;
-  }
-
   @Test
   public void getExternalIds() throws Exception {
     Collection<ExternalId> expectedIds = getAccountState(user.id()).externalIds();
@@ -732,38 +715,7 @@
   }
 
   @Test
-  public void checkNoReloadAfterUpdate() throws Exception {
-    Set<ExternalId> expectedExtIds = new HashSet<>(externalIds.byAccount(admin.id()));
-    try (AutoCloseable ctx = createFailOnLoadContext()) {
-      // insert external ID
-      ExternalId extId = externalIdFactory.create("foo", "bar", admin.id());
-      insertExtId(extId);
-      expectedExtIds.add(extId);
-      assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExtIds);
-
-      // update external ID
-      expectedExtIds.remove(extId);
-      ExternalId extId2 =
-          externalIdFactory.createWithEmail("foo", "bar", admin.id(), "foo.bar@example.com");
-      accountsUpdateProvider
-          .get()
-          .update("Update External ID", admin.id(), u -> u.updateExternalId(extId2));
-      expectedExtIds.add(extId2);
-      assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExtIds);
-
-      // delete external ID
-      accountsUpdateProvider
-          .get()
-          .update("Delete External ID", admin.id(), u -> u.deleteExternalId(extId));
-      expectedExtIds.remove(extId2);
-      assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExtIds);
-    }
-  }
-
-  @Test
   public void byAccountFailIfReadingExternalIdsFails() throws Exception {
-    assume().that(isPartialCacheReloadingEnabled()).isFalse();
-
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // update external ID branch so that external IDs need to be reloaded
       insertExtIdBehindGerritsBack(externalIdFactory.create("foo", "bar", admin.id()));
@@ -774,8 +726,6 @@
 
   @Test
   public void byEmailFailIfReadingExternalIdsFails() throws Exception {
-    assume().that(isPartialCacheReloadingEnabled()).isFalse();
-
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // update external ID branch so that external IDs need to be reloaded
       insertExtIdBehindGerritsBack(externalIdFactory.create("foo", "bar", admin.id()));
@@ -1033,10 +983,6 @@
     extIdNotes.commit(md);
   }
 
-  private boolean isPartialCacheReloadingEnabled() {
-    return cfg.getBoolean("cache", "external_ids_map", "enablePartialReloads", true);
-  }
-
   private void insertExtId(ExternalId extId) throws Exception {
     accountsUpdateProvider
         .get()
@@ -1059,7 +1005,7 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       // Inserting an external ID "behind Gerrit's back" means that the caches are not updated.
       ExternalIdNotes extIdNotes =
-          ExternalIdNotes.loadNoCacheUpdate(
+          ExternalIdNotes.load(
               allUsers, repo, externalIdFactory, IS_USER_NAME_CASE_INSENSITIVE_MIGRATION_MODE);
       extIdNotes.insert(extId);
       try (MetaDataUpdate metaDataUpdate =
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index a1e9bf1..c89e11a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -59,7 +59,7 @@
     AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
     assertAccountInfo(admin, info);
     Account account = getAccount(admin.id());
-    assertThat(info.registeredOn).isEqualTo(account.registeredOn());
+    assertThat(info.registeredOn.getTime()).isEqualTo(account.registeredOn().toEpochMilli());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 79484ca..f3b13d2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -145,7 +145,6 @@
           RestCall.get("/changes/%s/revisions/%s/related"),
           RestCall.get("/changes/%s/revisions/%s/review"),
           RestCall.post("/changes/%s/revisions/%s/review"),
-          RestCall.get("/changes/%s/revisions/%s/preview_submit"),
           RestCall.post("/changes/%s/revisions/%s/submit"),
           RestCall.get("/changes/%s/revisions/%s/submit_type"),
           RestCall.post("/changes/%s/revisions/%s/test.submit_rule"),
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 4861d16..0e4f212 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -82,7 +82,6 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -105,10 +104,8 @@
 import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.DiffFormatter;
@@ -149,162 +146,25 @@
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change = createChange();
     assertThat(change.getCommit().getParents()).isEmpty();
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
-    assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
     assertThat(projectOperations.project(project).getHead("master").getId())
         .isEqualTo(change.getCommit());
-    assertTrees(project, actual);
   }
 
   @Test
   public void submitSingleChange() throws Throwable {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSubmit).isEqualTo(initialHead);
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
 
-    if ((getSubmitType() == SubmitType.CHERRY_PICK)
-        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
-      // The change is updated as well:
-      assertThat(actual).hasSize(2);
-    } else {
-      assertThat(actual).hasSize(1);
-    }
-
     submit(change.getChangeId());
-    assertTrees(project, actual);
-  }
-
-  @Test
-  public void submitMultipleChangesOtherMergeConflictPreview() throws Throwable {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
-    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
-    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
-    // change 2 is not approved, but we ignore labels
-    approve(change3.getChangeId());
-
-    try (BinaryResult request =
-        gApi.changes().id(change4.getChangeId()).current().submitPreview()) {
-      assertThat(getSubmitType()).isEqualTo(SubmitType.CHERRY_PICK);
-      submit(change4.getChangeId());
-    } catch (RestApiException e) {
-      switch (getSubmitType()) {
-        case FAST_FORWARD_ONLY:
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Failed to submit 3 changes due to the following problems:\n"
-                      + "Change "
-                      + change2.getChange().getId()
-                      + ": Project policy "
-                      + "requires all submissions to be a fast-forward. Please "
-                      + "rebase the change locally and upload again for review.\n"
-                      + "Change "
-                      + change3.getChange().getId()
-                      + ": Project policy "
-                      + "requires all submissions to be a fast-forward. Please "
-                      + "rebase the change locally and upload again for review.\n"
-                      + "Change "
-                      + change4.getChange().getId()
-                      + ": Project policy "
-                      + "requires all submissions to be a fast-forward. Please "
-                      + "rebase the change locally and upload again for review.");
-          break;
-        case REBASE_IF_NECESSARY:
-        case REBASE_ALWAYS:
-          String change2hash = change2.getChange().currentPatchSet().commitId().name();
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Cannot rebase "
-                      + change2hash
-                      + ": The change could "
-                      + "not be rebased due to a conflict during merge.\n\n"
-                      + "merge conflict(s):\n"
-                      + "a.txt");
-          break;
-        case MERGE_ALWAYS:
-        case MERGE_IF_NECESSARY:
-        case INHERIT:
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Failed to submit 3 changes due to the following problems:\n"
-                      + "Change "
-                      + change2.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.\n"
-                      + "Change "
-                      + change3.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.\n"
-                      + "Change "
-                      + change4.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.");
-          break;
-        case CHERRY_PICK:
-        default:
-          assertWithMessage("Should not reach here.").fail();
-          break;
-      }
-
-      RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
-      assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
-      assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-      assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
-    }
-  }
-
-  @Test
-  public void submitMultipleChangesPreview() throws Throwable {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
-    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
-    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
-    // change 2 is not approved, but we ignore labels
-    approve(change3.getChangeId());
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change4.getChangeId());
-    Map<String, Map<String, Integer>> expected = new HashMap<>();
-    expected.put(project.get(), new HashMap<>());
-    expected.get(project.get()).put("refs/heads/master", 3);
-
-    assertThat(actual).containsKey(BranchNameKey.create(project, "refs/heads/master"));
-    if (getSubmitType() == SubmitType.CHERRY_PICK) {
-      // CherryPick ignores dependencies, thus only change and destination
-      // branch refs are modified.
-      assertThat(actual).hasSize(2);
-    } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
-      // RebaseAlways takes care of dependencies, therefore Change{2,3,4} and
-      // destination branch will be modified.
-      assertThat(actual).hasSize(4);
-    } else {
-      assertThat(actual).hasSize(1);
-    }
-
-    // check that the submit preview did not actually submit
-    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
-    assertThat(headAfterSubmit).isEqualTo(initialHead);
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents();
-
-    // now check we actually have the same content:
-    approve(change2.getChangeId());
-    submit(change4.getChangeId());
-    assertTrees(project, actual);
+    headAfterSubmit = projectOperations.project(project).getHead("master");
+    assertThat(headAfterSubmit).isNotEqualTo(initialHead);
   }
 
   /**
@@ -1239,14 +1099,11 @@
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change = createChange();
     assertThat(change.getCommit().getParents()).isEmpty();
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
-    assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
     assertThat(projectOperations.project(project).getHead("master").getId())
         .isEqualTo(change.getCommit());
-    assertTrees(project, actual);
   }
 
   @Test
@@ -1260,20 +1117,17 @@
     change.assertOkStatus();
     assertThat(change.getCommit().getTree()).isEqualTo(EMPTY_TREE_ID);
 
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
-    assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
     assertThat(projectOperations.project(project).getHead("master").getId())
         .isEqualTo(change.getCommit());
-    assertTrees(project, actual);
   }
 
   private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Throwable {
     for (PushOneCommit.Result change : changes) {
       try (BatchUpdate bu =
-          batchUpdateFactory.create(project, userFactory.create(admin.id()), TimeUtil.nowTs())) {
+          batchUpdateFactory.create(project, userFactory.create(admin.id()), TimeUtil.now())) {
         bu.addOp(
             change.getChange().getId(),
             new BatchUpdateOp() {
@@ -1396,8 +1250,8 @@
     submit(r.getChangeId());
     assertThat(r.getChange().getMergedOn()).isPresent();
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.updated);
-    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.submitted);
+    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.getUpdated());
+    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.getSubmitted());
   }
 
   @Override
@@ -1508,9 +1362,10 @@
   }
 
   protected void assertAuthorAndCommitDateEquals(RevCommit commit) {
-    assertThat(commit.getAuthorIdent().getWhen()).isEqualTo(commit.getCommitterIdent().getWhen());
-    assertThat(commit.getAuthorIdent().getTimeZone())
-        .isEqualTo(commit.getCommitterIdent().getTimeZone());
+    assertThat(commit.getAuthorIdent().getWhenAsInstant())
+        .isEqualTo(commit.getCommitterIdent().getWhenAsInstant());
+    assertThat(commit.getAuthorIdent().getZoneId())
+        .isEqualTo(commit.getCommitterIdent().getZoneId());
   }
 
   protected void assertSubmitter(String changeId, int psId) throws Throwable {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index e35f758..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();
@@ -116,7 +117,15 @@
       assertThat(info.enabled).isNull();
       assertThat(info.label).isEqualTo("Submit whole topic");
       assertThat(info.method).isEqualTo("POST");
-      assertThat(info.title).matches("Change " + legacyId2 + " is not ready: needs Code-Review");
+      assertThat(info.title)
+          .startsWith(
+              "Change "
+                  + change1.getChange().getId()
+                  + " must be submitted with change "
+                  + legacyId2
+                  + " but "
+                  + legacyId2
+                  + " is not ready: submit requirement 'Code-Review' is unsatisfied.");
     } else {
       noSubmitWholeTopicAssertions(actions, 1);
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 576b5512..e013267 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
@@ -261,6 +262,12 @@
             fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed");
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
 
+    // The removal also shows up in AttentionSetInfo.
+    AttentionSetInfo attentionSetInfo =
+        Iterables.getOnlyElement(change(r).get().removedFromAttentionSet.values());
+    assertThat(attentionSetInfo.reason).isEqualTo("removed");
+    assertThat(attentionSetInfo.account).isEqualTo(getAccountInfo(user.id()));
+
     // Second removal is ignored.
     fakeClock.advance(Duration.ofSeconds(42));
     change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed again"));
@@ -1927,6 +1934,30 @@
   }
 
   @Test
+  public void deleteVotesDoesNotAffectAttentionSetWhenIgnoreAutomaticRulesIsSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    DeleteVoteInput deleteVoteInput = new DeleteVoteInput();
+    deleteVoteInput.label = LabelId.CODE_REVIEW;
+
+    // set this to true to not change the attention set.
+    deleteVoteInput.ignoreAutomaticAttentionSetRules = true;
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .reviewer(user.id().toString())
+        .deleteVote(deleteVoteInput);
+
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
   public void deleteVotesOfOthersAddThemToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -2031,7 +2062,7 @@
     comment.side = Side.REVISION;
     comment.path = Patch.COMMIT_MSG;
     comment.message = "comment";
-    comment.updated = TimeUtil.nowTs();
+    comment.setUpdated(TimeUtil.now());
     comment.inReplyTo = id;
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index 88e5f10..70a3cf2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -65,9 +65,9 @@
       gApi.changes().id(r.getChangeId()).addReviewer(input);
 
       ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
-      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
+      assertThat(info.reviewers).containsExactly(state, ImmutableList.of(acc));
       // All reviewers added by email should be removable
-      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(acc));
+      assertThat(info.removableReviewers).containsExactly(acc);
     }
   }
 
@@ -92,7 +92,7 @@
       ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
       assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(byId, byEmail)));
       // All reviewers (both by id and by email) should be removable
-      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(byId, byEmail));
+      assertThat(info.removableReviewers).containsExactly(byId, byEmail);
     }
   }
 
@@ -368,6 +368,6 @@
   }
 
   private static String toRfcAddressString(AccountInfo info) {
-    return (Address.create(info.name, info.email)).toString();
+    return Address.create(info.name, info.email).toString();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index b0a14cf..dbebbf9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -27,6 +27,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
+import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -593,7 +594,7 @@
 
       PersonIdent expectedAuthor =
           changeNoteUtil.newAccountIdIdent(
-              getAccount(admin.id()).id(), c.created, serverIdent.get());
+              getAccount(admin.id()).id(), c.created.toInstant(), serverIdent.get());
       assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
 
       assertThat(commit.getCommitterIdent())
@@ -1085,7 +1086,7 @@
     ChangeInfo out = gApi.changes().create(in).get();
     assertThat(out.project).isEqualTo(in.project);
     assertThat(RefNames.fullName(out.branch)).isEqualTo(RefNames.fullName(in.branch));
-    assertThat(out.subject).isEqualTo(in.subject.split("\n")[0]);
+    assertThat(out.subject).isEqualTo(Splitter.on("\n").splitToList(in.subject).get(0));
     assertThat(out.topic).isEqualTo(in.topic);
     assertThat(out.status).isEqualTo(in.status);
     if (in.isPrivate) {
@@ -1109,7 +1110,7 @@
     ChangeInfo out = gApi.changes().createAsInfo(in);
     assertThat(out.project).isEqualTo(in.project);
     assertThat(RefNames.fullName(out.branch)).isEqualTo(RefNames.fullName(in.branch));
-    assertThat(out.subject).isEqualTo(in.subject.split("\n")[0]);
+    assertThat(out.subject).isEqualTo(Splitter.on("\n").splitToList(in.subject).get(0));
     assertThat(out.topic).isEqualTo(in.topic);
     assertThat(out.status).isEqualTo(in.status);
     if (in.isPrivate) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
index 29dd227..26e37f4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
@@ -60,6 +60,21 @@
   }
 
   @Test
+  public void metaDiffSubmitReq() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
+    ChangeInfo oldInfo = chApi.get();
+    chApi.setHashtags(new HashtagsInput(ImmutableSet.of(HASHTAG)));
+    ChangeInfo newInfo = chApi.get();
+
+    ChangeInfoDifference difference =
+        chApi.metaDiff(oldInfo.metaRevId, newInfo.metaRevId, ListChangesOption.SUBMIT_REQUIREMENTS);
+
+    assertThat(difference.added().submitRequirements).isNull();
+    assertThat(difference.removed().submitRequirements).isNull();
+  }
+
+  @Test
   public void metaDiffReturnsSuccessful() throws Exception {
     PushOneCommit.Result ch = createChange();
     ChangeInfo info = gApi.changes().id(ch.getChangeId()).get();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 58e48e9..3be49df 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -99,7 +99,7 @@
         "Failed to submit 2 changes due to the following problems:\n"
             + "Change "
             + id1
-            + ": needs Code-Review");
+            + ": submit requirement 'Code-Review' is unsatisfied.");
 
     RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 157c93c..c4f8f2c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -38,22 +38,10 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.inject.Inject;
-import java.io.File;
-import java.io.InputStream;
-import java.nio.file.Files;
-import java.util.ArrayList;
 import java.util.List;
-import java.util.Map;
-import java.util.zip.GZIPInputStream;
-import org.apache.commons.compress.archivers.ArchiveStreamFactory;
-import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
-import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
 import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
@@ -184,8 +172,6 @@
     approve(change2b.getChangeId());
     approve(change3.getChangeId());
 
-    // get a preview before submitting:
-    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
     submit(change1b.getChangeId());
 
     RevCommit tip1 = getRemoteLog(p1, "master").get(0);
@@ -197,23 +183,9 @@
     if (isSubmitWholeTopicEnabled()) {
       assertThat(tip2.getShortMessage()).isEqualTo(change2b.getCommit().getShortMessage());
       assertThat(tip3.getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
-
-      // check that the preview matched what happened:
-      assertThat(preview).hasSize(3);
-
-      assertThat(preview).containsKey(BranchNameKey.create(p1, "refs/heads/master"));
-      assertTrees(p1, preview);
-
-      assertThat(preview).containsKey(BranchNameKey.create(p2, "refs/heads/master"));
-      assertTrees(p2, preview);
-
-      assertThat(preview).containsKey(BranchNameKey.create(p3, "refs/heads/master"));
-      assertTrees(p3, preview);
     } else {
       assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
       assertThat(tip3.getShortMessage()).isEqualTo(initialHead3.getShortMessage());
-      assertThat(preview).hasSize(1);
-      assertThat(preview.get(BranchNameKey.create(p1, "refs/heads/master"))).isNotNull();
     }
   }
 
@@ -281,13 +253,6 @@
               + "merged due to a path conflict. Please rebase the change locally "
               + "and upload the rebased commit for review.";
 
-      // Get a preview before submitting:
-      RestApiException thrown =
-          assertThrows(
-              RestApiException.class,
-              () -> gApi.changes().id(change1b.getChangeId()).current().submitPreview().close());
-      assertThat(thrown.getMessage()).isEqualTo(msg);
-
       submitWithConflict(change1b.getChangeId(), msg);
     } else {
       submit(change1b.getChangeId());
@@ -756,34 +721,4 @@
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
   }
-
-  @Test
-  public void testPreviewSubmitTgz() throws Throwable {
-    Project.NameKey p1 = projectOperations.newProject().create();
-
-    TestRepository<?> repo1 = cloneProject(p1);
-    PushOneCommit.Result change1 = createChange(repo1, "master", "test", "a.txt", "1", "topic");
-    approve(change1.getChangeId());
-
-    // get a preview before submitting:
-    File tempfile;
-    try (BinaryResult request =
-        gApi.changes().id(change1.getChangeId()).current().submitPreview("tgz")) {
-      assertThat(request.getContentType()).isEqualTo("application/x-gzip");
-      tempfile = File.createTempFile("test", null);
-      request.writeTo(Files.newOutputStream(tempfile.toPath()));
-    }
-
-    InputStream is = new GZIPInputStream(Files.newInputStream(tempfile.toPath()));
-
-    List<String> untarredFiles = new ArrayList<>();
-    try (TarArchiveInputStream tarInputStream =
-        (TarArchiveInputStream) new ArchiveStreamFactory().createArchiveInputStream("tar", is)) {
-      TarArchiveEntry entry;
-      while ((entry = (TarArchiveEntry) tarInputStream.getNextEntry()) != null) {
-        untarredFiles.add(entry.getName());
-      }
-    }
-    assertThat(untarredFiles).containsExactly(p1.get() + ".git");
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index aa93815..2eade27 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project.NameKey;
-import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
+import com.google.gerrit.exceptions.MergeUpdateException;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -136,8 +136,8 @@
     ChangeMessageModifier modifier2 = (msg, orig, tip, dest) -> msg + "A-footer: value\n";
     try (Registration registration =
         extensionRegistry.newRegistration().add(modifier1).add(modifier2)) {
-      InternalServerWithUserMessageException thrown =
-          assertThrows(InternalServerWithUserMessageException.class, () -> submitWithRebase());
+      MergeUpdateException thrown =
+          assertThrows(MergeUpdateException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause).hasMessageThat().isEqualTo("boom");
@@ -153,8 +153,8 @@
             .newRegistration()
             .add(modifier1, "modifier-1")
             .add(modifier2, "modifier-2")) {
-      InternalServerWithUserMessageException thrown =
-          assertThrows(InternalServerWithUserMessageException.class, () -> submitWithRebase());
+      MergeUpdateException thrown =
+          assertThrows(MergeUpdateException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause)
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index a63d60a..0a9a098 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -306,7 +306,10 @@
   private void assertChangeSetMergeable(ChangeData change, boolean expected)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
           PermissionBackendException {
-    ChangeSet cs = mergeSuperSet.get().completeChangeSet(change.change(), user(admin));
+    ChangeSet cs =
+        mergeSuperSet
+            .get()
+            .completeChangeSet(change.change(), user(admin), /* includingTopicClosure= */ false);
     assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 97288a8..8131352 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -192,6 +192,9 @@
 
     // user
     assertThat(i.user.anonymousCowardName).isEqualTo(AnonymousCowardNameProvider.DEFAULT);
+
+    // submit requirement columns in dashboard
+    assertThat(i.submitRequirementDashboardColumns).isEmpty();
   }
 
   @Test
@@ -202,6 +205,15 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "dashboard.submitRequirementColumns",
+      values = {"Code-Review", "Verified"})
+  public void serverConfigWithMultipleSubmitRequirementColumn() throws Exception {
+    ServerInfo i = gApi.config().server().getInfo();
+    assertThat(i.submitRequirementDashboardColumns).containsExactly("Code-Review", "Verified");
+  }
+
+  @Test
   @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
   public void mergeabilityComputationBehavior_neverCompute() throws Exception {
     ServerInfo i = gApi.config().server().getInfo();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
index 531357a..793f256 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.ANNOTATED;
 import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.LIGHTWEIGHT;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
@@ -29,6 +30,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.testing.ConfigSuite;
@@ -191,33 +193,57 @@
     pushTagDeletion(tagName, Status.OK);
   }
 
+  @Test
+  public void pushToNonVisibleTagIsRejected() throws Exception {
+    allowTagCreation();
+    allowPushOnRefsTags();
+
+    String tagName = pushTagForExistingCommit(Status.OK);
+
+    removeReadFromRefsTags();
+    removeReadFromRefsHeads();
+
+    pushTag(
+        tagName,
+        /* newCommit= */ true,
+        /* force= */ false,
+        Status.REJECTED_OTHER_REASON,
+        /* expectedMessage= */ String.format(
+            "Cannot create ref '%s' because it already exists.", tagRef(tagName)));
+  }
+
   private String pushTagForExistingCommit(Status expectedStatus) throws Exception {
-    return pushTag(null, false, false, expectedStatus);
+    return pushTag(null, false, false, expectedStatus, /* expectedMessage= */ null);
   }
 
   private String pushTagForNewCommit(Status expectedStatus) throws Exception {
-    return pushTag(null, true, false, expectedStatus);
+    return pushTag(null, true, false, expectedStatus, /* expectedMessage= */ null);
   }
 
   private void fastForwardTagToExistingCommit(String tagName, Status expectedStatus)
       throws Exception {
-    pushTag(tagName, false, false, expectedStatus);
+    pushTag(tagName, false, false, expectedStatus, /* expectedMessage= */ null);
   }
 
   private void fastForwardTagToNewCommit(String tagName, Status expectedStatus) throws Exception {
-    pushTag(tagName, true, false, expectedStatus);
+    pushTag(tagName, true, false, expectedStatus, /* expectedMessage= */ null);
   }
 
   private void forceUpdateTagToExistingCommit(String tagName, Status expectedStatus)
       throws Exception {
-    pushTag(tagName, false, true, expectedStatus);
+    pushTag(tagName, false, true, expectedStatus, /* expectedMessage= */ null);
   }
 
   private void forceUpdateTagToNewCommit(String tagName, Status expectedStatus) throws Exception {
-    pushTag(tagName, true, true, expectedStatus);
+    pushTag(tagName, true, true, expectedStatus, /* expectedMessage= */ null);
   }
 
-  private String pushTag(String tagName, boolean newCommit, boolean force, Status expectedStatus)
+  private String pushTag(
+      String tagName,
+      boolean newCommit,
+      boolean force,
+      Status expectedStatus,
+      @Nullable String expectedMessage)
       throws Exception {
     if (force) {
       testRepo.reset(initialHead);
@@ -256,6 +282,9 @@
             : GitUtil.pushTag(testRepo, tagName, !createTag);
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
     assertWithMessage(tagType.name()).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
+    if (expectedMessage != null) {
+      assertWithMessage(tagType.name()).that(refUpdate.getMessage()).isEqualTo(expectedMessage);
+    }
     return tagName;
   }
 
@@ -352,6 +381,22 @@
         .update();
   }
 
+  private void removeReadFromRefsTags() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+  }
+
+  private void removeReadFromRefsHeads() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+  }
+
   private void commit(PersonIdent ident, String subject) throws Exception {
     commitBuilder().ident(ident).message(subject + " (" + System.nanoTime() + ")").create();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index edcb1f9..1fcc69a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -10,7 +10,7 @@
         ":project",
         ":push_tag_util",
         ":refassert",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
     ],
 ) for f in glob(["*IT.java"])]
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 33a7dc5..df899ce 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -16,6 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.entities.RefNames.REFS_HEADS;
@@ -24,8 +27,10 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 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.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
@@ -58,6 +63,7 @@
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -415,6 +421,87 @@
   }
 
   @Test
+  public void createBranchViaRestApiFailsIfCommitIsInvalid() throws Exception {
+    BranchInput input = new BranchInput();
+    input.ref = "new";
+
+    TestRefOperationValidationListener testRefOperationValidationListener =
+        new TestRefOperationValidationListener();
+    testRefOperationValidationListener.doReject = true;
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRefOperationValidationListener)) {
+      ResourceConflictException ex =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.projects().name(project.get()).branch(input.ref).create(input));
+      assertThat(ex)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "Validation for creation of ref 'refs/heads/new' in project %s failed:\n%s",
+                  project, TestRefOperationValidationListener.FAILURE_MESSAGE));
+    }
+  }
+
+  @Test
+  public void createBranchViaRestApiWithValidationOptions() throws Exception {
+    BranchInput input = new BranchInput();
+    input.ref = "new";
+    input.validationOptions = ImmutableMap.of("key", "value");
+
+    TestRefOperationValidationListener testRefOperationValidationListener =
+        new TestRefOperationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRefOperationValidationListener)) {
+      gApi.projects().name(project.get()).branch(input.ref).create(input);
+      assertThat(testRefOperationValidationListener.refReceivedEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
+  public void createBranchViaPushFailsIfCommitIsInvalid() throws Exception {
+    TestRefOperationValidationListener testRefOperationValidationListener =
+        new TestRefOperationValidationListener();
+    testRefOperationValidationListener.doReject = true;
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRefOperationValidationListener)) {
+      PushResult r =
+          pushHead(
+              testRepo,
+              "refs/heads/new",
+              /* pushTags= */ false,
+              /* force= */ false,
+              /* pushOptions= */ ImmutableList.of());
+      assertPushRejected(
+          r,
+          "refs/heads/new",
+          String.format(
+              "Validation for creation of ref 'refs/heads/new' in project %s failed:\n%s",
+              project, TestRefOperationValidationListener.FAILURE_MESSAGE));
+    }
+  }
+
+  @Test
+  public void createBranchViaPushWithValidationOptions() throws Exception {
+    TestRefOperationValidationListener testRefOperationValidationListener =
+        new TestRefOperationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRefOperationValidationListener)) {
+      PushResult r =
+          pushHead(
+              testRepo,
+              "refs/heads/new",
+              /* pushTags= */ false,
+              /* force= */ false,
+              /* pushOptions= */ ImmutableList.of("key=value"));
+      assertPushOk(r, "refs/heads/new");
+      assertThat(testRefOperationValidationListener.refReceivedEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   public void createBranchRevisionVisibility() throws Exception {
     AccountGroup.UUID privilegedGroupUuid =
         groupOperations.newGroup().name(name("privilegedGroup")).create();
@@ -502,4 +589,24 @@
       assertThat(thrown).hasMessageThat().contains(errMsg);
     }
   }
+
+  private static class TestRefOperationValidationListener
+      implements RefOperationValidationListener {
+    static final String FAILURE_MESSAGE = "failure from test";
+
+    public boolean doReject;
+    public RefReceivedEvent refReceivedEvent;
+
+    @Override
+    public List<ValidationMessage> onRefOperation(RefReceivedEvent refReceivedEvent)
+        throws ValidationException {
+      this.refReceivedEvent = refReceivedEvent;
+
+      if (doReject) {
+        throw new ValidationException(FAILURE_MESSAGE);
+      }
+
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index dfe69f9..755c2e1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -224,6 +224,19 @@
   }
 
   @Test
+  public void createWithNameAndDescription() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.description = "Foo label description";
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+
+    assertThat(createdLabel.name).isEqualTo("Foo");
+    assertThat(createdLabel.description).isEqualTo("Foo label description");
+  }
+
+  @Test
   public void createWithNameAndValuesOnly() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index ce92536..d8b0cb1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -37,6 +37,8 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -65,10 +67,10 @@
   }
 
   @Test
-  public void deleteBranchByProjectOwner() throws Exception {
+  public void deleteBranchByProjectOwner_Forbidden() throws Exception {
     grantOwner();
     requestScopeOperations.setApiUser(user.id());
-    assertDeleteSucceeds(testBranch);
+    assertDeleteForbidden(testBranch);
   }
 
   @Test
@@ -78,14 +80,6 @@
   }
 
   @Test
-  public void deleteBranchByProjectOwnerForcePushBlocked_Forbidden() throws Exception {
-    grantOwner();
-    blockForcePush();
-    requestScopeOperations.setApiUser(user.id());
-    assertDeleteForbidden(testBranch);
-  }
-
-  @Test
   public void deleteBranchByUserWithForcePushPermission() throws Exception {
     grantForcePush();
     requestScopeOperations.setApiUser(user.id());
@@ -192,6 +186,26 @@
     assertThat(thrown).hasMessageThat().contains("not allowed to delete HEAD");
   }
 
+  @Test
+  public void deleteRefsForBranch() throws Exception {
+    BranchNameKey refsForBranch = BranchNameKey.create(project, "refs/for/master");
+
+    // Creating a branch under refs/for/ is not allowed through the API, hence create it directly in
+    // the remote repo.
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      repo.branch(refsForBranch.branch()).commit().message("Initial empty commit").create();
+    }
+
+    assertThat(branch(refsForBranch).get().canDelete).isTrue();
+    String branchRev = branch(refsForBranch).get().revision;
+
+    branch(refsForBranch).delete();
+
+    eventRecorder.assertRefUpdatedEvents(project.get(), refsForBranch.branch(), branchRev, null);
+    assertThrows(ResourceNotFoundException.class, () -> branch(refsForBranch).get());
+  }
+
   private void blockForcePush() throws Exception {
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 7e60395..8c0836d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -59,10 +59,10 @@
   }
 
   @Test
-  public void deleteTagByProjectOwner() throws Exception {
+  public void deleteTagByProjectOwner_Forbidden() throws Exception {
     grantOwner();
     requestScopeOperations.setApiUser(user.id());
-    assertDeleteSucceeds();
+    assertDeleteForbidden();
   }
 
   @Test
@@ -72,14 +72,6 @@
   }
 
   @Test
-  public void deleteTagByProjectOwnerForcePushBlocked_Forbidden() throws Exception {
-    grantOwner();
-    blockForcePush();
-    requestScopeOperations.setApiUser(user.id());
-    assertDeleteForbidden();
-  }
-
-  @Test
   public void deleteTagByUserWithForcePushPermission() throws Exception {
     grantForcePush();
     requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
index a7f3174..8dce9c3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
@@ -18,16 +18,17 @@
 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.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
 
-@NoHttpd
 public class FileBranchIT extends AbstractDaemonTest {
 
   private BranchNameKey branch;
@@ -47,6 +48,24 @@
   }
 
   @Test
+  public void getFileFromNonExistingBranch() throws Exception {
+    RestResponse response =
+        adminRestSession.get(
+            String.format("/projects/%s/branches/non-existing/files/path", project.get()));
+    response.assertNotFound();
+  }
+
+  @Test
+  public void getFileFromSymbolicRefPointingToAnUnbornBranch() throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      repo.updateRef(Constants.HEAD, true).link("refs/heads/non-existing");
+    }
+    RestResponse response =
+        adminRestSession.get(String.format("/projects/%s/branches/HEAD/files/path", project.get()));
+    response.assertNotFound();
+  }
+
+  @Test
   public void getNonExistingFile() throws Exception {
     assertThrows(ResourceNotFoundException.class, () -> branch().file("does-not-exist"));
   }
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..40e5d50 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -35,9 +35,9 @@
             " 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();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
index 3c8357b..b2ececc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
-import org.apache.commons.lang.RandomStringUtils;
+import org.apache.commons.lang3.RandomStringUtils;
 import org.junit.Test;
 
 @NoHttpd
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index a397693..fbec664 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.inject.Inject;
 import java.util.List;
+import java.util.Optional;
 import org.junit.Test;
 
 @NoHttpd
@@ -100,6 +101,24 @@
   }
 
   @Test
+  public void labelWithDescription() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              "foo", labelType -> labelType.setDescription(Optional.of("foo label description")));
+      u.save();
+    }
+
+    List<LabelDefinitionInfo> labels = gApi.projects().name(project.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("foo");
+
+    LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
+    assertThat(fooLabel.description).isEqualTo("foo label description");
+  }
+
+  @Test
   public void labelLimitedToBranches() throws Exception {
     configLabel(
         "foo", LabelFunction.NO_OP, ImmutableList.of("refs/heads/master", "^refs/heads/stable-.*"));
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index 2e274d9..e69f781 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.ProjectAssert.assertThatNameList;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 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.stream.Collectors.toList;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableSet;
@@ -143,7 +143,7 @@
   public void listProjectsToOutputStream() throws Exception {
     int numInitialProjects = gApi.projects().list().get().size();
     int numTestProjects = 5;
-    List<String> testProjects = createProjects("zzz_testProject", numTestProjects);
+    ImmutableSet<String> testProjects = createProjects("zzz_testProject", numTestProjects);
     try (ByteArrayOutputStream displayOut = new ByteArrayOutputStream()) {
 
       listProjects.setStart(numInitialProjects);
@@ -153,7 +153,7 @@
           Splitter.on("\n")
               .omitEmptyStrings()
               .splitToList(new String(displayOut.toByteArray(), UTF_8));
-      assertThat(lines).isEqualTo(testProjects);
+      assertThat(lines).isEqualTo(testProjects.asList());
     }
   }
 
@@ -173,8 +173,7 @@
 
     int numInitialProjects = gApi.projects().list().get().size();
     int numTestProjects = 5;
-    Set<String> testProjects =
-        ImmutableSet.copyOf(createProjects("zzz_testProject", numTestProjects));
+    ImmutableSet<String> testProjects = createProjects("zzz_testProject", numTestProjects);
     try (ByteArrayOutputStream displayOut = new ByteArrayOutputStream()) {
 
       listProjects.setStart(numInitialProjects);
@@ -191,11 +190,11 @@
     }
   }
 
-  private List<String> createProjects(String prefix, int numProjects) {
+  private ImmutableSet<String> createProjects(String prefix, int numProjects) {
     return IntStream.range(0, numProjects)
         .mapToObj(i -> projectOperations.newProject().name(prefix + i).create())
         .map(Project.NameKey::get)
-        .collect(toList());
+        .collect(toImmutableSet());
   }
 
   @Test
@@ -227,7 +226,7 @@
 
     assertThatNameList(gApi.projects().list().withRegex(".*some").get())
         .containsExactly(projectAwesome);
-    String r = ("lpwr-some-project$").replace(".", "\\.");
+    String r = "lpwr-some-project$".replace(".", "\\.");
     assertThatNameList(gApi.projects().list().withRegex(r).get()).containsExactly(someProject);
     assertThatNameList(gApi.projects().list().withRegex(".*").get())
         .containsExactly(
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index b4938c1..cfca936 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -94,6 +94,20 @@
   }
 
   @Test
+  public void updateDescription() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.description = "Code review label description";
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
+    assertThat(updatedLabel.description).isEqualTo("Code review label description");
+
+    input.description = "";
+    updatedLabel = gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
+    assertThat(updatedLabel.description).isNull();
+  }
+
+  @Test
   public void nameIsTrimmed() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.name = " Foo-Review ";
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index b1879f6..c8c00a1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -41,7 +41,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.List;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -222,14 +222,14 @@
     assertThat(result.ref).isEqualTo(R_TAGS + input.ref);
     assertThat(result.revision).isEqualTo(input.revision);
     assertThat(result.canDelete).isTrue();
-    assertThat(result.created).isEqualTo(timestamp(r));
+    assertThat(result.created.toInstant()).isEqualTo(instant(r));
 
     input.ref = "refs/tags/v2.0";
     result = tag(input.ref).create(input).get();
     assertThat(result.ref).isEqualTo(input.ref);
     assertThat(result.revision).isEqualTo(input.revision);
     assertThat(result.canDelete).isTrue();
-    assertThat(result.created).isEqualTo(timestamp(r));
+    assertThat(result.created.toInstant()).isEqualTo(instant(r));
 
     requestScopeOperations.setApiUser(user.id());
     result = tag(input.ref).get();
@@ -457,8 +457,8 @@
     return gApi.projects().name(project.get()).tag(tagname);
   }
 
-  private Timestamp timestamp(PushOneCommit.Result r) {
-    return new Timestamp(r.getCommit().getCommitterIdent().getWhen().getTime());
+  private Instant instant(PushOneCommit.Result r) {
+    return r.getCommit().getCommitterIdent().getWhenAsInstant();
   }
 
   private void assertBadRequest(ListRefsRequest<TagInfo> req) throws Exception {
@@ -477,7 +477,7 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    for (AccessSection accessSection : ImmutableList.copyOf(cfg.getAccessSections())) {
+    for (AccessSection accessSection : cfg.getAccessSections()) {
       cfg.upsertAccessSection(
           accessSection.getName(),
           updatedAccessSection -> {
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/BUILD b/javatests/com/google/gerrit/acceptance/rest/util/BUILD
index 1d3fe65..9b16eb1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/util/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/util/BUILD
@@ -7,6 +7,6 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java b/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java
index 7b0002c..91cf15a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java
+++ b/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java
@@ -18,7 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import java.util.Optional;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.junit.Ignore;
 
 /** Data container for test REST requests. */
diff --git a/javatests/com/google/gerrit/acceptance/server/approval/PatchSetApprovalUuidTest.java b/javatests/com/google/gerrit/acceptance/server/approval/PatchSetApprovalUuidTest.java
new file mode 100644
index 0000000..8405b25
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/approval/PatchSetApprovalUuidTest.java
@@ -0,0 +1,55 @@
+// 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;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGeneratorImpl;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.time.Instant;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for {@link PatchSetApprovalUuidGeneratorImpl} - the default implementation of {@link
+ * PatchSetApprovalUuidGenerator}.
+ */
+@RunWith(JUnit4.class)
+public class PatchSetApprovalUuidTest {
+
+  @Test
+  public void sameInput_differentUuid() {
+    PatchSetApprovalUuidGeneratorImpl patchSetApprovalUuidGenerator =
+        new PatchSetApprovalUuidGeneratorImpl();
+    for (short value = -2; value <= 2; value++) {
+      PatchSet.Id patchSetId = PatchSet.id(Change.id(1), 1);
+      Account.Id accountId = Account.id(1);
+      String label = LabelId.CODE_REVIEW;
+      Instant granted = TimeUtil.now();
+      PatchSetApproval.UUID uuid1 =
+          patchSetApprovalUuidGenerator.get(patchSetId, accountId, label, value, granted);
+      PatchSetApproval.UUID uuid2 =
+          patchSetApprovalUuidGenerator.get(patchSetId, accountId, label, value, granted);
+      assertThat(uuid2).isNotEqualTo(uuid1);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 80cdad8..6d980c7 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.acceptance.testsuite.change.TestHumanComment;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.HumanComment;
@@ -47,9 +48,11 @@
 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.ReviewerInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -66,7 +69,7 @@
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -218,6 +221,30 @@
   }
 
   @Test
+  public void deletedCommentsAreResolved() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    String commentMessage = "to be deleted";
+    CommentInput comment =
+        CommentsUtil.newComment(
+            COMMIT_MSG, Side.REVISION, /*line= */ 0, commentMessage, /*unresolved= */ true);
+    CommentsUtil.addComments(gApi, changeId, revId, comment);
+
+    Map<String, List<CommentInfo>> results = getPublishedComments(changeId, revId);
+    CommentInfo oldComment = Iterables.getOnlyElement(results.get(COMMIT_MSG));
+
+    DeleteCommentInput input = new DeleteCommentInput("reason");
+    gApi.changes().id(changeId).revision(revId).comment(oldComment.id).delete(input);
+    CommentInfo updatedComment =
+        Iterables.getOnlyElement(getPublishedComments(changeId, revId).get(COMMIT_MSG));
+
+    assertThat(updatedComment.message).doesNotContain(commentMessage);
+    assertThat(updatedComment.unresolved).isFalse();
+  }
+
+  @Test
   public void patchsetLevelCommentEmailNotification() throws Exception {
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
@@ -640,7 +667,7 @@
   public void putDraft() throws Exception {
     for (Integer line : lines) {
       PushOneCommit.Result r = createChange();
-      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
+      Instant origLastUpdated = r.getChange().change().getLastUpdatedOn();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
@@ -887,7 +914,7 @@
   public void deleteDraft() throws Exception {
     for (Integer line : lines) {
       PushOneCommit.Result r = createChange();
-      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
+      Instant origLastUpdated = r.getChange().change().getLastUpdatedOn();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       DraftInput draft = CommentsUtil.newDraft("file1", Side.REVISION, line, "comment 1");
@@ -903,7 +930,7 @@
 
   @Test
   public void insertCommentsWithHistoricTimestamp() throws Exception {
-    Timestamp timestamp = new Timestamp(0);
+    Instant timestamp = Instant.EPOCH;
     for (Integer line : lines) {
       String file = "file";
       String contents = "contents " + line;
@@ -912,11 +939,11 @@
       PushOneCommit.Result r = push.to("refs/for/master");
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
+      Instant origLastUpdated = r.getChange().change().getLastUpdatedOn();
 
       ReviewInput input = new ReviewInput();
       CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", false);
-      comment.updated = timestamp;
+      comment.setUpdated(timestamp);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       ChangeResource changeRsrc =
@@ -1853,6 +1880,72 @@
     assertThat(exception.getMessage()).contains(String.format("%s not found", comment.inReplyTo));
   }
 
+  @Test
+  public void commentsOnRootCommitsAreIncludedInEmails() throws Exception {
+    // Create a change in a new branch, making the patch-set commit a root commit.
+    ChangeInfo changeInfo = createChangeInNewBranch("newBranch");
+    Change.Id changeId = Change.Id.tryParse(Integer.toString(changeInfo._number)).get();
+
+    // Add a file.
+    gApi.changes().id(changeId.get()).edit().modifyFile("f1.txt", RawInputUtil.create("content"));
+    gApi.changes().id(changeId.get()).edit().publish();
+    email.clear();
+
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = admin.email();
+    gApi.changes().id(changeId.get()).addReviewer(reviewerInput);
+    changeInfo = gApi.changes().id(changeId.get()).get();
+    assertThat(email.getMessages()).hasSize(1);
+    Message message = email.getMessages().get(0);
+    assertThat(message.body()).contains("f1.txt");
+    email.clear();
+
+    // Send a comment. Make sure the email that is sent includes the comment text.
+    CommentInput c1 =
+        CommentsUtil.newComment(
+            "f1.txt",
+            Side.REVISION,
+            /* line= */ 1,
+            /* message= */ "Comment text",
+            /* unresolved= */ false);
+    CommentsUtil.addComments(gApi, changeId.toString(), changeInfo.currentRevision, c1);
+    assertThat(email.getMessages()).hasSize(1);
+    Message commentMessage = email.getMessages().get(0);
+    assertThat(commentMessage.body())
+        .contains("Patch Set 2:\n" + "\n" + "(1 comment)\n" + "\n" + "File f1.txt:");
+    assertThat(commentMessage.body()).contains("PS2, Line 1: content\n" + "Comment text");
+  }
+
+  @Test
+  public void commentsOnDeletedFileIsIncludedInEmails() throws Exception {
+    // Create a change with a file.
+    createChange("subject", "f1.txt", "content");
+
+    // Stack a second change that deletes the file.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).edit().deleteFile("f1.txt");
+    gApi.changes().id(changeId).edit().publish();
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+
+    // Add a comment on the deleted file on the parent side.
+    email.clear();
+    CommentInput commentInput =
+        CommentsUtil.newComment(
+            "f1.txt",
+            Side.PARENT,
+            /* line= */ 1,
+            /* message= */ "Comment text",
+            /* unresolved= */ false);
+    CommentsUtil.addComments(gApi, changeId, currentRevision, commentInput);
+
+    // Assert email contains the comment text.
+    assertThat(email.getMessages()).hasSize(1);
+    Message commentMessage = email.getMessages().get(0);
+    assertThat(commentMessage.body()).contains("Patch Set 2:\n\n(1 comment)\n\nFile f1.txt:");
+    assertThat(commentMessage.body()).contains("PS2, Line 1: content\nComment text");
+  }
+
   private List<CommentInfo> getRevisionComments(String changeId, String revId) throws Exception {
     return getPublishedComments(changeId, revId).values().stream()
         .flatMap(List::stream)
@@ -2017,4 +2110,13 @@
     reviewInput.draftIdsToPublish = draftIdsToPublish;
     return reviewInput;
   }
+
+  private ChangeInfo createChangeInNewBranch(String branchName) throws Exception {
+    ChangeInput in = new ChangeInput();
+    in.project = project.get();
+    in.branch = branchName;
+    in.newBranch = true;
+    in.subject = "New changes";
+    return gApi.changes().create(in).get();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 9d821b7..bcde618 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -732,7 +732,7 @@
   }
 
   private BatchUpdate newUpdate(Account.Id owner) {
-    return batchUpdateFactory.create(project, userFactory.create(owner), TimeUtil.nowTs());
+    return batchUpdateFactory.create(project, userFactory.create(owner), TimeUtil.now());
   }
 
   private ChangeNotes insertChange() throws Exception {
@@ -828,7 +828,8 @@
   private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
     PersonIdent committer = serverIdent.get();
     PersonIdent author =
-        noteUtil.newAccountIdIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
+        noteUtil.newAccountIdIdent(
+            getAccount(admin.id()).id(), committer.getWhenAsInstant(), committer);
     serverSideTestRepo
         .branch(RefNames.changeMetaRef(id))
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index e778a5c..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);
@@ -666,7 +665,7 @@
   }
 
   private void clearGroups(PatchSet.Id psId) throws Exception {
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.now())) {
       bu.addOp(
           psId.changeId(),
           new BatchUpdateOp() {
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index a2a6caa..5a4f073 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -132,6 +132,8 @@
     } else {
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
     }
   }
 
@@ -154,6 +156,8 @@
     } else {
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
     }
   }
 
@@ -182,6 +186,9 @@
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
       assertSubmittedTogether(id3, id3, id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id3, id3, id2, id1);
     }
   }
 
@@ -249,6 +256,13 @@
       assertSubmittedTogether(id4, id4, id3, id2);
       assertSubmittedTogether(id5);
       assertSubmittedTogether(id6, id6, id5);
+
+      assertSubmittedTogetherWithTopicClosure(id1, id6, id5, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id6, id5, id2);
+      assertSubmittedTogetherWithTopicClosure(id3, id6, id5, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id4, id6, id5, id4, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id5);
+      assertSubmittedTogetherWithTopicClosure(id6, id6, id5, id2);
     }
   }
 
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/git/CodeReviewCommitTest.java b/javatests/com/google/gerrit/acceptance/server/git/CodeReviewCommitTest.java
new file mode 100644
index 0000000..e073f6f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/git/CodeReviewCommitTest.java
@@ -0,0 +1,75 @@
+// 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.git;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+/** Tests for {@link CodeReviewCommit}. */
+public class CodeReviewCommitTest {
+
+  @Test
+  public void checkSerializable_withStatusMessage() throws Exception {
+    CodeReviewCommit commit = new CodeReviewCommit(createCommit());
+    commit.setStatusMessage("Status");
+    CodeReviewCommit deserializedCommit = serializeAndReadBack(commit);
+    assertThat(deserializedCommit).isEqualTo(commit);
+    assertThat(deserializedCommit.getStatusMessage().get()).isEqualTo("Status");
+  }
+
+  @Test
+  public void checkSerializable_emptyStatusMessage() throws Exception {
+    CodeReviewCommit commit = new CodeReviewCommit(createCommit());
+    CodeReviewCommit deserializedCommit = serializeAndReadBack(commit);
+    assertThat(deserializedCommit).isEqualTo(commit);
+    assertThat(deserializedCommit.getStatusMessage().isPresent()).isFalse();
+  }
+
+  @SuppressWarnings("BanSerializableRead")
+  private CodeReviewCommit serializeAndReadBack(CodeReviewCommit codeReviewCommit)
+      throws Exception {
+    try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        ObjectOutputStream out = new ObjectOutputStream(bos)) {
+      out.writeObject(codeReviewCommit);
+      out.flush();
+      try (ByteArrayInputStream fileIn = new ByteArrayInputStream(bos.toByteArray());
+          ObjectInputStream in = new ObjectInputStream(fileIn); ) {
+        return (CodeReviewCommit) in.readObject();
+      }
+    }
+  }
+
+  private ObjectId createCommit() throws Exception {
+    InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+    Project.NameKey project = Project.nameKey("test");
+    try (Repository repo = repoManager.createRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      PersonIdent ident = new PersonIdent(new PersonIdent("Test Ident", "test@test.com"));
+      return tr.commit().author(ident).committer(ident).message("Test commit").create();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
index 1a01184..13e2f24 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -95,10 +95,7 @@
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
     String revId = result.getCommit().getName();
-    when(mockCommentValidator.validateComments(
-            CommentValidationContext.create(
-                result.getChange().getId().get(), result.getChange().project().get()),
-            ImmutableList.of(COMMENT_FOR_VALIDATION)))
+    when(mockCommentValidator.validateComments(captureCtx.capture(), capture.capture()))
         .thenReturn(ImmutableList.of());
     DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
     testCommentHelper.addDraft(changeId, revId, comment);
@@ -107,6 +104,12 @@
     amendResult.assertOkStatus();
     amendResult.assertNotMessage("Comment validation failure:");
     assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
+
+    assertThat(captureCtx.getAllValues()).hasSize(1);
+    assertThat(captureCtx.getValue().getProject()).isEqualTo(result.getChange().project().get());
+    assertThat(captureCtx.getValue().getChangeId()).isEqualTo(result.getChange().getId().get());
+    assertThat(captureCtx.getValue().getRefName()).isEqualTo("refs/heads/master");
+    assertThat(capture.getValue()).containsExactly(COMMENT_FOR_VALIDATION);
   }
 
   @Test
@@ -182,7 +185,9 @@
     String revId = result.getCommit().getName();
     when(mockCommentValidator.validateComments(
             CommentValidationContext.create(
-                result.getChange().getId().get(), result.getChange().project().get()),
+                result.getChange().getId().get(),
+                result.getChange().project().get(),
+                result.getChange().change().getDest().branch()),
             ImmutableList.of(COMMENT_FOR_VALIDATION)))
         .thenReturn(ImmutableList.of(COMMENT_FOR_VALIDATION.failValidation("Oh no!")));
     DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
@@ -215,6 +220,7 @@
 
     assertThat(captureCtx.getValue().getProject()).isEqualTo(result.getChange().project().get());
     assertThat(captureCtx.getValue().getChangeId()).isEqualTo(result.getChange().getId().get());
+    assertThat(captureCtx.getValue().getRefName()).isEqualTo("refs/heads/master");
 
     assertThat(capture.getAllValues().get(0))
         .containsExactly(
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 85238f8..6013862 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -64,6 +64,7 @@
 import org.junit.Test;
 
 public class ChangeNotificationsIT extends AbstractNotificationTest {
+
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
@@ -599,6 +600,7 @@
   }
 
   private interface Adder {
+
     void addReviewer(String changeId, String reviewer, @Nullable NotifyHandling notify)
         throws Exception;
   }
@@ -993,13 +995,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 +1071,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 +1084,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 +1104,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 +1302,7 @@
   }
 
   private interface Stager {
+
     StagedChange stage() throws Exception;
   }
 
@@ -2271,8 +2286,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 +2311,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 +2337,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 +2363,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/mail/MailMetadataIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
index d6fcccc..4e490a7 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -30,11 +30,10 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.util.Collection;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -100,7 +99,7 @@
     expectedHeaders.put("Gerrit-MessageType", "comment");
     expectedHeaders.put("Gerrit-Commit", newChange.getCommit().getId().name());
     expectedHeaders.put("Gerrit-ChangeURL", changeURL);
-    expectedHeaders.put("Gerrit-Comment-Date", Iterables.getLast(result).date);
+    expectedHeaders.put("Gerrit-Comment-Date", Iterables.getLast(result).date.toInstant());
 
     assertHeaders(message.headers(), expectedHeaders);
 
@@ -116,14 +115,14 @@
       if (entry.getValue() instanceof String) {
         assertThat(have)
             .containsEntry("X-" + entry.getKey(), new StringEmailHeader((String) entry.getValue()));
-      } else if (entry.getValue() instanceof Date) {
+      } else if (entry.getValue() instanceof Instant) {
         assertThat(have)
-            .containsEntry("X-" + entry.getKey(), new EmailHeader.Date((Date) entry.getValue()));
+            .containsEntry("X-" + entry.getKey(), new EmailHeader.Date((Instant) entry.getValue()));
       } else {
         throw new Exception(
             "Object has unsupported type: "
                 + entry.getValue().getClass().getName()
-                + " must be java.util.Date or java.lang.String for key "
+                + " must be java.time.Instant or java.lang.String for key "
                 + entry.getKey());
       }
     }
@@ -133,19 +132,18 @@
     for (Map.Entry<String, Object> entry : want.entrySet()) {
       if (entry.getValue() instanceof String) {
         assertThat(body).contains(entry.getKey() + ": " + entry.getValue());
-      } else if (entry.getValue() instanceof Timestamp) {
+      } else if (entry.getValue() instanceof Instant) {
         assertThat(body)
             .contains(
                 entry.getKey()
                     + ": "
                     + MailProcessingUtil.rfcDateformatter.format(
-                        ZonedDateTime.ofInstant(
-                            ((Timestamp) entry.getValue()).toInstant(), ZoneId.of("UTC"))));
+                        ZonedDateTime.ofInstant((Instant) entry.getValue(), ZoneId.of("UTC"))));
       } else {
         throw new Exception(
             "Object has unsupported type: "
                 + entry.getValue().getClass().getName()
-                + " must be java.util.Date or java.lang.String for key "
+                + " must be java.time.Instant or java.lang.String for key "
                 + entry.getKey());
       }
     }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index d4000b3..2bccc87 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -436,7 +436,7 @@
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", COMMENT_TEXT, null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
-    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    List<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
     mailProcessor.process(b.build());
     assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
 
@@ -445,7 +445,7 @@
     assertThat(message.body()).contains("rejected one or more comments");
 
     // ensure the message header contains a valid message id.
-    assertThat(((StringEmailHeader) (message.headers().get("Message-ID"))).getString())
+    assertThat(((StringEmailHeader) message.headers().get("Message-ID")).getString())
         .containsMatch("<someid-REJECTION-HTML@" + new URL(canonicalWebUrl.get()).getHost() + ">");
   }
 
@@ -465,7 +465,7 @@
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, COMMENT_TEXT, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
-    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    List<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
     mailProcessor.process(b.build());
     assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
 
@@ -490,7 +490,7 @@
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, null, COMMENT_TEXT);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
-    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    List<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
     mailProcessor.process(b.build());
     assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
 
@@ -576,7 +576,7 @@
             null);
     mailMessage.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
-    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    List<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
     mailProcessor.process(mailMessage.build());
     assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
 
@@ -596,7 +596,7 @@
             CommentForValidation.CommentSource.HUMAN, type, COMMENT_TEXT, COMMENT_TEXT.length());
 
     when(mockCommentValidator.validateComments(
-            CommentValidationContext.create(failChange, failProject),
+            CommentValidationContext.create(failChange, failProject, "refs/heads/master"),
             ImmutableList.of(commentForValidation)))
         .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
index 628b90c..65b1d4f 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
@@ -18,12 +18,17 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
 import org.junit.Test;
 
 public class NotificationMailFormatIT extends AbstractDaemonTest {
@@ -56,6 +61,33 @@
   }
 
   @Test
+  public void bccUserIsNotAddedToReplyTo() throws Exception {
+    TestAccount bccUser = accountCreator.user2();
+
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = project.get();
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+
+    gApi.accounts().id(bccUser.id().get()).setWatchedProjects(projectsToWatch);
+
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
+
+    assertThat(sender.getMessages()).hasSize(1);
+    FakeEmailSender.Message m = sender.getMessages().get(0);
+    assertMailReplyTo(m, admin.email());
+    assertMailReplyTo(m, user.email());
+    assertMailNotReplyTo(m, bccUser.email());
+
+    assertThat(m.rcpt().stream().map(a -> a.email()).collect(Collectors.toSet()))
+        .contains(bccUser.email());
+  }
+
+  @Test
   public void userReceivesHtmlAndPlaintextEmail() throws Exception {
     // Create change as admin and review as user
     PushOneCommit.Result r = createChange();
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
index 3c066a3..ab5e1d8 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -274,7 +274,7 @@
   }
 
   private BatchUpdate newBatchUpdate(BatchUpdate.Factory buf) {
-    return buf.create(project, identifiedUserFactory.create(user.id()), TimeUtil.nowTs());
+    return buf.create(project, identifiedUserFactory.create(user.id()), TimeUtil.now());
   }
 
   private Optional<ObjectId> getRef(String name) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
index 46687e3..3508112 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
@@ -53,6 +53,7 @@
 import com.google.inject.Module;
 import java.util.Collection;
 import java.util.Set;
+import java.util.stream.StreamSupport;
 import javax.inject.Inject;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -144,13 +145,14 @@
 
                       @Override
                       public boolean containsAnyOf(Iterable<AccountGroup.UUID> groupIds) {
-                        return ImmutableList.copyOf(groupIds).stream().anyMatch(g -> contains(g));
+                        return StreamSupport.stream(groupIds.spliterator(), /* parallel= */ false)
+                            .anyMatch(g -> contains(g));
                       }
 
                       @Override
                       public Set<AccountGroup.UUID> intersection(
                           Iterable<AccountGroup.UUID> groupIds) {
-                        return ImmutableList.copyOf(groupIds).stream()
+                        return StreamSupport.stream(groupIds.spliterator(), /* parallel= */ false)
                             .filter(g -> contains(g))
                             .collect(toImmutableSet());
                       }
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/OnStoreSubmitRequirementResultModifierIT.java b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
new file mode 100644
index 0000000..50fa3b2
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
@@ -0,0 +1,245 @@
+// 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.project;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.MoreCollectors;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestOnStoreSubmitRequirementResultModifier;
+import com.google.gerrit.acceptance.TestOnStoreSubmitRequirementResultModifier.ModificationStrategy;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.change.TestSubmitInput;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.util.ArrayDeque;
+import java.util.Collection;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link OnStoreSubmitRequirementResultModifier} on the closed changes. */
+@NoHttpd
+public class OnStoreSubmitRequirementResultModifierIT extends AbstractDaemonTest {
+
+  private static final TestOnStoreSubmitRequirementResultModifier
+      TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER =
+          new TestOnStoreSubmitRequirementResultModifier();
+
+  @Override
+  public Module createModule() {
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        bind(OnStoreSubmitRequirementResultModifier.class)
+            .toInstance(TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER);
+      }
+    };
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.hide(false);
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+  }
+
+  @Test
+  public void submitRequirementStored_canBeOverriddenForMergedChanges() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.OVERRIDE);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).current().submit();
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirementStored_canBeOverriddenForAbandonedChanges() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.OVERRIDE);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).abandon();
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirementStored_notReturnedWhenHidden() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.OVERRIDE);
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.hide(true);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).current().submit();
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(0);
+
+    ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+
+    SubmitRequirementResult result =
+        notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+    assertThat(result.submitRequirement().name()).isEqualTo("Code-Review");
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.OVERRIDDEN);
+    assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+        .isEqualTo("label:Code-Review=MAX");
+  }
+
+  @Test
+  public void overrideToUnsatisfied_unsatisfied_doesNotBlockSubmission() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.FAIL);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).current().submit();
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void overrideToUnsatisfied_doesNotBlockSubmissionWithRetries() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.FAIL);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    TestSubmitInput input = new TestSubmitInput();
+    input.generateLockFailures = new ArrayDeque<>(ImmutableList.of(true));
+    gApi.changes().id(changeId).current().submit(input);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void overrideToSatisfied_doesNotBypassSubmitRequirement() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.PASS);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    assertThrows(
+        ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
+  }
+
+  private void assertSubmitRequirementStatus(
+      Collection<SubmitRequirementResultInfo> results,
+      String requirementName,
+      SubmitRequirementResultInfo.Status status,
+      boolean isLegacy) {
+    assertWithMessage(
+            "Could not find submit requirement %s with status %s, legacy=%s, (results = %s)",
+            requirementName,
+            status,
+            isLegacy,
+            results.stream()
+                .map(r -> String.format("%s=%s, legacy=%s", r.name, r.status, r.isLegacy))
+                .collect(toImmutableList()))
+        .that(
+            results.stream()
+                .filter(
+                    result ->
+                        result.name.equals(requirementName)
+                            && result.status == status
+                            && result.isLegacy == isLegacy)
+                .count())
+        .isEqualTo(1);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index ba86976..d911512 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -46,16 +47,21 @@
 
   @Test
   public void newPatchSetsNotifyConfig() throws Exception {
-    Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig.Builder nc = NotifyConfig.builder();
-    nc.addAddress(addr);
-    nc.setName("new-patch-set");
-    nc.setHeader(NotifyConfig.Header.CC);
-    nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
-    nc.setFilter("message:sekret");
-
+    ImmutableList<String> messageFilters =
+        ImmutableList.of("message:subject-with-tokens", "message:subject-with-tokens=secret");
+    ImmutableList.Builder<Address> watchers = ImmutableList.builder();
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("watch", nc.build());
+      for (int i = 0; i < messageFilters.size(); i++) {
+        Address addr = Address.create("Watcher#" + i, String.format("watcher-%s@example.com", i));
+        watchers.add(addr);
+        NotifyConfig.Builder nc = NotifyConfig.builder();
+        nc.addAddress(addr);
+        nc.setName("new-patch-set" + i);
+        nc.setHeader(NotifyConfig.Header.CC);
+        nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
+        nc.setFilter(messageFilters.get(i));
+        u.getConfig().putNotifyConfig("watch" + i, nc.build());
+      }
       u.save();
     }
 
@@ -67,7 +73,13 @@
 
     r =
         pushFactory
-            .create(admin.newIdent(), testRepo, "super sekret subject", "a", "a2", r.getChangeId())
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "super sekret subject\n\nsubject-with-tokens=secret subject",
+                "a",
+                "a2",
+                r.getChangeId())
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -80,7 +92,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(addr);
+    assertThat(m.rcpt()).containsExactlyElementsIn(watchers.build());
     assertThat(m.body()).contains("Change subject: super sekret subject\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 2\n");
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 6e19c39..731e0df 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -15,16 +15,23 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.ExtensionRegistry.PLUGIN_NAME;
 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.value;
 
 import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
@@ -32,12 +39,21 @@
 import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
 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.project.SubmitRequirementEvaluationException;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeIsOperandFactory;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.Map;
 import java.util.Optional;
 import org.junit.Before;
 import org.junit.Test;
@@ -47,10 +63,16 @@
   @Inject SubmitRequirementsEvaluator evaluator;
   @Inject private ProjectOperations projectOperations;
   @Inject private Provider<InternalChangeQuery> changeQueryProvider;
+  @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ChangeOperations changeOperations;
 
   private ChangeData changeData;
   private String changeId;
 
+  private static final String FILE_NAME = "file,txt";
+  private static final String CONTENT = "line 1\nline 2\n line 3\n";
+
   @Before
   public void setUp() throws Exception {
     PushOneCommit.Result pushResult =
@@ -91,6 +113,25 @@
   }
 
   @Test
+  public void throwingSubmitRequirementPredicate() throws Exception {
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new ThrowingSubmitRequirementPredicate(),
+                ThrowingSubmitRequirementPredicate.OPERAND)) {
+      SubmitRequirementExpression expression =
+          SubmitRequirementExpression.create(
+              String.format("is:%s_%s", ThrowingSubmitRequirementPredicate.OPERAND, PLUGIN_NAME));
+      SubmitRequirementExpressionResult result =
+          evaluator.evaluateExpression(expression, changeData);
+      assertThat(result.status()).isEqualTo(Status.ERROR);
+      assertThat(result.errorMessage().get())
+          .isEqualTo(ThrowingSubmitRequirementPredicate.ERROR_MESSAGE);
+    }
+  }
+
+  @Test
   public void compositeExpression() throws Exception {
     SubmitRequirementExpression expression =
         SubmitRequirementExpression.create(
@@ -108,6 +149,93 @@
   }
 
   @Test
+  public void globalSubmitRequirementEvaluated() throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        createSubmitRequirement(
+            /*name=*/ "global-config-requirement",
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /*submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+            false);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      SubmitRequirement projectSubmitRequirement =
+          createSubmitRequirement(
+              /*name=*/ "project-config-requirement",
+              /* applicabilityExpr= */ "project:" + project.get(),
+              /*submittabilityExpr= */ "is:true",
+              /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+              false);
+      configSubmitRequirement(project, projectSubmitRequirement);
+      Map<SubmitRequirement, SubmitRequirementResult> results =
+          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+      assertThat(results).hasSize(2);
+      assertThat(results.get(globalSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+      assertThat(results.get(projectSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    }
+  }
+
+  @Test
+  public void
+      globalSubmitRequirement_duplicateInProjectConfig_overrideAllowed_projectResultReturned()
+          throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        createSubmitRequirement(
+            /*name=*/ "config-requirement",
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /*submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+            true);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      SubmitRequirement projectSubmitRequirement =
+          createSubmitRequirement(
+              /*name=*/ "config-requirement",
+              /* applicabilityExpr= */ "project:" + project.get(),
+              /*submittabilityExpr= */ "is:true",
+              /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+              false);
+      configSubmitRequirement(project, projectSubmitRequirement);
+      Map<SubmitRequirement, SubmitRequirementResult> results =
+          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+      assertThat(results).hasSize(1);
+      assertThat(results.get(projectSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    }
+  }
+
+  @Test
+  public void
+      globalSubmitRequirement_duplicateInProjectConfig_overrideNotAllowedAllowed_globalResultReturned()
+          throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        createSubmitRequirement(
+            /*name=*/ "config-requirement",
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /*submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+            false);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      SubmitRequirement projectSubmitRequirement =
+          createSubmitRequirement(
+              /*name=*/ "config-requirement",
+              /* applicabilityExpr= */ "project:" + project.get(),
+              /*submittabilityExpr= */ "is:true",
+              /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+              false);
+      configSubmitRequirement(project, projectSubmitRequirement);
+      Map<SubmitRequirement, SubmitRequirementResult> results =
+          evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false);
+      assertThat(results).hasSize(1);
+      assertThat(results.get(globalSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    }
+  }
+
+  @Test
   public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsFalse()
       throws Exception {
     SubmitRequirement sr =
@@ -121,6 +249,92 @@
   }
 
   @Test
+  public void submitRequirement_alwaysNotApplicable() {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "is:false",
+            /* submittabilityExpr= */ "is:false", // redundant
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void submitRequirement_alwaysApplicable() {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "is:true",
+            /* submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+  }
+
+  @Test
+  public void submittabilityAndOverrideNotEvaluated_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();
+  }
+
+  @Test
+  public void submittabilityAndOverrideEvaluated_whenApplicabilityIsEmpty() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ null,
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "label:\"build-cop-override=-1\"");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    assertThat(result.applicabilityExpressionResult().isPresent()).isFalse();
+    assertThat(result.submittabilityExpressionResult().get().status()).isEqualTo(Status.PASS);
+    assertThat(result.overrideExpressionResult().get().status()).isEqualTo(Status.FAIL);
+  }
+
+  @Test
+  public void submittabilityAndOverrideEvaluated_whenApplicabilityIsTrue() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "label:\"build-cop-override=-1\"");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    assertThat(result.applicabilityExpressionResult().get().status()).isEqualTo(Status.PASS);
+    assertThat(result.submittabilityExpressionResult().get().status()).isEqualTo(Status.PASS);
+    assertThat(result.overrideExpressionResult().get().status()).isEqualTo(Status.FAIL);
+  }
+
+  @Test
+  public void submittabilityIsEvaluated_whenOverrideApplies() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ null,
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "project:" + project.get());
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.OVERRIDDEN);
+
+    assertThat(result.applicabilityExpressionResult().isPresent()).isFalse();
+    assertThat(result.submittabilityExpressionResult().get().status()).isEqualTo(Status.PASS);
+    assertThat(result.overrideExpressionResult().get().status()).isEqualTo(Status.PASS);
+  }
+
+  @Test
   public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsTrue() throws Exception {
     SubmitRequirement sr =
         createSubmitRequirement(
@@ -138,13 +352,13 @@
     SubmitRequirement sr =
         createSubmitRequirement(
             /* applicabilityExpr= */ "project:" + project.get(),
-            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
             /* overrideExpr= */ "");
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
     assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED);
-    assertThat(result.submittabilityExpressionResult().failingAtoms())
-        .containsExactly("label:\"code-review=+2\"");
+    assertThat(result.submittabilityExpressionResult().get().failingAtoms())
+        .containsExactly("label:\"Code-Review=+2\"");
   }
 
   @Test
@@ -160,7 +374,7 @@
     SubmitRequirement sr =
         createSubmitRequirement(
             /* applicabilityExpr= */ "project:" + project.get(),
-            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
             /* overrideExpr= */ "label:\"build-cop-override=+1\"");
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
@@ -175,7 +389,7 @@
     SubmitRequirement sr =
         createSubmitRequirement(
             /* applicabilityExpr= */ "invalid_field:invalid_value",
-            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
             /* overrideExpr= */ "label:\"build-cop-override=+1\"");
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
@@ -197,7 +411,7 @@
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
     assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
-    assertThat(result.submittabilityExpressionResult().errorMessage().get())
+    assertThat(result.submittabilityExpressionResult().get().errorMessage().get())
         .isEqualTo("Unsupported operator invalid_field:invalid_value");
   }
 
@@ -206,7 +420,7 @@
     SubmitRequirement sr =
         createSubmitRequirement(
             /* applicabilityExpr= */ "project:" + project.get(),
-            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
             /* overrideExpr= */ "invalid_field:invalid_value");
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
@@ -215,6 +429,434 @@
         .isEqualTo("Unsupported operator invalid_field:invalid_value");
   }
 
+  @Test
+  public void byPureRevert() throws Exception {
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result pushResult =
+        createChange(testRepo, "refs/heads/master", "Fix a bug", "file.txt", "content", "topic");
+    changeData = pushResult.getChange();
+    changeId = pushResult.getChangeId();
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "is:pure-revert",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED);
+    approve(changeId);
+    gApi.changes().id(changeId).current().submit();
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).revert().get();
+    String revertId = Integer.toString(changeInfo._number);
+    ChangeData revertChangeData =
+        changeQueryProvider.get().byLegacyChangeId(Change.Id.tryParse(revertId).get()).get(0);
+    result = evaluator.evaluateRequirement(sr, revertChangeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+  }
+
+  @Test
+  public void byAuthorEmail() throws Exception {
+    TestAccount user2 =
+        accountCreator.create("Foo", "user@example.com", "User", /* displayName = */ null);
+    requestScopeOperations.setApiUser(user2.id());
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get();
+    ChangeData cd =
+        changeQueryProvider
+            .get()
+            .byLegacyChangeId(Change.Id.tryParse(Integer.toString(info._number)).get())
+            .get(0);
+
+    // Match by email works
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^.*@example\\.com\"",
+        SubmitRequirementResult.Status.SATISFIED);
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^user@.*\\.com\"",
+        SubmitRequirementResult.Status.SATISFIED);
+
+    // Match by name does not work
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^Foo$\"",
+        SubmitRequirementResult.Status.UNSATISFIED);
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^User$\"",
+        SubmitRequirementResult.Status.UNSATISFIED);
+  }
+
+  private void checkSubmitRequirementResult(
+      ChangeData cd, String submittabilityExpr, SubmitRequirementResult.Status expectedStatus) {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            submittabilityExpr,
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, cd);
+    assertThat(result.status()).isEqualTo(expectedStatus);
+  }
+
+  @Test
+  public void byFileEdits_deletedContent_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 2\n", ""))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='line 2'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_deletedContent_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 1\n", ""))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='line 2'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_addedContent_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT + "line 4\n")
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='line 4'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_addedContent_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT + "line 4\n")
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='line 5'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_addedFile_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file("new_file.txt")
+            .content("content of the new file")
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^new.*\\\\.txt',withDiffContaining='of the new'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_addedFile_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file("new_file.txt")
+            .content("content of the new file")
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^new.*\\.txt',withDiffContaining='not_exist'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_modifiedContent_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='three'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_modifiedContent_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='ten'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_modifiedContentPattern_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining='^.*th[rR]ee$'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_exactMatchingWithFilePath_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            String.format("file:\"'%s',withDiffContaining='three'\"", FILE_NAME));
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_exactMatchingWithFilePath_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'non_existent.txt',withDiffContaining='three'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_notMatchingWithFilePath() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    // commit edit only matches with files ending with ".java". Since our modified file name ends
+    // with ".txt", the applicability expression will not match.
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.java',withDiffContaining='three'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_escapeSingleQuotes() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line 'three' is modified\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining='line \\'three\\' is'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_doubleEscapeSingleQuote() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            // This will be written to the file as: line \'three\' is modified.
+            .content(CONTENT.replace("line 3\n", "line \\'three\\' is modified\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    // Users can still provide back-slashes in regexes by escaping them.
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining='line \\\\'three\\\\' is'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_escapeDoubleQuotes() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line \"three\" is modified\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining='line \\\"three\\\" is'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_invalidSyntax() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining=forgot single quotes\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    // If the format is invalid, the operator falls back to the default operator of
+    // ChangeQueryBuilder which does not match the change, i.e. returns false.
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_invalidFilePattern() throws Exception {
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^**',withDiffContaining='content'\"");
+
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, changeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+    assertThat(srResult.errorMessage().get()).isEqualTo("Invalid file pattern.");
+  }
+
+  @Test
+  public void byFileEdits_invalidContentPattern() throws Exception {
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'fileName\\.txt',withDiffContaining='^**'\"");
+
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, changeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+    assertThat(srResult.errorMessage().get()).isEqualTo("Invalid content pattern.");
+  }
+
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
@@ -239,13 +881,55 @@
       @Nullable String applicabilityExpr,
       String submittabilityExpr,
       @Nullable String overrideExpr) {
+    return createSubmitRequirement(
+        /*name= */ "sr-name",
+        applicabilityExpr,
+        submittabilityExpr,
+        overrideExpr,
+        /*allowOverrideInChildProjects=*/ false);
+  }
+
+  private SubmitRequirement createSubmitRequirement(
+      String name,
+      @Nullable String applicabilityExpr,
+      String submittabilityExpr,
+      @Nullable String overrideExpr,
+      boolean allowOverrideInChildProjects) {
     return SubmitRequirement.builder()
-        .setName("sr-name")
+        .setName(name)
         .setDescription(Optional.of("sr-description"))
         .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
         .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
         .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
-        .setAllowOverrideInChildProjects(false)
+        .setAllowOverrideInChildProjects(allowOverrideInChildProjects)
         .build();
   }
+
+  /** Submit requirement predicate that always throws an error on match. */
+  static class ThrowingSubmitRequirementPredicate extends SubmitRequirementPredicate
+      implements ChangeIsOperandFactory {
+
+    public static final String OPERAND = "throwing-predicate";
+
+    public static final String ERROR_MESSAGE = "Error is storage";
+
+    public ThrowingSubmitRequirementPredicate() {
+      super("is", OPERAND);
+    }
+
+    @Override
+    public boolean match(ChangeData object) {
+      throw new SubmitRequirementEvaluationException(ERROR_MESSAGE);
+    }
+
+    @Override
+    public int getCost() {
+      return 0;
+    }
+
+    @Override
+    public Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException {
+      return this;
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
index 4675bc0..d8aa789 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
@@ -50,7 +50,7 @@
                 ProjectConfig.SUBMIT_REQUIREMENT,
                 /* subsection= */ submitRequirementName,
                 /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
-                /* value= */ "label:\"code-review=+2\""));
+                /* value= */ "label:\"Code-Review=+2\""));
 
     PushResult r = pushRefsMetaConfig();
     assertOkStatus(r);
@@ -77,7 +77,7 @@
               ProjectConfig.SUBMIT_REQUIREMENT,
               /* subsection= */ submitRequirementName,
               /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
-              /* value= */ "label:\"code-review=+2\"");
+              /* value= */ "label:\"Code-Review=+2\"");
           projectConfig.setString(
               ProjectConfig.SUBMIT_REQUIREMENT,
               /* subsection= */ submitRequirementName,
@@ -95,6 +95,78 @@
   }
 
   @Test
+  public void parametersDirectlyInSubmitRequirementsSectionAreRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    updateProjectConfig(
+        projectConfig -> {
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ null,
+              /* name= */ ProjectConfig.KEY_SR_DESCRIPTION,
+              /* value= */ "foo bar description");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ null,
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"Code-Review=+2\"");
+        });
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Submit requirements must be defined in submit-requirement.<name>"
+                + " subsections. Setting parameters directly in the submit-requirement section is"
+                + " not allowed: [%s, %s]",
+            ProjectConfig.KEY_SR_DESCRIPTION, ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION));
+  }
+
+  @Test
+  public void unsupportedParameterDirectlyInSubmitRequirementsSectionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ null,
+                /* name= */ "unknown",
+                /* value= */ "value"));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        "project.config: Submit requirements must be defined in submit-requirement.<name>"
+            + " subsections. Setting parameters directly in the submit-requirement section is"
+            + " not allowed: [unknown]");
+  }
+
+  @Test
+  public void unsupportedParameterForSubmitRequirementIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ "unknown",
+                /* value= */ "value"));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Unsupported parameters for submit requirement '%s': [unknown]",
+            submitRequirementName));
+  }
+
+  @Test
   public void conflictingSubmitRequirementsAreRejected() throws Exception {
     fetchRefsMetaConfig();
 
@@ -105,12 +177,12 @@
               ProjectConfig.SUBMIT_REQUIREMENT,
               /* subsection= */ submitRequirementName,
               /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
-              /* value= */ "label:\"code-review=+2\"");
+              /* value= */ "label:\"Code-Review=+2\"");
           projectConfig.setString(
               ProjectConfig.SUBMIT_REQUIREMENT,
               /* subsection= */ submitRequirementName.toLowerCase(Locale.US),
               /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
-              /* value= */ "label:\"code-review=+2\"");
+              /* value= */ "label:\"Code-Review=+2\"");
         });
 
     PushResult r = pushRefsMetaConfig();
@@ -132,7 +204,7 @@
                 ProjectConfig.SUBMIT_REQUIREMENT,
                 /* subsection= */ submitRequirementName,
                 /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
-                /* value= */ "label:\"code-review=+2\""));
+                /* value= */ "label:\"Code-Review=+2\""));
     PushResult r = pushRefsMetaConfig();
     assertOkStatus(r);
 
@@ -142,7 +214,7 @@
                 ProjectConfig.SUBMIT_REQUIREMENT,
                 /* subsection= */ submitRequirementName.toLowerCase(Locale.US),
                 /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
-                /* value= */ "label:\"code-review=+2\""));
+                /* value= */ "label:\"Code-Review=+2\""));
     r = pushRefsMetaConfig();
     assertErrorStatus(
         r,
@@ -170,8 +242,139 @@
         r,
         "Invalid project configuration",
         String.format(
-            "project.config: Submit requirement '%s' does not define a submittability expression.",
-            submitRequirementName));
+            "project.config: Setting a submittability expression for submit requirement '%s' is"
+                + " required: Missing %s.%s.%s",
+            submitRequirementName,
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION));
+  }
+
+  @Test
+  public void submitRequirementWithInvalidSubmittabilityExpressionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidExpression = "invalid_field:invalid_value";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+                /* value= */ invalidExpression));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                + " invalid: Unsupported operator %s",
+            invalidExpression,
+            submitRequirementName,
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+            invalidExpression));
+  }
+
+  @Test
+  public void submitRequirementWithInvalidApplicabilityExpressionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidExpression = "invalid_field:invalid_value";
+    updateProjectConfig(
+        projectConfig -> {
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"Code-Review=+2\"");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+              /* value= */ invalidExpression);
+        });
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                + " invalid: Unsupported operator %s",
+            invalidExpression,
+            submitRequirementName,
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+            invalidExpression));
+  }
+
+  @Test
+  public void submitRequirementWithInvalidOverrideExpressionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidExpression = "invalid_field:invalid_value";
+    updateProjectConfig(
+        projectConfig -> {
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"Code-Review=+2\"");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+              /* value= */ invalidExpression);
+        });
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                + " invalid: Unsupported operator %s",
+            invalidExpression,
+            submitRequirementName,
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+            invalidExpression));
+  }
+
+  @Test
+  public void submitRequirementWithInvalidAllowOverrideInChildProjectsIsRejected()
+      throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidValue = "invalid";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+                /* value= */ invalidValue));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Invalid value %s.%s.%s for submit requirement '%s': %s",
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+            submitRequirementName,
+            invalidValue));
   }
 
   private void fetchRefsMetaConfig() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
index 537c7d8..b019354 100644
--- a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
@@ -35,7 +35,9 @@
 import com.google.gerrit.server.query.approval.ApprovalContext;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
-import java.util.Date;
+import java.time.Instant;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
 public class ApprovalQueryIT extends AbstractDaemonTest {
@@ -65,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);
@@ -125,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;
 
@@ -239,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 {
@@ -250,7 +294,7 @@
   }
 
   private ApprovalContext contextForCodeReviewLabel(
-      int value, PatchSet.Id psId, Account.Id approver) {
+      int value, PatchSet.Id psId, Account.Id approver) throws Exception {
     ChangeNotes changeNotes = changeNotesFactory.create(project, psId.changeId());
     PatchSet.Id newPsId = PatchSet.id(psId.changeId(), psId.get() + 1);
     ChangeKind changeKind =
@@ -259,11 +303,20 @@
     PatchSetApproval approval =
         PatchSetApproval.builder()
             .postSubmit(false)
-            .granted(new Date())
+            .granted(Instant.now())
             .key(PatchSetApproval.key(psId, approver, LabelId.create("Code-Review")))
             .value(value)
             .build();
-    return ApprovalContext.create(
-        changeNotes, approval, changeNotes.getPatchSets().get(newPsId), changeKind);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo.newObjectReader())) {
+      return ApprovalContext.create(
+          changeNotes,
+          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 6cb13c5..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;
@@ -41,11 +42,10 @@
  */
 @NoHttpd
 public class RulesIT extends AbstractDaemonTest {
-  private static final String RULE_TEMPLATE =
-      "submit_rule(submit(W)) :- \n%s,\nW = label('OK', ok(user(1000000))).";
-
   @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 {
@@ -243,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);
     }
@@ -255,7 +255,9 @@
   }
 
   private void modifySubmitRules(String ruleTested) throws Exception {
-    String newContent = String.format(RULE_TEMPLATE, ruleTested);
+    String newContent =
+        String.format(
+            "submit_rule(submit(W)) :- \n%s,\nW = label('OK', ok(user(1000000))).", ruleTested);
 
     try (Repository repo = repoManager.openRepository(project);
         TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
index 3b38bad..2a06900 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
@@ -26,10 +26,10 @@
 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;
-import com.google.inject.Injector;
 import java.util.List;
 import org.junit.Test;
 
@@ -37,8 +37,7 @@
 @UseSsh
 public abstract class AbstractIndexTests extends AbstractDaemonTest {
   @Inject private ExtensionRegistry extensionRegistry;
-
-  public void configureIndex(Injector injector) {}
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @Test
   @GerritConfig(name = "index.autoReindexIfStale", value = "false")
@@ -46,18 +45,16 @@
     ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(changeIndexedCounter)) {
-      configureIndex(server.getTestInjector());
 
       PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
       String changeId = change.getChangeId();
       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);
@@ -76,17 +73,15 @@
     ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(changeIndexedCounter)) {
-      configureIndex(server.getTestInjector());
 
       PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
       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/CustomIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java
index 434071f..fee413a 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java
@@ -14,9 +14,29 @@
 
 package com.google.gerrit.acceptance.ssh;
 
+import static com.google.common.truth.Truth.assertThat;
+
 import com.google.gerrit.index.IndexType;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.testing.AbstractFakeIndex;
+import com.google.gerrit.index.testing.FakeIndexVersionManager;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.AbstractIndexModule;
+import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Map;
 import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
 
 /**
  * Tests for a defaulted custom index configuration. This unknown type is the opposite of {@link
@@ -24,10 +44,70 @@
  */
 public class CustomIndexIT extends AbstractIndexTests {
 
+  @Override
+  public Module createModule() {
+    return CustomIndexModule.latestVersion(false);
+  }
+
   @ConfigSuite.Default
   public static Config customIndexType() {
     Config config = new Config();
     config.setString("index", null, "type", "custom");
     return config;
   }
+
+  @Inject private ChangeIndexCollection changeIndex;
+
+  @Test
+  public void customIndexModuleIsBound() throws Exception {
+    assertThat(changeIndex.getSearchIndex()).isInstanceOf(CustomModuleFakeIndexChange.class);
+  }
+}
+
+class CustomIndexModule extends AbstractIndexModule {
+
+  public static CustomIndexModule latestVersion(boolean secondary) {
+    return new CustomIndexModule(null, -1 /* direct executor */, secondary);
+  }
+
+  private CustomIndexModule(Map<String, Integer> singleVersions, int threads, boolean secondary) {
+    super(singleVersions, threads, secondary);
+  }
+
+  @Override
+  protected Class<? extends AccountIndex> getAccountIndex() {
+    return AbstractFakeIndex.FakeAccountIndex.class;
+  }
+
+  @Override
+  protected Class<? extends ChangeIndex> getChangeIndex() {
+    return CustomModuleFakeIndexChange.class;
+  }
+
+  @Override
+  protected Class<? extends GroupIndex> getGroupIndex() {
+    return AbstractFakeIndex.FakeGroupIndex.class;
+  }
+
+  @Override
+  protected Class<? extends ProjectIndex> getProjectIndex() {
+    return AbstractFakeIndex.FakeProjectIndex.class;
+  }
+
+  @Override
+  protected Class<? extends VersionManager> getVersionManager() {
+    return FakeIndexVersionManager.class;
+  }
+}
+
+class CustomModuleFakeIndexChange extends AbstractFakeIndex.FakeChangeIndex {
+
+  @com.google.inject.Inject
+  CustomModuleFakeIndexChange(
+      SitePaths sitePaths,
+      ChangeData.Factory changeDataFactory,
+      @Assisted Schema<ChangeData> schema,
+      @GerritServerConfig Config cfg) {
+    super(sitePaths, changeDataFactory, schema, cfg);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
index 59f80e9..8f4458d 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -51,7 +52,6 @@
 @NoHttpd
 @UseSsh
 public class QueryIT extends AbstractDaemonTest {
-
   private static Gson gson = new Gson();
 
   @Test
@@ -422,10 +422,10 @@
   }
 
   private static List<ChangeAttribute> getChanges(String rawResponse) {
-    String[] lines = rawResponse.split("\\n");
-    List<ChangeAttribute> changes = new ArrayList<>(lines.length - 1);
-    for (int i = 0; i < lines.length - 1; i++) {
-      changes.add(gson.fromJson(lines[i], ChangeAttribute.class));
+    List<String> lines = Splitter.on("\n").omitEmptyStrings().splitToList(rawResponse);
+    List<ChangeAttribute> changes = new ArrayList<>(lines.size() - 1);
+    for (int i = 0; i < lines.size() - 1; i++) {
+      changes.add(gson.fromJson(lines.get(i), ChangeAttribute.class));
     }
     return changes;
   }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
index 58c2517..2de52b2 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -50,7 +51,8 @@
 
   @Test
   public void byCommitHash() throws Exception {
-    String id = change.getCommit().getId().toString().split("\\s+")[1];
+    String id =
+        Splitter.onPattern("\\s+").splitToList(change.getCommit().getId().toString()).get(1);
     addReviewer(id);
     removeReviewer(id);
   }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index ac05801..ab84e70 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -63,7 +63,6 @@
   private static final ImmutableList<String> MASTER_ONLY_ROOT_COMMANDS =
       ImmutableList.of(
           "ban-commit",
-          "copy-approvals",
           "create-account",
           "create-branch",
           "create-group",
@@ -207,22 +206,22 @@
   private List<String> parseCommandsFromGerritHelpText(String helpText) {
     List<String> commands = new ArrayList<>();
 
-    String[] lines = helpText.split("\\n");
+    List<String> lines = Splitter.on("\n").splitToList(helpText);
 
     // Skip all lines including the line starting with "Available commands"
     int row = 0;
     do {
       row++;
-    } while (row < lines.length && !lines[row - 1].startsWith("Available commands"));
+    } while (row < lines.size() && !lines.get(row - 1).startsWith("Available commands"));
 
     // Skip all empty lines
-    while (lines[row].trim().isEmpty()) {
+    while (lines.get(row).trim().isEmpty()) {
       row++;
     }
 
     // Parse commands from all lines that are indented (start with a space)
-    while (row < lines.length && lines[row].startsWith(" ")) {
-      String line = lines[row].trim();
+    while (row < lines.size() && lines.get(row).startsWith(" ")) {
+      String line = lines.get(row).trim();
       // Abort on empty line
       if (line.isEmpty()) {
         break;
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
index a2d4a06..84c3936 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -32,8 +32,6 @@
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.List;
 import org.junit.Test;
 
 @UseSsh
@@ -122,7 +120,7 @@
   }
 
   private static class TestPerformanceLogger implements PerformanceLogger {
-    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+    private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
 
     @Override
     public void log(String operation, long durationMs, Metadata metadata) {
@@ -130,7 +128,7 @@
     }
 
     ImmutableList<PerformanceLogEntry> logEntries() {
-      return ImmutableList.copyOf(logEntries);
+      return logEntries.build();
     }
   }
 
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/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
index a003f9d..473b128 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -325,9 +325,9 @@
     GroupInfo group = gApi.groups().create(createArbitraryGroupInput()).detail();
     AccountGroup.UUID groupUuid = AccountGroup.uuid(group.id);
 
-    Timestamp createdOn = groupOperations.group(groupUuid).get().createdOn();
+    Instant createdOn = groupOperations.group(groupUuid).get().createdOn();
 
-    assertThat(createdOn).isEqualTo(group.createdOn);
+    assertThat(createdOn).isEqualTo(group.createdOn.toInstant());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/common/BUILD b/javatests/com/google/gerrit/common/BUILD
index c7b21a3..492d007 100644
--- a/javatests/com/google/gerrit/common/BUILD
+++ b/javatests/com/google/gerrit/common/BUILD
@@ -11,6 +11,7 @@
         "//lib:guava",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/log:log4j",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java b/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
index ec71e05..d959c2a 100644
--- a/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
+++ b/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
@@ -45,6 +45,7 @@
 
   @Test
   public void cppExtensions() {
+    assertThat(comparator.compare("abc/file.hh", "abc/file.cc")).isLessThan(0);
     assertThat(comparator.compare("abc/file.h", "abc/file.cc")).isLessThan(0);
     assertThat(comparator.compare("abc/file.c", "abc/file.hpp")).isGreaterThan(0);
     assertThat(comparator.compare("abc..xyz.file.h", "abc.xyz.file.cc")).isLessThan(0);
diff --git a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
index 593b635..477f9d2 100644
--- a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
+++ b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
@@ -127,8 +127,16 @@
     AccountGroup.UUID uuid1 = AccountGroup.uuid("uuid1");
     assertThat(GroupReference.create(uuid1, "foo").hashCode())
         .isEqualTo(GroupReference.create(uuid1, "bar").hashCode());
+  }
 
-    // Check that the following calls don't fail with an exception.
-    GroupReference.create("bar").hashCode();
+  @Test
+  public void testEqualsWithoutUuid() {
+    assertThat(GroupReference.create("foo").equals(GroupReference.create("bar"))).isTrue();
+  }
+
+  @Test
+  public void testHashCodeWithoutUuid() {
+    assertThat(GroupReference.create("foo").hashCode())
+        .isEqualTo(GroupReference.create("bar").hashCode());
   }
 }
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/entities/LabelFunctionTest.java b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
index 0132697..80d97db 100644
--- a/javatests/com/google/gerrit/entities/LabelFunctionTest.java
+++ b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.List;
 import org.junit.Test;
 
@@ -94,7 +93,7 @@
     return PatchSetApproval.builder()
         .key(PatchSetApproval.key(PS_ID, Account.id(10000 + value), LABEL_ID))
         .value(value)
-        .granted(Date.from(Instant.now()))
+        .granted(Instant.now())
         .build();
   }
 
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
index ec6c372..22daf5b 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import java.lang.reflect.Type;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class ChangeMessageProtoConverterTest {
@@ -40,7 +40,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13),
             "This is a change message.",
             Account.id(10003),
@@ -73,7 +73,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13));
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
@@ -140,7 +140,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13),
             "This is a change message.",
             Account.id(10003),
@@ -157,7 +157,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13),
             String.format(
                 "This is a change message by %s and includes %s ",
@@ -178,7 +178,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13));
 
     ChangeMessage convertedChangeMessage =
@@ -205,7 +205,7 @@
             ImmutableMap.<String, Type>builder()
                 .put("key", ChangeMessage.Key.class)
                 .put("author", Account.Id.class)
-                .put("writtenOn", Timestamp.class)
+                .put("writtenOn", Instant.class)
                 .put("message", String.class)
                 .put("patchset", PatchSet.Id.class)
                 .put("tag", String.class)
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index 8c5e449..bd4b2b1 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import java.lang.reflect.Type;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class ChangeProtoConverterTest {
@@ -41,8 +41,8 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch 74"),
-            new Timestamp(987654L));
-    change.setLastUpdatedOn(new Timestamp(1234567L));
+            Instant.ofEpochMilli(987654L));
+    change.setLastUpdatedOn(Instant.ofEpochMilli(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
         PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
@@ -90,7 +90,7 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
+            Instant.ofEpochMilli(987654L));
 
     Entities.Change proto = changeProtoConverter.toProto(change);
 
@@ -125,7 +125,7 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
+            Instant.ofEpochMilli(987654L));
     // O as ID actually means that no current patch set is present.
     change.setCurrentPatchSet(PatchSet.id(Change.id(14), 0), null, null);
 
@@ -162,7 +162,7 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
+            Instant.ofEpochMilli(987654L));
     change.setCurrentPatchSet(PatchSet.id(Change.id(14), 23), "subject ABC", null);
 
     Entities.Change proto = changeProtoConverter.toProto(change);
@@ -198,8 +198,8 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
-    change.setLastUpdatedOn(new Timestamp(1234567L));
+            Instant.ofEpochMilli(987654L));
+    change.setLastUpdatedOn(Instant.ofEpochMilli(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
         PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
@@ -223,7 +223,7 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
+            Instant.ofEpochMilli(987654L));
 
     Change convertedChange = changeProtoConverter.fromProto(changeProtoConverter.toProto(change));
     assertEqualChange(convertedChange, change);
@@ -242,8 +242,8 @@
     assertThat(change.getKey()).isNull();
     assertThat(change.getOwner()).isNull();
     assertThat(change.getDest()).isNull();
-    assertThat(change.getCreatedOn()).isEqualTo(new Timestamp(0));
-    assertThat(change.getLastUpdatedOn()).isEqualTo(new Timestamp(0));
+    assertThat(change.getCreatedOn()).isEqualTo(Instant.EPOCH);
+    assertThat(change.getLastUpdatedOn()).isEqualTo(Instant.EPOCH);
     assertThat(change.getSubject()).isNull();
     assertThat(change.currentPatchSetId()).isNull();
     // Default values for unset protobuf fields which can't be unset in the entity object.
@@ -268,7 +268,7 @@
             .build();
     Change change = changeProtoConverter.fromProto(proto);
 
-    assertThat(change.getLastUpdatedOn()).isEqualTo(new Timestamp(987654L));
+    assertThat(change.getLastUpdatedOn()).isEqualTo(Instant.ofEpochMilli(987654L));
   }
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
@@ -279,8 +279,8 @@
             ImmutableMap.<String, Type>builder()
                 .put("changeId", Change.Id.class)
                 .put("changeKey", Change.Key.class)
-                .put("createdOn", Timestamp.class)
-                .put("lastUpdatedOn", Timestamp.class)
+                .put("createdOn", Instant.class)
+                .put("lastUpdatedOn", Instant.class)
                 .put("owner", Account.Id.class)
                 .put("dest", BranchNameKey.class)
                 .put("status", char.class)
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
index d332f8a..28f9cdb 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
@@ -28,8 +28,7 @@
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
 import java.lang.reflect.Type;
-import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -44,8 +43,9 @@
             .key(
                 PatchSetApproval.key(
                     PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .uuid(Optional.of(PatchSetApproval.uuid("577fb248e474018276351785930358ec0450e9f7")))
             .value(456)
-            .granted(new Date(987654L))
+            .granted(Instant.ofEpochMilli(987654L))
             .tag("tag-21")
             .realAccountId(Account.id(612))
             .postSubmit(true)
@@ -64,6 +64,7 @@
                             .setId(14))
                     .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
                     .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
+            .setUuid("577fb248e474018276351785930358ec0450e9f7")
             .setValue(456)
             .setGranted(987654L)
             .setTag("tag-21")
@@ -82,7 +83,7 @@
                 PatchSetApproval.key(
                     PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
             .value(456)
-            .granted(new Date(987654L))
+            .granted(Instant.ofEpochMilli(987654L))
             .build();
 
     Entities.PatchSetApproval proto = protoConverter.toProto(patchSetApproval);
@@ -113,8 +114,9 @@
             .key(
                 PatchSetApproval.key(
                     PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .uuid(Optional.of(PatchSetApproval.uuid("577fb248e474018276351785930358ec0450e9f7")))
             .value(456)
-            .granted(new Date(987654L))
+            .granted(Instant.ofEpochMilli(987654L))
             .tag("tag-21")
             .realAccountId(Account.id(612))
             .postSubmit(true)
@@ -134,7 +136,7 @@
                 PatchSetApproval.key(
                     PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
             .value(456)
-            .granted(new Date(987654L))
+            .granted(Instant.ofEpochMilli(987654L))
             .build();
 
     PatchSetApproval convertedPatchSetApproval =
@@ -164,7 +166,7 @@
     assertThat(patchSetApproval.labelId()).isEqualTo(LabelId.create("label-8"));
     // Default values for unset protobuf fields which can't be unset in the entity object.
     assertThat(patchSetApproval.value()).isEqualTo(0);
-    assertThat(patchSetApproval.granted()).isEqualTo(new Timestamp(0));
+    assertThat(patchSetApproval.granted()).isEqualTo(Instant.EPOCH);
     assertThat(patchSetApproval.postSubmit()).isEqualTo(false);
     assertThat(patchSetApproval.copied()).isEqualTo(false);
   }
@@ -176,8 +178,9 @@
         .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("key", PatchSetApproval.Key.class)
+                .put("uuid", new TypeLiteral<Optional<PatchSetApproval.UUID>>() {}.getType())
                 .put("value", short.class)
-                .put("granted", Timestamp.class)
+                .put("granted", Instant.class)
                 .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("realAccountId", Account.Id.class)
                 .put("postSubmit", boolean.class)
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
index efeb24f..3a534e9 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
 import java.lang.reflect.Type;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -42,7 +42,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
-            .createdOn(new Timestamp(930349320L))
+            .createdOn(Instant.ofEpochMilli(930349320L))
             .groups(ImmutableList.of("group1", " group2"))
             .pushCertificate("my push certificate")
             .description("This is a patch set description.")
@@ -74,7 +74,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
-            .createdOn(new Timestamp(930349320L))
+            .createdOn(Instant.ofEpochMilli(930349320L))
             .build();
 
     Entities.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
@@ -100,7 +100,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
-            .createdOn(new Timestamp(930349320L))
+            .createdOn(Instant.ofEpochMilli(930349320L))
             .groups(ImmutableList.of("group1", " group2"))
             .pushCertificate("my push certificate")
             .description("This is a patch set description.")
@@ -118,7 +118,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
-            .createdOn(new Timestamp(930349320L))
+            .createdOn(Instant.ofEpochMilli(930349320L))
             .build();
 
     PatchSet convertedPatchSet =
@@ -143,7 +143,7 @@
                 .id(PatchSet.id(Change.id(103), 73))
                 .commitId(ObjectId.fromString("0000000000000000000000000000000000000000"))
                 .uploader(Account.id(0))
-                .createdOn(new Timestamp(0))
+                .createdOn(Instant.EPOCH)
                 .build());
   }
 
@@ -156,7 +156,7 @@
                 .put("id", PatchSet.Id.class)
                 .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
-                .put("createdOn", Timestamp.class)
+                .put("createdOn", Instant.class)
                 .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
                 .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("description", new TypeLiteral<Optional<String>>() {}.getType())
diff --git a/javatests/com/google/gerrit/git/RefUpdateUtilTest.java b/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
index 1d021f7..a1f6cef 100644
--- a/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
+++ b/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
@@ -33,44 +33,38 @@
 
 @RunWith(JUnit4.class)
 public class RefUpdateUtilTest {
-  private static final Consumer<ReceiveCommand> OK = c -> c.setResult(ReceiveCommand.Result.OK);
-  private static final Consumer<ReceiveCommand> LOCK_FAILURE =
-      c -> c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
-  private static final Consumer<ReceiveCommand> REJECTED =
-      c -> c.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON);
-  private static final Consumer<ReceiveCommand> ABORTED =
-      c -> {
-        c.setResult(ReceiveCommand.Result.NOT_ATTEMPTED);
-        ReceiveCommand.abort(ImmutableList.of(c));
-        checkState(
-            c.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED
-                && c.getResult() != ReceiveCommand.Result.LOCK_FAILURE
-                && c.getResult() != ReceiveCommand.Result.OK,
-            "unexpected state after abort: %s",
-            c);
-      };
-
   @Test
   public void checkBatchRefUpdateResults() throws Exception {
     checkResults();
-    checkResults(OK);
-    checkResults(OK, OK);
+    checkResults(RefUpdateUtilTest::ok);
+    checkResults(RefUpdateUtilTest::ok, RefUpdateUtilTest::ok);
 
-    assertIoException(REJECTED);
-    assertIoException(OK, REJECTED);
-    assertIoException(LOCK_FAILURE, REJECTED);
-    assertIoException(LOCK_FAILURE, OK);
-    assertIoException(LOCK_FAILURE, REJECTED, OK);
-    assertIoException(LOCK_FAILURE, LOCK_FAILURE, REJECTED);
-    assertIoException(LOCK_FAILURE, ABORTED, REJECTED);
-    assertIoException(LOCK_FAILURE, ABORTED, OK);
+    assertIoException(RefUpdateUtilTest::rejected);
+    assertIoException(RefUpdateUtilTest::ok, RefUpdateUtilTest::rejected);
+    assertIoException(RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::rejected);
+    assertIoException(RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::ok);
+    assertIoException(
+        RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::rejected, RefUpdateUtilTest::ok);
+    assertIoException(
+        RefUpdateUtilTest::lockFailure,
+        RefUpdateUtilTest::lockFailure,
+        RefUpdateUtilTest::rejected);
+    assertIoException(
+        RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::aborted, RefUpdateUtilTest::rejected);
+    assertIoException(
+        RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::aborted, RefUpdateUtilTest::ok);
 
-    assertLockFailureException(LOCK_FAILURE);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED, ABORTED);
-    assertLockFailureException(ABORTED);
-    assertLockFailureException(ABORTED, ABORTED);
+    assertLockFailureException(RefUpdateUtilTest::lockFailure);
+    assertLockFailureException(RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::lockFailure);
+    assertLockFailureException(
+        RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::aborted);
+    assertLockFailureException(
+        RefUpdateUtilTest::lockFailure,
+        RefUpdateUtilTest::lockFailure,
+        RefUpdateUtilTest::aborted,
+        RefUpdateUtilTest::aborted);
+    assertLockFailureException(RefUpdateUtilTest::aborted);
+    assertLockFailureException(RefUpdateUtilTest::aborted, RefUpdateUtilTest::aborted);
   }
 
   @SafeVarargs
@@ -110,4 +104,27 @@
       return bru;
     }
   }
+
+  private static void ok(ReceiveCommand c) {
+    c.setResult(ReceiveCommand.Result.OK);
+  }
+
+  private static void lockFailure(ReceiveCommand c) {
+    c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
+  }
+
+  private static void rejected(ReceiveCommand c) {
+    c.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON);
+  }
+
+  private static void aborted(ReceiveCommand c) {
+    c.setResult(ReceiveCommand.Result.NOT_ATTEMPTED);
+    ReceiveCommand.abort(ImmutableList.of(c));
+    checkState(
+        c.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED
+            && c.getResult() != ReceiveCommand.Result.LOCK_FAILURE
+            && c.getResult() != ReceiveCommand.Result.OK,
+        "unexpected state after abort: %s",
+        c);
+  }
 }
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
index 7703fb0..8bafafe 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
@@ -38,11 +38,12 @@
 import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.gpg.testing.TestKey;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -212,9 +213,11 @@
     String problem = "Key is revoked (key material has been compromised): test6 compromised";
     assertProblems(k, problem);
 
-    SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
-    PublicKeyChecker checker =
-        new PublicKeyChecker().setStore(store).setEffectiveTime(df.parse("2010-01-01 12:00:00"));
+    Instant instant =
+        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
+            .withZone(ZoneId.systemDefault())
+            .parse("2010-01-01 12:00:00", Instant::from);
+    PublicKeyChecker checker = new PublicKeyChecker().setStore(store).setEffectiveTime(instant);
     assertProblems(checker, k, problem);
   }
 
@@ -360,8 +363,8 @@
         + " is valid, but key is not trusted";
   }
 
-  private static Date parseDate(String str) throws Exception {
-    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").parse(str);
+  private static Instant parseDate(String str) throws Exception {
+    return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z").parse(str, Instant::from);
   }
 
   private static List<String> list(String first, String[] rest) {
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/auth/container/HttpAuthFilterTest.java b/javatests/com/google/gerrit/httpd/auth/container/HttpAuthFilterTest.java
new file mode 100644
index 0000000..a5f8349
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/auth/container/HttpAuthFilterTest.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.auth.container;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.doReturn;
+
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.httpd.WebSession;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
+import java.io.IOException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.StrictStubs.class)
+public class HttpAuthFilterTest {
+
+  private static String DISPLAYNAME_HEADER = "displaynameHeader";
+  private static String DISPLAYNAME = "displayname";
+
+  @Mock private DynamicItem<WebSession> webSession;
+  @Mock private ExternalIdKeyFactory externalIdKeyFactory;
+  @Mock private AuthConfig authConfig;
+
+  @Test
+  public void getRemoteDisplaynameShouldReturnDisplaynameHeaderWhenHeaderIsConfiguredAndSet()
+      throws IOException {
+    doReturn(DISPLAYNAME_HEADER).when(authConfig).getHttpDisplaynameHeader();
+    HttpAuthFilter httpAuthFilter =
+        new HttpAuthFilter(webSession, authConfig, externalIdKeyFactory);
+
+    FakeHttpServletRequest req = new FakeHttpServletRequest();
+    req.addHeader(DISPLAYNAME_HEADER, DISPLAYNAME);
+
+    assertThat(httpAuthFilter.getRemoteDisplayname(req)).isEqualTo(DISPLAYNAME);
+  }
+
+  @Test
+  public void getRemoteDisplaynameShouldReturnNullWhenDisplaynameHeaderIsConfiguredAndNotSet()
+      throws IOException {
+    doReturn(DISPLAYNAME_HEADER).when(authConfig).getHttpDisplaynameHeader();
+    HttpAuthFilter httpAuthFilter =
+        new HttpAuthFilter(webSession, authConfig, externalIdKeyFactory);
+
+    FakeHttpServletRequest req = new FakeHttpServletRequest();
+
+    assertThat(httpAuthFilter.getRemoteDisplayname(req)).isNull();
+  }
+
+  @Test
+  public void getRemoteDisplaynameShouldReturnNullWhenDisplaynameHeaderIsConfiguredAndEmpty()
+      throws IOException {
+    doReturn(DISPLAYNAME_HEADER).when(authConfig).getHttpDisplaynameHeader();
+    HttpAuthFilter httpAuthFilter =
+        new HttpAuthFilter(webSession, authConfig, externalIdKeyFactory);
+
+    FakeHttpServletRequest req = new FakeHttpServletRequest();
+    req.addHeader(DISPLAYNAME_HEADER, "");
+
+    assertThat(httpAuthFilter.getRemoteDisplayname(req)).isNull();
+  }
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
index 6691587..cc1ee00 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -116,7 +116,7 @@
 
     assertThat(dynamicTemplateData(gerritApi, "/c/project/+/123"))
         .containsAtLeast(
-            "defaultChangeDetailHex", "916314",
+            "defaultChangeDetailHex", "1916314",
             "changeRequestsPath", "changes/project~123");
   }
 
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/index/query/PredicateTest.java b/javatests/com/google/gerrit/index/query/PredicateTest.java
index 9b70159..d789201 100644
--- a/javatests/com/google/gerrit/index/query/PredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/PredicateTest.java
@@ -18,6 +18,7 @@
 
 @Ignore
 public abstract class PredicateTest {
+  @SuppressWarnings("ProtectedMembersInFinalClass")
   protected static class TestDataSourcePredicate extends TestMatchablePredicate<String>
       implements DataSource<String> {
     protected final int cardinality;
diff --git a/javatests/com/google/gerrit/index/query/QueryParserTest.java b/javatests/com/google/gerrit/index/query/QueryParserTest.java
index 776a2c4..2ff56a8 100644
--- a/javatests/com/google/gerrit/index/query/QueryParserTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryParserTest.java
@@ -17,7 +17,9 @@
 import static com.google.gerrit.index.query.QueryParser.AND;
 import static com.google.gerrit.index.query.QueryParser.COLON;
 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.SINGLE_WORD;
 import static com.google.gerrit.index.query.QueryParser.parse;
 import static com.google.gerrit.index.query.testing.TreeSubject.assertThat;
@@ -204,6 +206,156 @@
     assertThat(r).child(2).hasNoChildren();
   }
 
+  @Test
+  public void fieldNameWithEscapedDoubleQuotesInValue() throws Exception {
+    // Actual String: A \"special\" word
+    String search = "message:\"A \\\"special\\\" word\"";
+    Tree r = parse(search);
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasChildCount(1);
+    assertThat(r).hasText("message");
+    assertThat(r).child(0).hasType(EXACT_PHRASE);
+    // Antlr escaped the double quotes in the phrase.
+    assertThat(r).child(0).hasText("A \"special\" word");
+  }
+
+  @Test
+  public void fieldNameWithEscapedTabCharacterIsPreserved() throws Exception {
+    String[] searches = {"message:\"A \\t word\"", "message:{A \\t word}"};
+    for (String search : searches) {
+      Tree r = parse(search);
+      assertThat(r).hasType(FIELD_NAME);
+      assertThat(r).hasChildCount(1);
+      assertThat(r).hasText("message");
+      assertThat(r).child(0).hasType(EXACT_PHRASE);
+      assertThat(r).child(0).hasText("A \t word");
+    }
+  }
+
+  @Test
+  public void fieldNameWithEscapedBackslashIsIncludedInOutput() throws Exception {
+    String search = "message:\"A backslash \\\\ in phrase\"";
+    Tree r = parse(search);
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasChildCount(1);
+    assertThat(r).hasText("message");
+    assertThat(r).child(0).hasType(EXACT_PHRASE);
+    assertThat(r).child(0).hasText("A backslash \\ in phrase");
+  }
+
+  @Test
+  public void fieldNameWithNot() throws Exception {
+    Tree r = parse("-foo:bar");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasText("NOT");
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(FIELD_NAME);
+    assertThat(r).child(0).hasText("foo");
+    assertThat(r).child(0).hasChildCount(1);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("bar");
+    assertThat(r).child(0).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithDigit() throws Exception {
+    Tree r = parse("foo9:bar");
+    assertThat(r).hasType(DEFAULT_FIELD);
+    assertThat(r).hasChildCount(3);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("foo9");
+    assertThat(r).child(0).hasNoChildren();
+    assertThat(r).child(1).hasType(COLON);
+    assertThat(r).child(1).hasText(":");
+    assertThat(r).child(1).hasNoChildren();
+    assertThat(r).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(2).hasText("bar");
+    assertThat(r).child(2).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithUnderscore() throws Exception {
+    Tree r = parse("foo_bar:baz");
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasText("foo_bar");
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("baz");
+    assertThat(r).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithHyphen() throws Exception {
+    Tree r = parse("foo-bar:baz");
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasText("foo-bar");
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("baz");
+    assertThat(r).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameEndingWithHyphen() throws Exception {
+    Tree r = parse("foo-:bar");
+    assertThat(r).hasType(DEFAULT_FIELD);
+    assertThat(r).hasChildCount(3);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("foo-");
+    assertThat(r).child(0).hasNoChildren();
+    assertThat(r).child(1).hasType(COLON);
+    assertThat(r).child(1).hasText(":");
+    assertThat(r).child(1).hasNoChildren();
+    assertThat(r).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(2).hasText("bar");
+    assertThat(r).child(2).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithNotAndHyphen() throws Exception {
+    Tree r = parse("-foo-bar:baz");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasText("NOT");
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(FIELD_NAME);
+    assertThat(r).child(0).hasText("foo-bar");
+    assertThat(r).child(0).hasChildCount(1);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("baz");
+    assertThat(r).child(0).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithNotAndEndingWithHyphen() throws Exception {
+    Tree r = parse("-foo-bar-:baz");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(DEFAULT_FIELD);
+    assertThat(r).child(0).hasChildCount(3);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("foo-bar-");
+    assertThat(r).child(0).child(0).hasNoChildren();
+    assertThat(r).child(0).child(1).hasType(COLON);
+    assertThat(r).child(0).child(1).hasText(":");
+    assertThat(r).child(0).child(1).hasNoChildren();
+    assertThat(r).child(0).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(2).hasText("baz");
+    assertThat(r).child(0).child(2).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithMiscellaneousCharacters() throws Exception {
+    Tree r = parse("-foo-bar_-baz_:qux");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(FIELD_NAME);
+    assertThat(r).child(0).hasText("foo-bar_-baz_");
+    assertThat(r).child(0).hasChildCount(1);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("qux");
+    assertThat(r).child(0).child(0).hasNoChildren();
+  }
+
   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/integration/ssh/PeerKeysAuthIT.java b/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java
index a219cc2..039bc8e 100644
--- a/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java
+++ b/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java
@@ -19,6 +19,7 @@
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.GerritServer.TestSshServerAddress;
@@ -31,6 +32,7 @@
 import java.io.IOException;
 import java.net.InetSocketAddress;
 import java.nio.file.Files;
+import java.util.List;
 import org.junit.Test;
 
 @NoHttpd
@@ -59,17 +61,19 @@
       // Generate private/public key for user
       execute(ImmutableList.<String>builder().add(SSH_KEYGEN_CMD).build());
 
-      String[] parts =
-          new String(Files.readAllBytes(sitePaths.data_dir.resolve("id_rsa.pub")), UTF_8)
-              .split(" ");
+      List<String> parts =
+          Splitter.on(" ")
+              .splitToList(
+                  new String(Files.readAllBytes(sitePaths.data_dir.resolve("id_rsa.pub")), UTF_8));
 
       // Loose algorithm at index 0, verify the format: "key comment"
       Files.write(
-          sitePaths.peer_keys, String.format("%s %s", parts[1], parts[2]).getBytes(ISO_8859_1));
+          sitePaths.peer_keys,
+          String.format("%s %s", parts.get(1), parts.get(2)).getBytes(ISO_8859_1));
       assertContent(execGerritVersionCommand());
 
       // Only preserve the key material: no algorithm and no comment
-      Files.write(sitePaths.peer_keys, parts[1].getBytes(ISO_8859_1));
+      Files.write(sitePaths.peer_keys, parts.get(1).getBytes(ISO_8859_1));
       assertContent(execGerritVersionCommand());
 
       // Wipe out the content of the peer keys file
diff --git a/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java b/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java
index 2699c3b..44ef822 100644
--- a/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java
+++ b/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java
@@ -16,9 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gson.JsonPrimitive;
 import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class SqlTimestampDeserializerTest {
@@ -28,6 +28,6 @@
   @Test
   public void emptyStringIsDeserializedToMagicTimestamp() {
     Timestamp timestamp = deserializer.deserialize(new JsonPrimitive(""), Timestamp.class, null);
-    assertThat(timestamp).isEqualTo(TimeUtil.never());
+    assertThat(timestamp).isEqualTo(Timestamp.from(Instant.EPOCH));
   }
 }
diff --git a/javatests/com/google/gerrit/mail/AbstractParserTest.java b/javatests/com/google/gerrit/mail/AbstractParserTest.java
index a2432a2..f99a2af 100644
--- a/javatests/com/google/gerrit/mail/AbstractParserTest.java
+++ b/javatests/com/google/gerrit/mail/AbstractParserTest.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
@@ -58,7 +57,7 @@
         new HumanComment(
             new Comment.Key(uuid, file, 1),
             Account.id(0),
-            new Timestamp(0L),
+            Instant.EPOCH,
             (short) 0,
             message,
             "",
@@ -73,7 +72,7 @@
         new HumanComment(
             new Comment.Key(uuid, file, 1),
             Account.id(0),
-            new Timestamp(0L),
+            Instant.EPOCH,
             (short) 0,
             message,
             "",
diff --git a/javatests/com/google/gerrit/metrics/BUILD b/javatests/com/google/gerrit/metrics/BUILD
new file mode 100644
index 0000000..531280e
--- /dev/null
+++ b/javatests/com/google/gerrit/metrics/BUILD
@@ -0,0 +1,13 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "field_tests",
+    size = "small",
+    srcs = glob(["*.java"]),
+    tags = ["metrics"],
+    deps = [
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/metrics/FieldSanitizeProjectNameTest.java b/javatests/com/google/gerrit/metrics/FieldSanitizeProjectNameTest.java
new file mode 100644
index 0000000..f12b1b3
--- /dev/null
+++ b/javatests/com/google/gerrit/metrics/FieldSanitizeProjectNameTest.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.Arrays;
+import java.util.Collection;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+@RunWith(Parameterized.class)
+public class FieldSanitizeProjectNameTest {
+  @Parameterized.Parameters
+  public static Collection<Object[]> testData() {
+    return Arrays.asList(
+        new Object[][] {
+          {"repoName", "repoName"},
+          {"repo_name", "repo_name"},
+          {"repo-name", "repo-name"},
+          {"repo/name", "repo_0x2F_name"},
+          {"repo+name", "repo_0x2B_name"},
+          {"repo_0x2F_name", "repo_0x_0x2F_name"},
+        });
+  }
+
+  private final String input;
+  private final String expected;
+
+  public FieldSanitizeProjectNameTest(String input, String expected) {
+    this.input = input;
+    this.expected = expected;
+  }
+
+  @Test
+  public void shouldSanitizeProjectName() {
+    Field<String> projectNameField = Field.ofProjectName("test_name").build();
+    String result = projectNameField.formatter().apply(input);
+    assertThat(result).isEqualTo(expected);
+  }
+}
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/BUILD b/javatests/com/google/gerrit/metrics/dropwizard/BUILD
index e236f30..42cf111 100644
--- a/javatests/com/google/gerrit/metrics/dropwizard/BUILD
+++ b/javatests/com/google/gerrit/metrics/dropwizard/BUILD
@@ -8,6 +8,7 @@
     deps = [
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/metrics/dropwizard",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib/mockito",
         "//lib/truth",
         "@dropwizard-core//jar",
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/BucketedCallbackTest.java b/javatests/com/google/gerrit/metrics/dropwizard/BucketedCallbackTest.java
new file mode 100644
index 0000000..e87a208
--- /dev/null
+++ b/javatests/com/google/gerrit/metrics/dropwizard/BucketedCallbackTest.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.codahale.metrics.MetricRegistry;
+import com.google.gerrit.metrics.Description;
+import org.junit.Before;
+import org.junit.Test;
+
+public class BucketedCallbackTest {
+
+  private MetricRegistry registry;
+
+  private DropWizardMetricMaker metrics;
+
+  private static final String CODE_NAME = "name";
+  private static final String KEY_NAME = "foo";
+  private static final String OTHER_KEY_NAME = "bar";
+  private static final String COLLIDING_KEY_NAME1 = "foo1";
+  private static final String COLLIDING_KEY_NAME2 = "foo2";
+  private static final String COLLIDING_SUBMETRIC_NAME = "foocollision";
+
+  private String metricName(String fieldValues) {
+    return CODE_NAME + "/" + fieldValues;
+  }
+
+  @Before
+  public void setup() {
+    registry = new MetricRegistry();
+    metrics = new DropWizardMetricMaker(registry, null);
+  }
+
+  @Test
+  public void shouldRegisterMetricWithNewKey() {
+    BucketedCallback<Long> bc = new CallbackMetricTestImpl();
+
+    bc.getOrCreate(KEY_NAME);
+    assertThat(registry.getNames()).containsExactly(metricName(KEY_NAME));
+
+    bc.getOrCreate(OTHER_KEY_NAME);
+    assertThat(registry.getNames())
+        .containsExactly(metricName(KEY_NAME), metricName(OTHER_KEY_NAME));
+  }
+
+  @Test
+  public void shouldNotReRegisterPreviouslyRegisteredMetric() {
+    BucketedCallback<Long> bc = new CallbackMetricTestImpl();
+    bc.getOrCreate(KEY_NAME);
+    bc.getOrCreate(KEY_NAME);
+    assertThat(registry.getNames()).containsExactly(metricName(KEY_NAME));
+  }
+
+  @Test
+  public void shouldStoreKeyValueInCellsAndRegisterSubmetricName() {
+    BucketedCallback<Long> bc = new CallbackMetricTestImpl();
+    bc.getOrCreate(COLLIDING_KEY_NAME1);
+    assertThat(bc.getCells().keySet()).containsExactly(COLLIDING_KEY_NAME1);
+    assertThat(registry.getNames()).containsExactly(metricName(COLLIDING_SUBMETRIC_NAME));
+  }
+
+  @Test
+  public void shouldErrorIfKeyIsDifferentButNameCollides() {
+    BucketedCallback<Long> bc = new CallbackMetricTestImpl();
+    bc.getOrCreate(COLLIDING_KEY_NAME1);
+
+    assertThrows(IllegalArgumentException.class, () -> bc.getOrCreate(COLLIDING_KEY_NAME2));
+    assertThat(bc.getCells().keySet()).containsExactly(COLLIDING_KEY_NAME1);
+    assertThat(registry.getNames()).containsExactly(metricName(COLLIDING_SUBMETRIC_NAME));
+  }
+
+  private class CallbackMetricTestImpl extends BucketedCallback<Long> {
+
+    CallbackMetricTestImpl() {
+      super(metrics, registry, CODE_NAME, Long.class, new Description("description"));
+    }
+
+    @Override
+    String name(Object key) {
+      if (key.equals(COLLIDING_KEY_NAME1) || key.equals(COLLIDING_KEY_NAME2)) {
+        return COLLIDING_SUBMETRIC_NAME;
+      } else {
+        return key.toString();
+      }
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
index 5777779..a50520ac 100644
--- a/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
+++ b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
@@ -44,15 +44,33 @@
   }
 
   @Test
+  public void shouldNotSanitizeSafeName() throws Exception {
+    assertThat(metrics.sanitizeMetricName("metric/submetric1/submetric2/submetric3"))
+        .isEqualTo("metric/submetric1/submetric2/submetric3");
+  }
+
+  @Test
+  public void shouldNotCreateSimpleCollisions() throws Exception {
+    String sanitizedCase1 = metrics.sanitizeMetricName("foo_bar");
+    String sanitizedCase2 = metrics.sanitizeMetricName("foo+bar");
+    assertThat(sanitizedCase1).isNotEqualTo(sanitizedCase2);
+  }
+
+  @Test
+  public void shouldDoubleTheReplacementPrefix() throws Exception {
+    assertThat(metrics.sanitizeMetricName("foo_0x_bar")).isEqualTo("foo_0x_0x_bar");
+  }
+
+  @Test
   public void shouldSanitizeUnwantedChars() throws Exception {
     assertThat(metrics.sanitizeMetricName("very+confusing$long#metric@net/name^1"))
-        .isEqualTo("very_confusing_long_metric_net/name_1");
+        .isEqualTo("very_0x2B_confusing_0x24_long_0x23_metric_0x40_net/name_0x5E_1");
     assertThat(metrics.sanitizeMetricName("/metric/submetric")).isEqualTo("_metric/submetric");
   }
 
   @Test
   public void shouldReduceConsecutiveSlashesToOne() throws Exception {
-    assertThat(metrics.sanitizeMetricName("/metric//submetric1///submetric2/submetric3"))
+    assertThat(metrics.sanitizeMetricName("/metric///submetric1///submetric2/submetric3"))
         .isEqualTo("_metric/submetric1/submetric2/submetric3");
   }
 
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index efe21d7..a586c0e 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -53,6 +53,7 @@
         "//java/com/google/gerrit/proto/testing",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/account/externalids/testing",
+        "//java/com/google/gerrit/server/cache/mem",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
         "//java/com/google/gerrit/server/cancellation",
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index 463af35..855a0bc 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -99,7 +99,7 @@
     injector.injectMembers(this);
 
     Account account =
-        Account.builder(Account.id(1), TimeUtil.nowTs())
+        Account.builder(Account.id(1), TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build();
     Account.Id ownerId = account.id();
diff --git a/javatests/com/google/gerrit/server/account/AccountCacheTest.java b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
index a2aa40b..c1eff15 100644
--- a/javatests/com/google/gerrit/server/account/AccountCacheTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.config.CachedPreferences;
-import java.sql.Timestamp;
 import java.time.Instant;
 import org.junit.Test;
 
@@ -32,8 +31,7 @@
  * of the {@code AccountCache}.
  */
 public class AccountCacheTest {
-  private static final Account ACCOUNT =
-      Account.builder(Account.id(1), Timestamp.from(Instant.EPOCH)).build();
+  private static final Account ACCOUNT = Account.builder(Account.id(1), Instant.EPOCH).build();
   private static final Cache.AccountProto ACCOUNT_PROTO =
       Cache.AccountProto.newBuilder().setId(1).setRegisteredOn(0).build();
   private static final CachedAccountDetails.Serializer SERIALIZER =
@@ -42,7 +40,7 @@
   @Test
   public void account_roundTrip() throws Exception {
     Account account =
-        Account.builder(Account.id(1), Timestamp.from(Instant.EPOCH))
+        Account.builder(Account.id(1), Instant.EPOCH)
             .setFullName("foo bar")
             .setDisplayName("foo")
             .setActive(false)
diff --git a/javatests/com/google/gerrit/server/account/AccountResolverTest.java b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
index 5e49aaf..3658834 100644
--- a/javatests/com/google/gerrit/server/account/AccountResolverTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
@@ -96,15 +96,15 @@
             new TestSearcher("foo", false, newAccount(1)),
             new TestSearcher("bar", false, newAccount(2), newAccount(3)));
 
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("foo");
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
 
-    result = search("bar", searchers, allVisible());
+    result = search("bar", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("bar");
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(2, 3));
 
-    result = search("baz", searchers, allVisible());
+    result = search("baz", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("baz");
     assertThat(result.asIdSet()).isEmpty();
   }
@@ -115,11 +115,11 @@
         ImmutableList.of(
             new TestSearcher("f.*", true), new TestSearcher("foo|bar", false, newAccount(1)));
 
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("foo");
     assertThat(result.asIdSet()).isEmpty();
 
-    result = search("bar", searchers, allVisible());
+    result = search("bar", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("bar");
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
   }
@@ -129,7 +129,7 @@
     ImmutableList<Searcher<?>> searchers =
         ImmutableList.of(new TestSearcher("foo", false, newAccount(1), newAccount(2)));
 
-    assertThat(search("foo", searchers, allVisible()).asIdSet())
+    assertThat(search("foo", searchers, AccountResolverTest::allVisiblePredicate).asIdSet())
         .containsExactlyElementsIn(ids(1, 2));
     assertThat(search("foo", searchers, only(2)).asIdSet()).containsExactlyElementsIn(ids(2));
   }
@@ -152,7 +152,7 @@
             new TestSearcher("foo", false, newInactiveAccount(1)),
             new TestSearcher("f.*", false, newInactiveAccount(2)));
 
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     // Searchers always short-circuit when finding a non-empty result list, and this one didn't
     // filter out inactive results, so the second searcher never ran.
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
@@ -168,7 +168,7 @@
     searcher2.setCallerShouldFilterOutInactiveCandidates();
     ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher1, searcher2);
 
-    Result result = search("foo", searchers, allVisible(), (a) -> true);
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate, (a) -> true);
     // Searchers always short-circuit when finding a non-empty result list,
     // and this one didn't filter out inactive results,
     // so the second searcher never ran.
@@ -185,8 +185,9 @@
     searcher2.setCallerShouldFilterOutInactiveCandidates();
     ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher1, searcher2);
 
-    Result result = search("foo", searchers, allVisible());
-    assertThat(search("foo", searchers, allVisible()).asIdSet()).containsExactlyElementsIn(ids(2));
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
+    assertThat(search("foo", searchers, AccountResolverTest::allVisiblePredicate).asIdSet())
+        .containsExactlyElementsIn(ids(2));
     // No info about inactive results exposed if there was at least one active result.
     assertThat(filteredInactiveIds(result)).isEmpty();
   }
@@ -199,7 +200,7 @@
     searcher2.setCallerShouldFilterOutInactiveCandidates();
     ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher1, searcher2);
 
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.asIdSet()).isEmpty();
     assertThat(filteredInactiveIds(result)).containsExactlyElementsIn(ids(1, 2));
   }
@@ -217,7 +218,7 @@
 
     // searcher1 matched, but filtered out all candidates because account2 is inactive. Actual
     // result came from searcher2 instead.
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1, 2));
   }
 
@@ -233,7 +234,7 @@
 
     // searcher1 matched and then filtered out all candidates because account2 is inactive, but
     // still short-circuited.
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.asIdSet()).isEmpty();
     assertThat(filteredInactiveIds(result)).containsExactlyElementsIn(ids(2));
   }
@@ -242,11 +243,10 @@
   public void asUniqueWithNoResults() throws Exception {
     String input = "foo";
     ImmutableList<Searcher<?>> searchers = ImmutableList.of();
-    Supplier<Predicate<AccountState>> visibilitySupplier = allVisible();
     UnresolvableAccountException thrown =
         assertThrows(
             UnresolvableAccountException.class,
-            () -> search(input, searchers, visibilitySupplier).asUnique());
+            () -> search(input, searchers, AccountResolverTest::allVisiblePredicate).asUnique());
     assertThat(thrown).hasMessageThat().isEqualTo("Account 'foo' not found");
   }
 
@@ -255,7 +255,11 @@
     AccountState account = newAccount(1);
     ImmutableList<Searcher<?>> searchers =
         ImmutableList.of(new TestSearcher("foo", false, account));
-    assertThat(search("foo", searchers, allVisible()).asUnique().account().id())
+    assertThat(
+            search("foo", searchers, AccountResolverTest::allVisiblePredicate)
+                .asUnique()
+                .account()
+                .id())
         .isEqualTo(account.account().id());
   }
 
@@ -266,7 +270,7 @@
     UnresolvableAccountException thrown =
         assertThrows(
             UnresolvableAccountException.class,
-            () -> search("foo", searchers, allVisible()).asUnique());
+            () -> search("foo", searchers, AccountResolverTest::allVisiblePredicate).asUnique());
     assertThat(thrown)
         .hasMessageThat()
         .isEqualTo(
@@ -339,7 +343,7 @@
       ImmutableList<Searcher<?>> searchers,
       Supplier<Predicate<AccountState>> visibilitySupplier)
       throws Exception {
-    return search(input, searchers, visibilitySupplier, activityPrediate());
+    return search(input, searchers, visibilitySupplier, AccountResolverTest::isActive);
   }
 
   private Result search(
@@ -357,13 +361,13 @@
 
   private AccountState newAccount(int id) {
     return AccountState.forAccount(
-        Account.builder(Account.id(id), TimeUtil.nowTs())
+        Account.builder(Account.id(id), TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build());
   }
 
   private AccountState newInactiveAccount(int id) {
-    Account.Builder a = Account.builder(Account.id(id), TimeUtil.nowTs());
+    Account.Builder a = Account.builder(Account.id(id), TimeUtil.now());
     a.setActive(false);
     return AccountState.forAccount(a.build());
   }
@@ -372,12 +376,17 @@
     return Arrays.stream(ids).mapToObj(Account::id).collect(toImmutableSet());
   }
 
-  private static Supplier<Predicate<AccountState>> allVisible() {
-    return () -> a -> true;
+  private static Predicate<AccountState> allVisiblePredicate() {
+    return AccountResolverTest::allVisible;
   }
 
-  private Predicate<AccountState> activityPrediate() {
-    return (AccountState accountState) -> accountState.account().isActive();
+  /** @param accountState account state for which the visibility should be checked */
+  private static boolean allVisible(AccountState accountState) {
+    return true;
+  }
+
+  private static boolean isActive(AccountState accountState) {
+    return accountState.account().isActive();
   }
 
   private static Supplier<Predicate<AccountState>> only(int... ids) {
diff --git a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
index 1381c75..1a7d1fb 100644
--- a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
+++ b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.base.Splitter;
 import com.google.gerrit.entities.Account;
 import java.util.ArrayList;
 import java.util.List;
@@ -125,18 +126,22 @@
   public void getters() throws Exception {
     AccountSshKey key = AccountSshKey.create(accountId, 1, KEY1);
     assertThat(key.sshPublicKey()).isEqualTo(KEY1);
-    assertThat(key.algorithm()).isEqualTo(KEY1.split(" ")[0]);
-    assertThat(key.encodedKey()).isEqualTo(KEY1.split(" ")[1]);
-    assertThat(key.comment()).isEqualTo(KEY1.split(" ")[2]);
+
+    List<String> keyParts = Splitter.on(" ").splitToList(KEY1);
+    assertThat(key.algorithm()).isEqualTo(keyParts.get(0));
+    assertThat(key.encodedKey()).isEqualTo(keyParts.get(1));
+    assertThat(key.comment()).isEqualTo(keyParts.get(2));
   }
 
   @Test
   public void keyWithNewLines() throws Exception {
     AccountSshKey key = AccountSshKey.create(accountId, 1, KEY1_WITH_NEWLINES);
     assertThat(key.sshPublicKey()).isEqualTo(KEY1);
-    assertThat(key.algorithm()).isEqualTo(KEY1.split(" ")[0]);
-    assertThat(key.encodedKey()).isEqualTo(KEY1.split(" ")[1]);
-    assertThat(key.comment()).isEqualTo(KEY1.split(" ")[2]);
+
+    List<String> keyParts = Splitter.on(" ").splitToList(KEY1);
+    assertThat(key.algorithm()).isEqualTo(keyParts.get(0));
+    assertThat(key.encodedKey()).isEqualTo(keyParts.get(1));
+    assertThat(key.comment()).isEqualTo(keyParts.get(2));
   }
 
   private static String toWindowsLineEndings(String s) {
diff --git a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
index 1b5e951..d270138 100644
--- a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
@@ -17,8 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 
+import com.google.common.base.CharMatcher;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.collect.ImmutableList;
@@ -33,9 +34,11 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.inject.util.Providers;
 import java.io.IOException;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.function.Consumer;
+import java.util.stream.Stream;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -64,23 +67,14 @@
 
   @Before
   public void setUp() throws Exception {
-    externalIdFactory =
-        new ExternalIdFactory(
-            new ExternalIdKeyFactory(
-                new ExternalIdKeyFactory.Config() {
-                  @Override
-                  public boolean isUserNameCaseInsensitive() {
-                    return false;
-                  }
-                }),
-            authConfig);
+    externalIdFactory = new ExternalIdFactory(new ExternalIdKeyFactory(() -> false), authConfig);
     externalIdCache = CacheBuilder.newBuilder().build();
     repoManager.createRepository(ALL_USERS).close();
     externalIdReader =
         new ExternalIdReader(
             repoManager, ALL_USERS, new DisabledMetricMaker(), externalIdFactory, authConfig);
     externalIdReaderSpy = Mockito.spy(externalIdReader);
-    loader = createLoader(true);
+    loader = createLoader();
   }
 
   @Test
@@ -97,7 +91,38 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
+  }
+
+  @Test
+  public void loadCacheSuccessfullyWhenInInconsistentState() throws Exception {
+    int key = 1;
+    int account = 1;
+    ExternalId externalId = externalId(key, account);
+    ExternalId.Key externalIdKey = externalId.key();
+
+    Repository repo = repoManager.openRepository(ALL_USERS);
+    ObjectId newState = insertExternalId(key, account);
+    TreeWalk tw = new TreeWalk(repo);
+    tw.reset(new RevWalk(repo).parseCommit(newState).getTree());
+    tw.next();
+
+    HashMap<ObjectId, ObjectId> additions = new HashMap<>();
+    additions.put(fileNameToObjectId(tw.getPathString()), tw.getObjectId(0));
+    AllExternalIds oldExternalIds =
+        AllExternalIds.create(Stream.<ExternalId>builder().add(externalId).build());
+
+    AllExternalIds allExternalIds =
+        loader.buildAllExternalIds(repo, oldExternalIds, additions, new HashSet<>());
+
+    assertThat(allExternalIds).isNotNull();
+    assertThat(allExternalIds.byKey().containsKey(externalIdKey)).isTrue();
+    assertThat(allExternalIds.byKey().get(externalIdKey)).isEqualTo(externalId);
+  }
+
+  private static ObjectId fileNameToObjectId(String path) {
+    return ObjectId.fromString(CharMatcher.is('/').removeFrom(path));
   }
 
   @Test
@@ -109,7 +134,8 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -122,16 +148,6 @@
   }
 
   @Test
-  public void partialReloadingDisabledAlwaysTriggersFullReload() throws Exception {
-    loader = createLoader(false);
-    insertExternalId(1, 1);
-    ObjectId head = insertExternalId(2, 2);
-
-    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verify(externalIdReaderSpy, times(1)).all(head);
-  }
-
-  @Test
   public void fallsBackToFullReloadOnManyUpdatesOnBranch() throws Exception {
     insertExternalId(1, 1);
     ObjectId head = null;
@@ -159,7 +175,8 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -174,7 +191,8 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -191,26 +209,28 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
   public void handlesTreePrefixesInDifferentialReload() throws Exception {
     // Create more than 256 notes (NoteMap's current sharding limit) and check that we really have
     // created a situation where NoteNames are sharded.
-    ObjectId oldState = inserExternalIds(257);
+    ObjectId oldState = insertExternalIds(257);
     assertAllFilesHaveSlashesInPath();
     ObjectId head = insertExternalId(500, 500);
     externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
   public void handlesReshard() throws Exception {
     // Create 256 notes (NoteMap's current sharding limit) and check that we are not yet sharding
-    ObjectId oldState = inserExternalIds(256);
+    ObjectId oldState = insertExternalIds(256);
     assertNoFilesHaveSlashesInPath();
     // Create one more external ID and then have the Loader compute the new state
     ObjectId head = insertExternalId(500, 500);
@@ -218,19 +238,18 @@
     externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
-  private ExternalIdCacheLoader createLoader(boolean allowPartial) {
-    Config cfg = new Config();
-    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", allowPartial);
+  private ExternalIdCacheLoader createLoader() {
     return new ExternalIdCacheLoader(
         repoManager,
         ALL_USERS,
         externalIdReaderSpy,
-        Providers.of(externalIdCache),
+        externalIdCache,
         new DisabledMetricMaker(),
-        cfg,
+        new Config(),
         externalIdFactory);
   }
 
@@ -238,7 +257,7 @@
     return AllExternalIds.create(externalIdReader.all(revision).stream());
   }
 
-  private ObjectId inserExternalIds(int numberOfIdsToInsert) throws Exception {
+  private ObjectId insertExternalIds(int numberOfIdsToInsert) throws Exception {
     ObjectId oldState = null;
     // Create more than 256 notes (NoteMap's current sharding limit) and check that we really have
     // created a situation where NoteNames are sharded.
@@ -281,8 +300,7 @@
   private ObjectId performExternalIdUpdate(Consumer<ExternalIdNotes> update) throws Exception {
     try (Repository repo = repoManager.openRepository(ALL_USERS)) {
       PersonIdent updater = new PersonIdent("Foo bar", "foo@bar.com");
-      ExternalIdNotes extIdNotes =
-          ExternalIdNotes.loadNoCacheUpdate(ALL_USERS, repo, externalIdFactory, false);
+      ExternalIdNotes extIdNotes = ExternalIdNotes.load(ALL_USERS, repo, externalIdFactory, false);
       update.accept(extIdNotes);
       try (MetaDataUpdate metaDataUpdate =
           new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, repo)) {
diff --git a/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
index 2264612..fb9a375 100644
--- a/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
+++ b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
@@ -57,15 +57,13 @@
     }
 
     @Override
-    public <K, V> com.google.common.cache.Cache<K, V> build(
-        PersistentCacheDef<K, V> def, CacheBackend backend) {
-      return memoryCacheFactory.build(def, backend);
+    public <K, V> com.google.common.cache.Cache<K, V> build(PersistentCacheDef<K, V> def) {
+      return memoryCacheFactory.build(def);
     }
 
     @Override
-    public <K, V> LoadingCache<K, V> build(
-        PersistentCacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend) {
-      return memoryCacheFactory.build(def, loader, backend);
+    public <K, V> LoadingCache<K, V> build(PersistentCacheDef<K, V> def, CacheLoader<K, V> loader) {
+      return memoryCacheFactory.build(def, loader);
     }
 
     @Override
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index b83365f..8c4eb08 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -45,7 +45,7 @@
 import org.junit.Test;
 
 public class H2CacheTest {
-  private static final TypeLiteral<String> KEY_TYPE = new TypeLiteral<String>() {};
+  private static final TypeLiteral<String> KEY_TYPE = new TypeLiteral<>() {};
   private static final int DEFAULT_VERSION = 1234;
   private static int dbCnt;
 
diff --git a/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java b/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
index 7beb2ce..0771afa 100644
--- a/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
+++ b/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
@@ -23,7 +23,6 @@
 import com.google.common.cache.Weigher;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.metrics.DisabledMetricMaker;
-import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.CacheDef;
 import com.google.gerrit.server.cache.ForwardingRemovalListener;
 import com.google.gerrit.server.git.WorkQueue;
@@ -100,7 +99,7 @@
   @Test
   public void shouldNotBlockEvictionsWhenCacheIsDisabledByDefault() throws Exception {
     LoadingCache<Integer, Integer> disabledCache =
-        memoryCacheFactory.build(newCacheDef(0), newCacheLoader(identity()), CacheBackend.CAFFEINE);
+        memoryCacheFactory.build(newCacheDef(0), newCacheLoader(identity()));
 
     assertCacheEvictionIsNotBlocking(disabledCache);
   }
@@ -109,7 +108,7 @@
   public void shouldNotBlockEvictionsWhenCacheIsDisabledByConfiguration() throws Exception {
     memoryCacheConfig.setInt("cache", TEST_CACHE, "memoryLimit", 0);
     LoadingCache<Integer, Integer> disabledCache =
-        memoryCacheFactory.build(newCacheDef(1), newCacheLoader(identity()), CacheBackend.CAFFEINE);
+        memoryCacheFactory.build(newCacheDef(1), newCacheLoader(identity()));
 
     assertCacheEvictionIsNotBlocking(disabledCache);
   }
@@ -117,7 +116,7 @@
   @Test
   public void shouldBlockEvictionsWhenCacheIsEnabled() throws Exception {
     LoadingCache<Integer, Integer> cache =
-        memoryCacheFactory.build(newCacheDef(1), newCacheLoader(identity()), CacheBackend.CAFFEINE);
+        memoryCacheFactory.build(newCacheDef(1), newCacheLoader(identity()));
 
     ScheduledFuture<Integer> cacheValue =
         executor.schedule(() -> cache.getUnchecked(TEST_CACHE_KEY), 0, TimeUnit.SECONDS);
@@ -143,7 +142,7 @@
   private void shouldRunEvictionListenerInThreadPool(
       DefaultMemoryCacheFactory cacheFactory, String threadPoolPrefix) throws Exception {
     LoadingCache<Integer, Integer> cache =
-        cacheFactory.build(newCacheDef(1), newCacheLoader(identity()), CacheBackend.CAFFEINE);
+        cacheFactory.build(newCacheDef(1), newCacheLoader(identity()));
 
     cache.put(TEST_CACHE_KEY, TEST_CACHE_VALUE);
 
@@ -159,8 +158,7 @@
   @Test
   public void shouldRunEvictionListenerWithDirectExecutor() throws Exception {
     LoadingCache<Integer, Integer> cache =
-        memoryCacheFactoryDirectExecutor.build(
-            newCacheDef(1), newCacheLoader(identity()), CacheBackend.CAFFEINE);
+        memoryCacheFactoryDirectExecutor.build(newCacheDef(1), newCacheLoader(identity()));
 
     cache.put(TEST_CACHE_KEY, TEST_CACHE_VALUE);
     cache.invalidate(TEST_CACHE_KEY);
@@ -171,7 +169,7 @@
   @Test
   public void shouldLoadAllKeysWithDisabledCache() throws Exception {
     LoadingCache<Integer, Integer> disabledCache =
-        memoryCacheFactory.build(newCacheDef(0), newCacheLoader(identity()), CacheBackend.CAFFEINE);
+        memoryCacheFactory.build(newCacheDef(0), newCacheLoader(identity()));
 
     List<Integer> keys = Arrays.asList(1, 2);
     ImmutableMap<Integer, Integer> entries = disabledCache.getAll(keys);
@@ -196,7 +194,7 @@
   }
 
   private CacheLoader<Integer, Integer> newCacheLoader(Function<Integer, Integer> loadFunc) {
-    return new CacheLoader<Integer, Integer>() {
+    return new CacheLoader<>() {
 
       @Override
       public Integer load(Integer n) throws Exception {
@@ -206,6 +204,7 @@
           v = loadFunc.apply(n);
           cacheGetCompleted.await(TEST_TIMEOUT_SEC, TimeUnit.SECONDS);
         } catch (TimeoutException | BrokenBarrierException e) {
+          // Just continue
         }
         return v;
       }
@@ -261,7 +260,7 @@
   }
 
   private CacheDef<Integer, Integer> newCacheDef(long maximumWeight) {
-    return new CacheDef<Integer, Integer>() {
+    return new CacheDef<>() {
 
       @Override
       public String name() {
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/BUILD b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
index 8df9292..d68a5c1 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -16,5 +16,6 @@
         "//lib/truth:truth-proto-extension",
         "//proto:cache_java_proto",
         "//proto/testing:test_java_proto",
+        "@gson//jar",
     ],
 )
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/InternalGroupSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java
index 8d301e4..78947a2 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java
@@ -34,7 +34,7 @@
           .setOwnerGroupUUID(AccountGroup.uuid("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
           .setVisibleToAll(false)
           .setGroupUUID(AccountGroup.uuid("deadbeefdeadbeefdeadbeefdeadbeef12345678"))
-          .setCreatedOn(TimeUtil.nowTs())
+          .setCreatedOn(TimeUtil.now())
           .setMembers(ImmutableSet.of(Account.id(123), Account.id(321)))
           .setSubgroups(
               ImmutableSet.of(
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
index 614dcf0..b759fec 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
+import java.util.Optional;
 import org.junit.Test;
 
 public class LabelTypeSerializerTest {
@@ -30,6 +31,7 @@
               ImmutableList.of(
                   LabelValue.create((short) 0, "no vote"),
                   LabelValue.create((short) 1, "approved")))
+          .setDescription(Optional.of("description"))
           .setCanOverride(!LabelType.DEF_CAN_OVERRIDE)
           .setAllowPostSubmit(!LabelType.DEF_ALLOW_POST_SUBMIT)
           .setIgnoreSelfApproval(!LabelType.DEF_IGNORE_SELF_APPROVAL)
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
new file mode 100644
index 0000000..4705c55
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java
@@ -0,0 +1,53 @@
+// 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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.SubmitRequirementExpressionResultSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.SubmitRequirementExpressionResultSerializer.serialize;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import java.util.Optional;
+import org.junit.Test;
+
+public class SubmitRequirementExpressionResultSerializerTest {
+  private static final SubmitRequirementExpressionResult r1 =
+      SubmitRequirementExpressionResult.create(
+          SubmitRequirementExpression.create("label:Code-Review=+2"),
+          Status.PASS,
+          ImmutableList.of("Label:Code-Review=+2"),
+          ImmutableList.of());
+
+  private static final SubmitRequirementExpressionResult r2 =
+      SubmitRequirementExpressionResult.create(
+          SubmitRequirementExpression.create("label:Code-Review=+2"),
+          Status.ERROR,
+          ImmutableList.of(),
+          ImmutableList.of(),
+          Optional.of("Failed to parse the code review label"));
+
+  @Test
+  public void roundTrip_withoutError() throws Exception {
+    assertThat(deserialize(serialize(r1))).isEqualTo(r1);
+  }
+
+  @Test
+  public void roundTrip_withErrorMessage() throws Exception {
+    assertThat(deserialize(serialize(r2))).isEqualTo(r2);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
new file mode 100644
index 0000000..7b8db25
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
@@ -0,0 +1,303 @@
+// 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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.notedb.ChangeNoteJson;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class SubmitRequirementJsonSerializerTest {
+  private static final SubmitRequirementExpression srReqExp =
+      SubmitRequirementExpression.create("label:Code-Review=+2");
+
+  private static final String srReqExpSerial = "{\"expressionString\":\"label:Code-Review=+2\"}";
+
+  private static final SubmitRequirement sr =
+      SubmitRequirement.builder()
+          .setName("CR")
+          .setDescription(Optional.of("CR description"))
+          .setApplicabilityExpression(SubmitRequirementExpression.of("branch:refs/heads/master"))
+          .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
+          .setAllowOverrideInChildProjects(true)
+          .build();
+
+  private static final String srSerial =
+      "{\"name\":\"CR\","
+          + "\"description\":{\"value\":\"CR description\"},"
+          + "\"applicabilityExpression\":{\"value\":"
+          + "{\"expressionString\":\"branch:refs/heads/master\"}},"
+          + "\"submittabilityExpression\":{"
+          + "\"expressionString\":\"label:Code-Review=+2\"},"
+          + "\"overrideExpression\":{\"value\":null},"
+          + "\"allowOverrideInChildProjects\":true}";
+
+  private static final SubmitRequirementExpressionResult srExpResult =
+      SubmitRequirementExpressionResult.create(
+          SubmitRequirementExpression.create("label:Code-Review=MAX AND -label:Code-Review=MIN"),
+          Status.FAIL,
+          /* passingAtoms= */ ImmutableList.of("label:Code-Review=MAX"),
+          /* failingAtoms= */ ImmutableList.of("label:Code-Review=MIN"));
+
+  private static final String srExpResultSerial =
+      "{\"expression\":{\"expressionString\":"
+          + "\"label:Code-Review=MAX AND -label:Code-Review=MIN\"},"
+          + "\"status\":\"FAIL\","
+          + "\"errorMessage\":{\"value\":null},"
+          + "\"passingAtoms\":[\"label:Code-Review=MAX\"],"
+          + "\"failingAtoms\":[\"label:Code-Review=MIN\"]}";
+
+  private static final SubmitRequirementResult srReqResult =
+      SubmitRequirementResult.builder()
+          .submitRequirement(
+              SubmitRequirement.builder()
+                  .setName("CR")
+                  .setDescription(Optional.of("CR Description"))
+                  .setApplicabilityExpression(
+                      SubmitRequirementExpression.of("branch:refs/heads/master"))
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create("label:\"Code-Review=+2\""))
+                  .setOverrideExpression(SubmitRequirementExpression.of("label:Override=+1"))
+                  .setAllowOverrideInChildProjects(false)
+                  .build())
+          .patchSetCommitId(ObjectId.fromString("4663ab9e9eb49a214e68e60f0fe5d0b6f44f763e"))
+          .applicabilityExpressionResult(
+              Optional.of(
+                  SubmitRequirementExpressionResult.create(
+                      SubmitRequirementExpression.create("branch:refs/heads/master"),
+                      Status.PASS,
+                      ImmutableList.of("refs/heads/master"),
+                      ImmutableList.of())))
+          .submittabilityExpressionResult(
+              SubmitRequirementExpressionResult.create(
+                  SubmitRequirementExpression.create("label:\"Code-Review=+2\""),
+                  Status.PASS,
+                  /* passingAtoms= */ ImmutableList.of("label:\"Code-Review=+2\""),
+                  /* failingAtoms= */ ImmutableList.of()))
+          .overrideExpressionResult(
+              Optional.of(
+                  SubmitRequirementExpressionResult.create(
+                      SubmitRequirementExpression.create("label:Override=+1"),
+                      Status.PASS,
+                      /* passingAtoms= */ ImmutableList.of(),
+                      /* failingAtoms= */ ImmutableList.of("label:Override=+1"))))
+          .legacy(Optional.of(true))
+          .build();
+
+  private static final String srReqResultSerial =
+      "{\"submitRequirement\":{\"name\":\"CR\",\"description\":{\"value\":\"CR Description\"},"
+          + "\"applicabilityExpression\":{\"value\":{"
+          + "\"expressionString\":\"branch:refs/heads/master\"}},"
+          + "\"submittabilityExpression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+          + "\"overrideExpression\":{\"value\":{\"expressionString\":\"label:Override=+1\"}},"
+          + "\"allowOverrideInChildProjects\":false},"
+          + "\"applicabilityExpressionResult\":{\"value\":{"
+          + "\"expression\":{\"expressionString\":\"branch:refs/heads/master\"},"
+          + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+          + "\"passingAtoms\":[\"refs/heads/master\"],"
+          + "\"failingAtoms\":[]}},"
+          + "\"submittabilityExpressionResult\":{\"value\":{"
+          + "\"expression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+          + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+          + "\"passingAtoms\":[\"label:\\\"Code-Review=+2\\\"\"],"
+          + "\"failingAtoms\":[]}},"
+          + "\"overrideExpressionResult\":{\"value\":{"
+          + "\"expression\":{\"expressionString\":\"label:Override=+1\"},"
+          + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+          + "\"passingAtoms\":[],"
+          + "\"failingAtoms\":[\"label:Override=+1\"]}},"
+          + "\"patchSetCommitId\":\"4663ab9e9eb49a214e68e60f0fe5d0b6f44f763e\","
+          + "\"legacy\":{\"value\":true},"
+          + "\"forced\":{\"value\":null},"
+          + "\"hidden\":{\"value\":null}}";
+
+  private static final Gson gson = new ChangeNoteJson().getGson();
+
+  @Test
+  public void submitRequirementExpression_serialize() {
+    assertThat(SubmitRequirementExpression.typeAdapter(gson).toJson(srReqExp))
+        .isEqualTo(srReqExpSerial);
+  }
+
+  @Test
+  public void submitRequirementExpression_deserialize() throws Exception {
+    assertThat(SubmitRequirementExpression.typeAdapter(gson).fromJson(srReqExpSerial))
+        .isEqualTo(srReqExp);
+  }
+
+  @Test
+  public void submitRequirementExpression_roundTrip() throws Exception {
+    SubmitRequirementExpression exp = SubmitRequirementExpression.create("label:Code-Review=+2");
+    TypeAdapter<SubmitRequirementExpression> adapter =
+        SubmitRequirementExpression.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(exp))).isEqualTo(exp);
+  }
+
+  @Test
+  public void submitRequirement_serialize() throws Exception {
+    assertThat(SubmitRequirement.typeAdapter(gson).toJson(sr)).isEqualTo(srSerial);
+  }
+
+  @Test
+  public void submitRequirement_deserialize() throws Exception {
+    assertThat(SubmitRequirement.typeAdapter(gson).fromJson(srSerial)).isEqualTo(sr);
+  }
+
+  @Test
+  public void submitRequirement_roundTrip() throws Exception {
+    TypeAdapter<SubmitRequirement> adapter = SubmitRequirement.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(sr))).isEqualTo(sr);
+  }
+
+  @Test
+  public void submitRequirementExpressionResult_serialize() throws Exception {
+    assertThat(SubmitRequirementExpressionResult.typeAdapter(gson).toJson(srExpResult))
+        .isEqualTo(srExpResultSerial);
+  }
+
+  @Test
+  public void submitRequirementExpressionResult_deserialize() throws Exception {
+    assertThat(SubmitRequirementExpressionResult.typeAdapter(gson).fromJson(srExpResultSerial))
+        .isEqualTo(srExpResult);
+  }
+
+  @Test
+  public void submitRequirementExpressionResult_roundtrip() throws Exception {
+    TypeAdapter<SubmitRequirementExpressionResult> adapter =
+        SubmitRequirementExpressionResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srExpResult))).isEqualTo(srExpResult);
+  }
+
+  @Test
+  public void submitRequirementResult_serialize() throws Exception {
+    assertThat(SubmitRequirementResult.typeAdapter(gson).toJson(srReqResult))
+        .isEqualTo(srReqResultSerial);
+  }
+
+  @Test
+  public void submitRequirementResult_deserialize_optionalSubmittabilityExpressionResultField()
+      throws Exception {
+    assertThat(SubmitRequirementResult.typeAdapter(gson).fromJson(srReqResultSerial))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_deserialize_nonOptionalSubmittabilityExpressionResultField()
+      throws Exception {
+    String oldFormatSrReqResultSerial =
+        srReqResultSerial.replace(
+            "\"submittabilityExpressionResult\":{\"value\":{"
+                + "\"expression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+                + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+                + "\"passingAtoms\":[\"label:\\\"Code-Review=+2\\\"\"],"
+                + "\"failingAtoms\":[]}},",
+            "\"submittabilityExpressionResult\":{"
+                + "\"expression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+                + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+                + "\"passingAtoms\":[\"label:\\\"Code-Review=+2\\\"\"],"
+                + "\"failingAtoms\":[]},");
+    assertThat(SubmitRequirementResult.typeAdapter(gson).fromJson(oldFormatSrReqResultSerial))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_roundTrip() throws Exception {
+    TypeAdapter<SubmitRequirementResult> adapter = SubmitRequirementResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srReqResult))).isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_withHidden_roundTrip() throws Exception {
+    SubmitRequirementResult srResultWithHidden =
+        srReqResult.toBuilder().hidden(Optional.of(true)).build();
+    TypeAdapter<SubmitRequirementResult> adapter = SubmitRequirementResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srResultWithHidden))).isEqualTo(srResultWithHidden);
+  }
+
+  @Test
+  public void submitRequirementResult_deserializeNoHidden() throws Exception {
+    String srResultSerialMandatoryLegacyFieldFormat =
+        srReqResultSerial.replace(",hidden\":{\"value\":null}", "");
+    assertThat(
+            SubmitRequirementResult.typeAdapter(gson)
+                .fromJson(srResultSerialMandatoryLegacyFieldFormat))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_deserializeNonExistentField() throws Exception {
+    // Tests that unrecognized fields are skipped on deserialization (e.g. when the new fields are
+    // introduced, the old binary can parse the new-format Json)
+    String srResultSerialMandatoryLegacyFieldFormat =
+        srReqResultSerial.replace(
+            "\"hidden\":{\"value\":null}}", "\"non-existent\":{\"value\":null}}");
+    assertThat(
+            SubmitRequirementResult.typeAdapter(gson)
+                .fromJson(srResultSerialMandatoryLegacyFieldFormat))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_emptySubmittabilityExpressionResultField_roundTrip()
+      throws Exception {
+    SubmitRequirementResult srResult =
+        srReqResult
+            .toBuilder()
+            .submittabilityExpressionResult(Optional.empty())
+            .applicabilityExpressionResult(Optional.empty())
+            .overrideExpressionResult(Optional.empty())
+            .build();
+    TypeAdapter<SubmitRequirementResult> adapter = SubmitRequirementResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srResult))).isEqualTo(srResult);
+  }
+
+  @Test
+  public void deserializeSubmitRequirementResult_withJGitPatchsetIdFormat() throws Exception {
+    String srResultSerialJgitFormat =
+        srReqResultSerial.replace(
+            "\"4663ab9e9eb49a214e68e60f0fe5d0b6f44f763e\"",
+            "{\"w1\":1180937118,\"w2\":-1632331231,\"w3\":1315497487,"
+                + "\"w4\":266719414,\"w5\":-196118978}");
+    assertThat(SubmitRequirementResult.typeAdapter(gson).fromJson(srResultSerialJgitFormat))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_deserializeNonOptionalLegacyField() throws Exception {
+    String srResultSerialMandatoryLegacyFieldFormat =
+        srReqResultSerial.replace("\"legacy\":{\"value\":true}", "\"legacy\":true");
+    assertThat(
+            SubmitRequirementResult.typeAdapter(gson)
+                .fromJson(srResultSerialMandatoryLegacyFieldFormat))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_emptyLegacyField_roundTrip() throws Exception {
+    SubmitRequirementResult srResult = srReqResult.toBuilder().legacy(Optional.empty()).build();
+    TypeAdapter<SubmitRequirementResult> adapter = SubmitRequirementResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srResult))).isEqualTo(srResult);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
index a1dee1a..6993dfe 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
@@ -26,7 +26,7 @@
 public class SubmitRequirementSerializerTest {
   private static final SubmitRequirement submitReq =
       SubmitRequirement.builder()
-          .setName("code-review")
+          .setName("Code-Review")
           .setDescription(Optional.of("require code review +2"))
           .setApplicabilityExpression(SubmitRequirementExpression.of("branch(refs/heads/master)"))
           .setSubmittabilityExpression(SubmitRequirementExpression.create("label(code-review, 2+)"))
diff --git a/javatests/com/google/gerrit/server/change/CommentThreadTest.java b/javatests/com/google/gerrit/server/change/CommentThreadTest.java
index 08485a4..d0b6c14 100644
--- a/javatests/com/google/gerrit/server/change/CommentThreadTest.java
+++ b/javatests/com/google/gerrit/server/change/CommentThreadTest.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class CommentThreadTest {
@@ -60,7 +60,7 @@
     return new HumanComment(
         new Comment.Key(commentUuid, "myFile", 1),
         Account.id(100),
-        new Timestamp(1234),
+        Instant.ofEpochMilli(1234),
         (short) 1,
         "Comment text",
         "serverId",
diff --git a/javatests/com/google/gerrit/server/change/CommentThreadsTest.java b/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
index 0c61906..83e8370 100644
--- a/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
+++ b/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class CommentThreadsTest {
@@ -126,13 +126,15 @@
 
   @Test
   public void branchedThreadsAreFlattenedAccordingToDate() {
-    HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
-    HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
-    HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(3));
+    HumanComment root = writtenOn(createComment("root"), Instant.ofEpochMilli(1));
+    HumanComment sibling1 =
+        writtenOn(asReply(createComment("sibling1"), "root"), Instant.ofEpochMilli(2));
+    HumanComment sibling2 =
+        writtenOn(asReply(createComment("sibling2"), "root"), Instant.ofEpochMilli(3));
     HumanComment sibling1Child =
-        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), new Timestamp(4));
+        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), Instant.ofEpochMilli(4));
     HumanComment sibling2Child =
-        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), new Timestamp(5));
+        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), Instant.ofEpochMilli(5));
 
     ImmutableList<HumanComment> comments =
         ImmutableList.of(sibling2, sibling2Child, sibling1, sibling1Child, root);
@@ -146,9 +148,11 @@
 
   @Test
   public void threadsConsiderParentRelationshipStrongerThanDate() {
-    HumanComment root = writtenOn(createComment("root"), new Timestamp(3));
-    HumanComment child1 = writtenOn(asReply(createComment("child1"), "root"), new Timestamp(2));
-    HumanComment child2 = writtenOn(asReply(createComment("child2"), "child1"), new Timestamp(1));
+    HumanComment root = writtenOn(createComment("root"), Instant.ofEpochMilli(3));
+    HumanComment child1 =
+        writtenOn(asReply(createComment("child1"), "root"), Instant.ofEpochMilli(2));
+    HumanComment child2 =
+        writtenOn(asReply(createComment("child2"), "child1"), Instant.ofEpochMilli(1));
 
     ImmutableList<HumanComment> comments = ImmutableList.of(child2, child1, root);
     ImmutableSet<CommentThread<HumanComment>> commentThreads =
@@ -161,9 +165,11 @@
 
   @Test
   public void threadsFallBackToUuidOrderIfParentAndDateAreTheSame() {
-    HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
-    HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
-    HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(2));
+    HumanComment root = writtenOn(createComment("root"), Instant.ofEpochMilli(1));
+    HumanComment sibling1 =
+        writtenOn(asReply(createComment("sibling1"), "root"), Instant.ofEpochMilli(2));
+    HumanComment sibling2 =
+        writtenOn(asReply(createComment("sibling2"), "root"), Instant.ofEpochMilli(2));
 
     ImmutableList<HumanComment> comments = ImmutableList.of(sibling2, sibling1, root);
     ImmutableSet<CommentThread<HumanComment>> commentThreads =
@@ -224,13 +230,15 @@
 
   @Test
   public void completeThreadWithBranchesCanBeRequestedByReplyToIntermediateComment() {
-    HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
-    HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
-    HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(3));
+    HumanComment root = writtenOn(createComment("root"), Instant.ofEpochMilli(1));
+    HumanComment sibling1 =
+        writtenOn(asReply(createComment("sibling1"), "root"), Instant.ofEpochMilli(2));
+    HumanComment sibling2 =
+        writtenOn(asReply(createComment("sibling2"), "root"), Instant.ofEpochMilli(3));
     HumanComment sibling1Child =
-        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), new Timestamp(4));
+        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), Instant.ofEpochMilli(4));
     HumanComment sibling2Child =
-        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), new Timestamp(5));
+        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), Instant.ofEpochMilli(5));
 
     HumanComment reply = asReply(createComment("sibling1"), "root");
 
@@ -262,7 +270,7 @@
     return new HumanComment(
         new Comment.Key(commentUuid, "myFile", 1),
         Account.id(100),
-        new Timestamp(1234),
+        Instant.ofEpochMilli(1234),
         (short) 1,
         "Comment text",
         "serverId",
@@ -274,8 +282,8 @@
     return comment;
   }
 
-  private static HumanComment writtenOn(HumanComment comment, Timestamp writtenOn) {
-    comment.writtenOn = writtenOn;
+  private static HumanComment writtenOn(HumanComment comment, Instant writtenOn) {
+    comment.setWrittenOn(writtenOn);
     return comment;
   }
 
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index 8f341aa..b048163 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -100,7 +100,7 @@
   private void configureProject() throws Exception {
     ProjectConfig pc = loadAllProjects();
 
-    for (AccessSection sec : ImmutableList.copyOf(pc.getAccessSections())) {
+    for (AccessSection sec : pc.getAccessSections()) {
       pc.upsertAccessSection(
           sec.getName(),
           updatedSection -> {
@@ -213,7 +213,7 @@
     return PatchSetApproval.builder()
         .key(PatchSetApproval.key(change.currentPatchSetId(), accountId, LabelId.create(label)))
         .value(value)
-        .granted(TimeUtil.nowTs())
+        .granted(TimeUtil.now())
         .build();
   }
 
diff --git a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
index 97f6e4e..00b92b4 100644
--- a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
+++ b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -27,8 +27,8 @@
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gson.Gson;
-import java.sql.Timestamp;
 import org.junit.Test;
 
 public class EventDeserializerTest {
@@ -278,7 +278,7 @@
             Change.id(1000),
             Account.id(1000),
             BranchNameKey.create(Project.nameKey("myproject"), "mybranch"),
-            new Timestamp(System.currentTimeMillis()));
+            TimeUtil.now());
     return change;
   }
 
@@ -335,7 +335,7 @@
     a.commitMessage = "This is a test commit message";
     a.url = "http://somewhere.com";
     a.status = change.getStatus();
-    a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.createdOn = change.getCreatedOn().getEpochSecond();
     a.wip = change.isWorkInProgress() ? true : null;
     a.isPrivate = change.isPrivate() ? true : null;
     return Suppliers.ofInstance(a);
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
index 8e4f436..3c9a355 100644
--- a/javatests/com/google/gerrit/server/events/EventJsonTest.java
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -607,7 +607,7 @@
         Change.id(CHANGE_NUM),
         Account.id(9999),
         BranchNameKey.create(Project.nameKey(PROJECT), BRANCH),
-        TimeUtil.nowTs());
+        TimeUtil.now());
   }
 
   private <T> Supplier<T> createSupplier(T value) {
@@ -625,7 +625,7 @@
     a.commitMessage = COMMIT_MESSAGE;
     a.url = URL;
     a.status = change.getStatus();
-    a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.createdOn = change.getCreatedOn().getEpochSecond();
     a.wip = change.isWorkInProgress() ? true : null;
     a.isPrivate = change.isPrivate() ? true : null;
     return Suppliers.ofInstance(a);
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..9143dd5f
--- /dev/null
+++ b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
@@ -0,0 +1,147 @@
+// 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 static com.google.common.truth.Truth.assertThat;
+
+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.extensions.events.GitReferenceUpdated.GitBatchRefUpdateEvent;
+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.ArgumentCaptor;
+import org.mockito.Captor;
+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;
+  @Captor ArgumentCaptor<GitBatchRefUpdateEvent> eventCaptor;
+
+  @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 shouldPreserveRefsOrderInTheBatchRefsUpdatedEvent() {
+    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(eventCaptor.capture());
+    GitBatchRefUpdateEvent batchEvent = eventCaptor.getValue();
+    assertThat(batchEvent.getRefNames()).isInOrder();
+  }
+
+  @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/git/DeleteZombieCommentsRefsTest.java b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
index 3a8d7e4..5810df7 100644
--- a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
+++ b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
@@ -207,7 +207,7 @@
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent committer =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(committer);
diff --git a/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java
index 6b8177e..82cc049 100644
--- a/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java
@@ -16,7 +16,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.Project.NameKey;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
@@ -53,7 +53,7 @@
     }
 
     @Override
-    public SortedSet<NameKey> list() {
+    public NavigableSet<NameKey> list() {
       throw new UnsupportedOperationException("Not implemented");
     }
   }
diff --git a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
index 4902830..6c771d7 100644
--- a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
@@ -26,7 +26,7 @@
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -77,7 +77,7 @@
     assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
         .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
 
-    SortedSet<Project.NameKey> repoList = repoManager.list();
+    NavigableSet<Project.NameKey> repoList = repoManager.list();
     assertThat(repoList).hasSize(1);
     assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
         .isEqualTo(new Project.NameKey[] {someProjectKey});
@@ -105,7 +105,7 @@
     assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
         .isEqualTo(alternateBasePath.toString());
 
-    SortedSet<Project.NameKey> repoList = repoManager.list();
+    NavigableSet<Project.NameKey> repoList = repoManager.list();
     assertThat(repoList).hasSize(1);
     assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
         .isEqualTo(new Project.NameKey[] {someProjectKey});
@@ -131,7 +131,7 @@
     createRepository(repoManager.getBasePath(basePathProject), misplacedProject2);
     createRepository(alternateBasePath, misplacedProject1);
 
-    SortedSet<Project.NameKey> repoList = repoManager.list();
+    NavigableSet<Project.NameKey> repoList = repoManager.list();
     assertThat(repoList).hasSize(2);
     assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
         .isEqualTo(new Project.NameKey[] {altPathProject, basePathProject});
diff --git a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
index 690a5cc..91d5596 100644
--- a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
+++ b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
@@ -29,9 +29,9 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.Arrays;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -55,7 +55,7 @@
   // instead coming up with a replacement interface for
   // VersionedMetaData/BatchMetaDataUpdate/MetaDataUpdate that is easier to use correctly.
 
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
   private static final String DEFAULT_REF = "refs/meta/config";
 
   private Project.NameKey project;
@@ -222,7 +222,8 @@
 
   private CommitBuilder newCommitBuilder() {
     CommitBuilder cb = new CommitBuilder();
-    PersonIdent author = new PersonIdent("J. Author", "author@example.com", TimeUtil.nowTs(), TZ);
+    PersonIdent author =
+        new PersonIdent("J. Author", "author@example.com", TimeUtil.now(), ZONE_ID);
     cb.setAuthor(author);
     cb.setCommitter(
         new PersonIdent(
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index e24d481..11f3528 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -30,9 +30,9 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -44,7 +44,7 @@
 
 @Ignore
 public class AbstractGroupTest {
-  protected static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  protected static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
   protected static final String SERVER_ID = "server-id";
   protected static final String SERVER_NAME = "Gerrit Server";
   protected static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
@@ -65,7 +65,7 @@
     repoManager = new InMemoryRepositoryManager();
     allUsersRepo = repoManager.createRepository(allUsersName);
     serverAccountId = Account.id(SERVER_ACCOUNT_NUMBER);
-    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
     userId = Account.id(USER_ACCOUNT_NUMBER);
     userIdent = newPersonIdent(userId, serverIdent);
   }
@@ -75,12 +75,12 @@
     allUsersRepo.close();
   }
 
-  protected Timestamp getTipTimestamp(AccountGroup.UUID uuid) throws Exception {
+  protected Instant getTipTimestamp(AccountGroup.UUID uuid) throws Exception {
     try (RevWalk rw = new RevWalk(allUsersRepo)) {
       Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(uuid));
       return ref == null
           ? null
-          : new Timestamp(rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhen().getTime());
+          : rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhenAsInstant();
     }
   }
 
@@ -110,7 +110,7 @@
   }
 
   protected static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
   }
 
   protected static PersonIdent newPersonIdent(Account.Id id, PersonIdent ident) {
@@ -123,7 +123,7 @@
   }
 
   private static Optional<Account> getAccount(Account.Id id) {
-    Account.Builder account = Account.builder(id, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(id, TimeUtil.now());
     account.setFullName("Account " + id);
     return Optional.of(account.build());
   }
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index 6ad899e..a764654 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.entities.AccountGroupMemberAudit;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.account.GroupUuid;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Set;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Before;
@@ -299,7 +299,7 @@
   }
 
   private static AccountGroupMemberAudit createExpMemberAudit(
-      AccountGroup.Id groupId, Account.Id id, Account.Id addedBy, Timestamp addedOn) {
+      AccountGroup.Id groupId, Account.Id id, Account.Id addedBy, Instant addedOn) {
     return AccountGroupMemberAudit.builder()
         .groupId(groupId)
         .memberId(id)
@@ -309,7 +309,7 @@
   }
 
   private static AccountGroupByIdAudit createExpGroupAudit(
-      AccountGroup.Id groupId, AccountGroup.UUID uuid, Account.Id addedBy, Timestamp addedOn) {
+      AccountGroup.Id groupId, AccountGroup.UUID uuid, Account.Id addedBy, Instant addedOn) {
     return AccountGroupByIdAudit.builder()
         .groupId(groupId)
         .includeUuid(uuid)
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index 1bfa95c..8c19732 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -35,13 +35,12 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.truth.OptionalSubject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneId;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -63,7 +62,7 @@
   private final AccountGroup.Id groupId = AccountGroup.id(123);
   private final AuditLogFormatter auditLogFormatter =
       AuditLogFormatter.createBackedBy(ImmutableSet.of(), ImmutableSet.of(), "server-id");
-  private final TimeZone timeZone = TimeZone.getTimeZone("America/Los_Angeles");
+  private final ZoneId zoneId = ZoneId.of("America/Los_Angeles");
 
   @Before
   public void setUp() throws Exception {
@@ -246,7 +245,7 @@
   @Test
   public void createdOnDefaultsToNow() throws Exception {
     // Git timestamps are only precise to the second.
-    Timestamp testStart = TimeUtil.truncateToSecond(TimeUtil.nowTs());
+    Instant testStart = TimeUtil.truncateToSecond(TimeUtil.now());
 
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
@@ -262,7 +261,7 @@
 
   @Test
   public void specifiedCreatedOnIsRespectedForNewGroup() throws Exception {
-    Timestamp createdOn = toTimestamp(LocalDate.of(2017, Month.DECEMBER, 11).atTime(13, 44, 10));
+    Instant createdOn = toInstant(LocalDate.of(2017, Month.DECEMBER, 11).atTime(13, 44, 10));
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     GroupDelta groupDelta = GroupDelta.builder().setUpdatedOn(createdOn).build();
@@ -607,8 +606,8 @@
 
   @Test
   public void createdOnIsNotAffectedByFurtherUpdates() throws Exception {
-    Timestamp createdOn = toTimestamp(LocalDate.of(2017, Month.MAY, 11).atTime(13, 44, 10));
-    Timestamp updatedOn = toTimestamp(LocalDate.of(2017, Month.DECEMBER, 12).atTime(10, 21, 49));
+    Instant createdOn = toInstant(LocalDate.of(2017, Month.MAY, 11).atTime(13, 44, 10));
+    Instant updatedOn = toInstant(LocalDate.of(2017, Month.DECEMBER, 12).atTime(10, 21, 49));
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     GroupDelta initialGroupDelta = GroupDelta.builder().setUpdatedOn(createdOn).build();
@@ -740,7 +739,7 @@
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
             .setName(AccountGroup.nameKey("Another name"))
-            .setUpdatedOn(new Timestamp(92900892))
+            .setUpdatedOn(Instant.ofEpochMilli(92900892))
             .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
@@ -761,7 +760,7 @@
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
             .setName(AccountGroup.nameKey("Another name"))
-            .setUpdatedOn(new Timestamp(92900892))
+            .setUpdatedOn(Instant.ofEpochMilli(92900892))
             .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
@@ -783,7 +782,7 @@
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
             .setName(AccountGroup.nameKey("Another name"))
-            .setUpdatedOn(new Timestamp(92900892))
+            .setUpdatedOn(Instant.ofEpochMilli(92900892))
             .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
@@ -867,7 +866,7 @@
   public void newCommitIsNotCreatedForPureUpdatedOnUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    Timestamp updatedOn = toTimestamp(LocalDate.of(3017, Month.DECEMBER, 12).atTime(10, 21, 49));
+    Instant updatedOn = toInstant(LocalDate.of(3017, Month.DECEMBER, 12).atTime(10, 21, 49));
     GroupDelta groupDelta = GroupDelta.builder().setUpdatedOn(updatedOn).build();
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
@@ -1008,7 +1007,7 @@
   @Test
   public void commitTimeMatchesDefaultCreatedOnOfNewGroup() throws Exception {
     // Git timestamps are only precise to the second.
-    long testStartAsSecondsSinceEpoch = TimeUtil.nowTs().getTime() / 1000;
+    long testStartAsSecondsSinceEpoch = TimeUtil.now().getEpochSecond();
 
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
@@ -1035,7 +1034,7 @@
             .build();
     GroupDelta groupDelta =
         GroupDelta.builder()
-            .setUpdatedOn(new Timestamp(createdOnAsSecondsSinceEpoch * 1000))
+            .setUpdatedOn(Instant.ofEpochSecond(createdOnAsSecondsSinceEpoch))
             .build();
     createGroup(groupCreation, groupDelta);
 
@@ -1045,9 +1044,9 @@
 
   @Test
   public void timestampOfCommitterMatchesSpecifiedCreatedOnOfNewGroup() throws Exception {
-    Timestamp committerTimestamp =
-        toTimestamp(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
-    Timestamp createdOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
+    Instant committerTimestamp =
+        toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
+    Instant createdOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
@@ -1064,23 +1063,22 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getCommitterIdent().getWhen()).isEqualTo(createdOn);
-    assertThat(revCommit.getCommitterIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getCommitterIdent().getWhenAsInstant()).isEqualTo(createdOn);
+    assertThat(revCommit.getCommitterIdent().getZoneId().getRules().getOffset(createdOn))
+        .isEqualTo(zoneId.getRules().getOffset(createdOn));
   }
 
   @Test
   public void timestampOfAuthorMatchesSpecifiedCreatedOnOfNewGroup() throws Exception {
-    Timestamp authorTimestamp =
-        toTimestamp(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
-    Timestamp createdOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
+    Instant authorTimestamp = toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
+    Instant createdOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
@@ -1097,22 +1095,22 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getAuthorIdent().getWhen()).isEqualTo(createdOn);
-    assertThat(revCommit.getAuthorIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getAuthorIdent().getWhenAsInstant()).isEqualTo(createdOn);
+    assertThat(revCommit.getAuthorIdent().getZoneId().getRules().getOffset(createdOn))
+        .isEqualTo(zoneId.getRules().getOffset(createdOn));
   }
 
   @Test
   public void commitTimeMatchesDefaultUpdatedOnOfUpdatedGroup() throws Exception {
     // Git timestamps are only precise to the second.
-    long testStartAsSecondsSinceEpoch = TimeUtil.nowTs().getTime() / 1000;
+    long testStartAsSecondsSinceEpoch = TimeUtil.now().getEpochSecond();
 
     createArbitraryGroup(groupUuid);
     GroupDelta groupDelta =
@@ -1132,7 +1130,7 @@
     GroupDelta groupDelta =
         GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
-            .setUpdatedOn(new Timestamp(updatedOnAsSecondsSinceEpoch * 1000))
+            .setUpdatedOn(Instant.ofEpochSecond(updatedOnAsSecondsSinceEpoch))
             .build();
     updateGroup(groupUuid, groupDelta);
 
@@ -1142,9 +1140,9 @@
 
   @Test
   public void timestampOfCommitterMatchesSpecifiedUpdatedOnOfUpdatedGroup() throws Exception {
-    Timestamp committerTimestamp =
-        toTimestamp(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
-    Timestamp updatedOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
+    Instant committerTimestamp =
+        toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
+    Instant updatedOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     createArbitraryGroup(groupUuid);
     GroupDelta groupDelta =
@@ -1156,23 +1154,22 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getCommitterIdent().getWhen()).isEqualTo(updatedOn);
-    assertThat(revCommit.getCommitterIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getCommitterIdent().getWhenAsInstant()).isEqualTo(updatedOn);
+    assertThat(revCommit.getCommitterIdent().getZoneId().getRules().getOffset(updatedOn))
+        .isEqualTo(zoneId.getRules().getOffset(updatedOn));
   }
 
   @Test
   public void timestampOfAuthorMatchesSpecifiedUpdatedOnOfUpdatedGroup() throws Exception {
-    Timestamp authorTimestamp =
-        toTimestamp(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
-    Timestamp updatedOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
+    Instant authorTimestamp = toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
+    Instant updatedOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     createArbitraryGroup(groupUuid);
     GroupDelta groupDelta =
@@ -1184,16 +1181,16 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getAuthorIdent().getWhen()).isEqualTo(updatedOn);
-    assertThat(revCommit.getAuthorIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getAuthorIdent().getWhenAsInstant()).isEqualTo(updatedOn);
+    assertThat(revCommit.getAuthorIdent().getZoneId().getRules().getOffset(updatedOn))
+        .isEqualTo(zoneId.getRules().getOffset(updatedOn));
   }
 
   @Test
@@ -1458,8 +1455,8 @@
                 + "Rename from Old name to New name");
   }
 
-  private static Timestamp toTimestamp(LocalDateTime localDateTime) {
-    return Timestamp.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
+  private static Instant toInstant(LocalDateTime localDateTime) {
+    return localDateTime.atZone(ZoneId.systemDefault()).toInstant();
   }
 
   private void populateGroupConfig(AccountGroup.UUID uuid, String fileContent) throws Exception {
@@ -1544,8 +1541,7 @@
 
   private MetaDataUpdate createMetaDataUpdate() {
     PersonIdent serverIdent =
-        new PersonIdent(
-            "Gerrit Server", "noreply@gerritcodereview.com", TimeUtil.nowTs(), timeZone);
+        new PersonIdent("Gerrit Server", "noreply@gerritcodereview.com", TimeUtil.now(), zoneId);
 
     MetaDataUpdate metaDataUpdate =
         new MetaDataUpdate(
@@ -1567,7 +1563,7 @@
   }
 
   private static Account createAccount(Account.Id id, String name) {
-    Account.Builder account = Account.builder(id, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(id, TimeUtil.now());
     account.setFullName(name);
     return account.build();
   }
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index 3b7beb9..9d8f260 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -44,10 +44,10 @@
 import com.google.gerrit.truth.ListSubject;
 import com.google.gerrit.truth.OptionalSubject;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -71,7 +71,7 @@
 public class GroupNameNotesTest {
   private static final String SERVER_NAME = "Gerrit Server";
   private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
 
   private final AccountGroup.UUID groupUuid = AccountGroup.uuid("users-XYZ");
   private final AccountGroup.NameKey groupName = AccountGroup.nameKey("users");
@@ -558,7 +558,7 @@
   }
 
   private static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
   }
 
   private static ObjectId getNoteKey(GroupReference g) {
diff --git a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
index 9025691..6745b1d 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo.warning;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
@@ -95,7 +96,7 @@
   @Test
   public void groupNameNoteFailToParse() throws Exception {
     updateGroupNamesRef("g-1", "[invalid");
-    List<ConsistencyProblemInfo> problems =
+    ImmutableList<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
             allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
index d16efc3..4d9cb76 100644
--- a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -36,7 +36,7 @@
   @Test
   public void refStateFieldValues() throws Exception {
     AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
-    Account.Builder account = Account.builder(Account.id(1), TimeUtil.nowTs());
+    Account.Builder account = Account.builder(Account.id(1), TimeUtil.now());
     String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
     account.setMetaId(metaId);
     List<String> values =
@@ -51,7 +51,7 @@
   public void externalIdStateFieldValues() throws Exception {
     Account.Id id = Account.id(1);
     Account account =
-        Account.builder(id, TimeUtil.nowTs())
+        Account.builder(id, TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build();
     ExternalId extId1 =
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 6ad2060..0bdf5cd 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -27,13 +27,17 @@
 import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.index.testing.FakeStoredValue;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestTimeUtil;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
@@ -55,17 +59,22 @@
 
   @Test
   public void reviewerFieldValues() {
-    Table<ReviewerStateInternal, Account.Id, Timestamp> t = HashBasedTable.create();
-    Timestamp t1 = TimeUtil.nowTs();
+    Table<ReviewerStateInternal, Account.Id, Instant> t = HashBasedTable.create();
+
+    // Timestamps are stored as epoch millis in the reviewer field. Epoch millis are less precise
+    // than Instants which have nanosecond precision. Create Instants with millisecond precision
+    // here so that the comparison for the assertions works.
+    Instant t1 = Instant.ofEpochMilli(TimeUtil.nowMs());
+    Instant t2 = Instant.ofEpochMilli(TimeUtil.nowMs());
+
     t.put(ReviewerStateInternal.REVIEWER, Account.id(1), t1);
-    Timestamp t2 = TimeUtil.nowTs();
     t.put(ReviewerStateInternal.CC, Account.id(2), t2);
     ReviewerSet reviewers = ReviewerSet.fromTable(t);
 
     List<String> values = ChangeField.getReviewerFieldValues(reviewers);
     assertThat(values)
         .containsExactly(
-            "REVIEWER,1", "REVIEWER,1," + t1.getTime(), "CC,2", "CC,2," + t2.getTime());
+            "REVIEWER,1", "REVIEWER,1," + t1.toEpochMilli(), "CC,2", "CC,2," + t2.toEpochMilli());
 
     assertThat(ChangeField.parseReviewerFieldValues(Change.id(1), values)).isEqualTo(reviewers);
   }
@@ -84,6 +93,18 @@
   }
 
   @Test
+  public void formatSubmitRequirementValues() {
+    assertThat(
+            ChangeField.formatSubmitRequirementValues(
+                ImmutableList.of(
+                    submitRequirementResult(
+                        "CR", "label:CR=+1", SubmitRequirementExpressionResult.Status.PASS),
+                    submitRequirementResult(
+                        "LC", "label:LC=+1", SubmitRequirementExpressionResult.Status.FAIL))))
+        .containsExactly("MAY,cr", "OK,cr", "NEED,lc", "REJECT,lc");
+  }
+
+  @Test
   public void storedSubmitRecords() {
     assertStoredRecordRoundTrip(record(SubmitRecord.Status.CLOSED));
 
@@ -147,6 +168,55 @@
     assertThat(ChangeField.DELETED.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
   }
 
+  @Test
+  public void shortStringIsNotTruncated() {
+    assertThat(ChangeField.truncateStringValue("short string", 20)).isEqualTo("short string");
+    String two_byte_str = String.format("short string %s", new String(Character.toChars(956)));
+    assertThat(ChangeField.truncateStringValue(two_byte_str, 20)).isEqualTo(two_byte_str);
+    String three_byte_str = String.format("short string %s", new String(Character.toChars(43421)));
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 20)).isEqualTo(three_byte_str);
+    String four_byte_str = String.format("short string %s", new String(Character.toChars(132878)));
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 20)).isEqualTo(four_byte_str);
+    assertThat(ChangeField.truncateStringValue("", 6)).isEqualTo("");
+    assertThat(ChangeField.truncateStringValue("", 0)).isEqualTo("");
+  }
+
+  @Test
+  public void longStringIsTruncated() {
+    assertThat(ChangeField.truncateStringValue("longer string", 6)).isEqualTo("longer");
+    assertThat(ChangeField.truncateStringValue("longer string", 0)).isEqualTo("");
+
+    String two_byte_str =
+        String.format(
+            "multibytechars %1$s%1$s%1$s%1$s present", new String(Character.toChars(956)));
+    assertThat(ChangeField.truncateStringValue(two_byte_str, 16)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(two_byte_str, 17))
+        .isEqualTo(String.format("multibytechars %1$s", new String(Character.toChars(956))));
+    assertThat(ChangeField.truncateStringValue(two_byte_str, 18))
+        .isEqualTo(String.format("multibytechars %1$s", new String(Character.toChars(956))));
+
+    String three_byte_str =
+        String.format(
+            "multibytechars %1$s%1$s%1$s%1$s present", new String(Character.toChars(43421)));
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 16)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 17)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 18))
+        .isEqualTo(String.format("multibytechars %1$s", new String(Character.toChars(43421))));
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 21))
+        .isEqualTo(String.format("multibytechars %1$s%1$s", new String(Character.toChars(43421))));
+
+    String four_byte_str =
+        String.format(
+            "multibytechars %1$s%1$s%1$s%1$s present", new String(Character.toChars(132878)));
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 16)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 17)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 18)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 19))
+        .isEqualTo(String.format("multibytechars %1$s", new String(Character.toChars(132878))));
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 23))
+        .isEqualTo(String.format("multibytechars %1$s%1$s", new String(Character.toChars(132878))));
+  }
+
   private static SubmitRecord record(SubmitRecord.Status status, SubmitRecord.Label... labels) {
     SubmitRecord r = new SubmitRecord();
     r.status = status;
@@ -156,6 +226,25 @@
     return r;
   }
 
+  private SubmitRequirementResult submitRequirementResult(
+      String srName, String submitExpr, SubmitRequirementExpressionResult.Status submitExprStatus) {
+    return SubmitRequirementResult.builder()
+        .submitRequirement(
+            SubmitRequirement.builder()
+                .setName(srName)
+                .setSubmittabilityExpression(SubmitRequirementExpression.create("NA"))
+                .setAllowOverrideInChildProjects(false)
+                .build())
+        .submittabilityExpressionResult(
+            SubmitRequirementExpressionResult.create(
+                SubmitRequirementExpression.create(submitExpr),
+                submitExprStatus,
+                ImmutableList.of(submitExpr),
+                ImmutableList.of()))
+        .patchSetCommitId(ObjectId.zeroId())
+        .build();
+  }
+
   private static SubmitRecord.Label label(
       SubmitRecord.Label.Status status, String label, Integer appliedBy) {
     SubmitRecord.Label l = new SubmitRecord.Label();
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index 8c25edf..26e9e54 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -220,12 +220,10 @@
     assertThat(status("file:a")).isEqualTo(all);
     assertThat(status("is:new")).containsExactly(NEW);
     assertThat(status("is:new OR is:merged")).containsExactly(NEW, MERGED);
-    assertThat(status("is:new OR is:x")).isEqualTo(all);
 
     assertThat(status("is:new is:merged")).isEmpty();
     assertThat(status("(is:new) (is:merged)")).isEmpty();
     assertThat(status("(is:new) (is:merged)")).isEmpty();
-    assertThat(status("is:new is:x")).containsExactly(NEW);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 66a98e8..e879170 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.index.query.OperatorPredicate;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import org.eclipse.jgit.lib.Config;
@@ -25,7 +26,7 @@
 public class FakeQueryBuilder extends ChangeQueryBuilder {
   FakeQueryBuilder(ChangeIndexCollection indexes) {
     super(
-        new ChangeQueryBuilder.Definition<>(FakeQueryBuilder.class),
+        new QueryBuilder.Definition<>(FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(
             null,
             null,
@@ -56,6 +57,7 @@
             new Config(),
             null,
             null,
+            null,
             null));
   }
 
@@ -70,6 +72,6 @@
   }
 
   private Predicate<ChangeData> predicate(String name, String value) {
-    return new OperatorPredicate<ChangeData>(name, value) {};
+    return new OperatorPredicate<>(name, value) {};
   }
 }
diff --git a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
index 981e284..512a1b1 100644
--- a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
@@ -32,8 +32,6 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
 import org.eclipse.jgit.lib.Config;
 import org.junit.After;
@@ -173,7 +171,7 @@
               new Description("Latency metric for testing"),
               Field.ofInteger("account", Metadata.Builder::accountId).build(),
               Field.ofInteger("change", Metadata.Builder::changeId).build(),
-              Field.ofString("project", Metadata.Builder::projectName).build());
+              Field.ofProjectName("project").build());
       timer3.start(1000000, 123, "foo/bar").close();
 
       assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(4);
@@ -208,14 +206,14 @@
           metricMaker.newTimer(
               "test1/latency",
               new Description("Latency metric for testing"),
-              Field.ofString("project", Metadata.Builder::projectName).build());
+              Field.ofProjectName("project").build());
       timer1.start(null).close();
 
       Timer2<String, String> timer2 =
           metricMaker.newTimer(
               "test2/latency",
               new Description("Latency metric for testing"),
-              Field.ofString("project", Metadata.Builder::projectName).build(),
+              Field.ofProjectName("project").build(),
               Field.ofString("branch", Metadata.Builder::branchName).build());
       timer2.start(null, null).close();
 
@@ -223,7 +221,7 @@
           metricMaker.newTimer(
               "test3/latency",
               new Description("Latency metric for testing"),
-              Field.ofString("project", Metadata.Builder::projectName).build(),
+              Field.ofProjectName("project").build(),
               Field.ofString("branch", Metadata.Builder::branchName).build(),
               Field.ofString("revision", Metadata.Builder::revision).build());
       timer3.start(null, null, null).close();
@@ -272,7 +270,7 @@
             new Description("Latency metric for testing"),
             Field.ofInteger("account", Metadata.Builder::accountId).build(),
             Field.ofInteger("change", Metadata.Builder::changeId).build(),
-            Field.ofString("project", Metadata.Builder::projectName).build());
+            Field.ofProjectName("project").build());
     timer3.start(1000000, 123, "value3").close();
 
     assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
@@ -319,7 +317,7 @@
               new Description("Latency metric for testing"),
               Field.ofInteger("account", Metadata.Builder::accountId).build(),
               Field.ofInteger("change", Metadata.Builder::changeId).build(),
-              Field.ofString("project", Metadata.Builder::projectName).build());
+              Field.ofProjectName("project").build());
       timer3.start(1000000, 123, "foo/bar").close();
 
       assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
@@ -363,7 +361,7 @@
   }
 
   private static class TestPerformanceLogger implements PerformanceLogger {
-    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+    private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
 
     @Override
     public void log(String operation, long durationMs, Metadata metadata) {
@@ -371,7 +369,7 @@
     }
 
     ImmutableList<PerformanceLogEntry> logEntries() {
-      return ImmutableList.copyOf(logEntries);
+      return logEntries.build();
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/mail/SignedTokenTest.java b/javatests/com/google/gerrit/server/mail/SignedTokenTest.java
index 41d8d69..27c4f56 100644
--- a/javatests/com/google/gerrit/server/mail/SignedTokenTest.java
+++ b/javatests/com/google/gerrit/server/mail/SignedTokenTest.java
@@ -152,7 +152,7 @@
   private static String randomString(int length) {
     String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
     Random random = new Random();
-    StringBuffer sb = new StringBuffer();
+    StringBuilder sb = new StringBuilder();
     for (int i = 0; i < length; i++) {
       int number = random.nextInt(62);
       sb.append(str.charAt(number));
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index 5980071..629b0cc 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -362,7 +362,7 @@
 
   private AccountState makeUser(String name, String email) {
     final Account.Id userId = Account.id(42);
-    final Account.Builder account = Account.builder(userId, TimeUtil.nowTs());
+    final Account.Builder account = Account.builder(userId, TimeUtil.now());
     account.setFullName(name);
     account.setPreferredEmail(email);
     return AccountState.forAccount(account.build());
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
similarity index 89%
rename from javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java
rename to javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
index 2ec5e4d..fbeabe1 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
@@ -25,7 +25,7 @@
 import org.junit.Before;
 import org.junit.Test;
 
-public class MailSoySauceProviderTest {
+public class MailSoySauceLoaderTest {
 
   private SitePaths sitePaths;
   private DynamicSet<MailSoyTemplateProvider> set;
@@ -38,11 +38,11 @@
 
   @Test
   public void soyCompilation() {
-    MailSoySauceProvider provider =
-        new MailSoySauceProvider(
+    MailSoySauceLoader loader =
+        new MailSoySauceLoader(
             sitePaths,
             new SoyAstCache(),
             new PluginSetContext<>(set, PluginMetrics.DISABLED_INSTANCE));
-    assertThat(provider.get()).isNotNull(); // should not throw
+    assertThat(loader.load()).isNotNull(); // should not throw
   }
 }
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
new file mode 100644
index 0000000..3ce60b8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
@@ -0,0 +1,74 @@
+// 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;
+import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
+
+import com.google.common.cache.LoadingCache;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.CacheRefreshExecutor;
+import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Names;
+import com.google.template.soy.jbcsrc.api.SoySauce;
+import java.nio.file.Paths;
+import javax.inject.Provider;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class MailSoySauceModuleTest {
+  @Test
+  public void soySauceProviderReturnsCachedValue() throws Exception {
+    SitePaths sitePaths = new SitePaths(Paths.get("."));
+    Injector injector =
+        Guice.createInjector(
+            new MailSoySauceModule(),
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                super.configure();
+                bind(ListeningExecutorService.class)
+                    .annotatedWith(CacheRefreshExecutor.class)
+                    .toInstance(newDirectExecutorService());
+                bind(SitePaths.class).toInstance(sitePaths);
+                bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(new Config());
+                bind(MetricMaker.class).to(DisabledMetricMaker.class);
+                install(new DefaultMemoryCacheModule());
+              }
+            });
+    Provider<SoySauce> soySauceProvider =
+        injector.getProvider(Key.get(SoySauce.class, MailTemplates.class));
+    LoadingCache<String, SoySauce> cache =
+        injector.getInstance(
+            Key.get(
+                new TypeLiteral<LoadingCache<String, SoySauce>>() {},
+                Names.named(MailSoySauceModule.CACHE_NAME)));
+    assertThat(cache.stats().loadCount()).isEqualTo(0);
+    // Theoretically, this can be flaky, if the delay before the second get takes several seconds.
+    // We assume that tests is fast enough.
+    assertThat(soySauceProvider.get()).isNotNull();
+    assertThat(soySauceProvider.get()).isNotNull();
+    assertThat(cache.stats().loadCount()).isEqualTo(1);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index ba514fd..d7c779e 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -39,6 +39,8 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
+import com.google.gerrit.server.approval.testing.TestPatchSetApprovalUuidGenerator;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
@@ -65,8 +67,8 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.sql.Timestamp;
-import java.util.TimeZone;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.concurrent.ExecutorService;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -82,7 +84,7 @@
 @Ignore
 @RunWith(ConfigSuite.class)
 public abstract class AbstractChangeNotesTest {
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
 
   @ConfigSuite.Parameter public Config testConfig;
 
@@ -116,18 +118,18 @@
   public void setUpTestEnvironment() throws Exception {
     setTimeForTesting();
 
-    serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.nowTs(), TZ);
+    serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.now(), ZONE_ID);
     project = Project.nameKey("test-project");
     repoManager = new InMemoryRepositoryManager();
     repo = repoManager.createRepository(project);
     tr = new TestRepository<>(repo);
     rw = tr.getRevWalk();
     accountCache = new FakeAccountCache();
-    Account.Builder co = Account.builder(Account.id(1), TimeUtil.nowTs());
+    Account.Builder co = Account.builder(Account.id(1), TimeUtil.now());
     co.setFullName("Change Owner");
     co.setPreferredEmail("change@owner.com");
     accountCache.put(co.build());
-    Account.Builder ou = Account.builder(Account.id(2), TimeUtil.nowTs());
+    Account.Builder ou = Account.builder(Account.id(2), TimeUtil.now());
     ou.setFullName("Other Account");
     ou.setPreferredEmail("other@account.com");
     accountCache.put(ou.build());
@@ -173,6 +175,8 @@
                         () -> {
                           throw new UnsupportedOperationException();
                         });
+                bind(PatchSetApprovalUuidGenerator.class)
+                    .to(TestPatchSetApprovalUuidGenerator.class);
               }
             });
 
@@ -261,7 +265,7 @@
       int line,
       IdentifiedUser commenter,
       String parentUUID,
-      Timestamp t,
+      Instant t,
       String message,
       short side,
       ObjectId commitId,
@@ -282,11 +286,11 @@
     return c;
   }
 
-  protected static Timestamp truncate(Timestamp ts) {
-    return new Timestamp((ts.getTime() / 1000) * 1000);
+  protected static Instant truncate(Instant ts) {
+    return Instant.ofEpochMilli((ts.toEpochMilli() / 1000) * 1000);
   }
 
-  protected static Timestamp after(Change c, long millis) {
-    return new Timestamp(c.getCreatedOn().getTime() + millis);
+  protected static Instant after(Change c, long millis) {
+    return Instant.ofEpochMilli(c.getCreatedOn().toEpochMilli() + millis);
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
new file mode 100644
index 0000000..24e28f3
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.gson.Gson;
+import com.google.inject.TypeLiteral;
+import java.util.Optional;
+import org.junit.Test;
+
+public class ChangeNoteJsonTest {
+  private final Gson gson = new ChangeNoteJson().getGson();
+
+  static class Child {
+    Optional<String> optionalValue;
+  }
+
+  static class Parent {
+    Optional<Child> optionalChild;
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeEmptyOptional() {
+    // given
+    Optional<?> empty = Optional.empty();
+
+    // when
+    String json = gson.toJson(empty);
+
+    // then
+    assertThat(json).isEqualTo("{}");
+
+    // and when
+    Optional<?> result = gson.fromJson(json, Optional.class);
+
+    // and then
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNonEmptyOptional() {
+    // given
+    String value = "foo";
+    Optional<String> nonEmpty = Optional.of(value);
+
+    // when
+    String json = gson.toJson(nonEmpty);
+
+    // then
+    assertThat(json).isEqualTo("{\n  \"value\": \"" + value + "\"\n}");
+
+    // and when
+    Optional<String> result = gson.fromJson(json, new TypeLiteral<Optional<String>>() {}.getType());
+
+    // and then
+    assertThat(result).isPresent();
+    assertThat(result.get()).isEqualTo(value);
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNestedNonEmptyOptional() {
+    String value = "foo";
+    Child fooChild = new Child();
+    fooChild.optionalValue = Optional.of(value);
+    Parent parent = new Parent();
+    parent.optionalChild = Optional.of(fooChild);
+
+    String json = gson.toJson(parent);
+
+    assertThat(json)
+        .isEqualTo(
+            "{\n"
+                + "  \"optionalChild\": {\n"
+                + "    \"value\": {\n"
+                + "      \"optionalValue\": {\n"
+                + "        \"value\": \"foo\"\n"
+                + "      }\n"
+                + "    }\n"
+                + "  }\n"
+                + "}");
+
+    Parent result = gson.fromJson(json, new TypeLiteral<Parent>() {}.getType());
+
+    assertThat(result.optionalChild).isPresent();
+    assertThat(result.optionalChild.get().optionalValue).isPresent();
+    assertThat(result.optionalChild.get().optionalValue.get()).isEqualTo(value);
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNestedEmptyOptional() {
+    Child fooChild = new Child();
+    fooChild.optionalValue = Optional.empty();
+    Parent parent = new Parent();
+    parent.optionalChild = Optional.of(fooChild);
+
+    String json = gson.toJson(parent);
+
+    assertThat(json)
+        .isEqualTo(
+            "{\n"
+                + "  \"optionalChild\": {\n"
+                + "    \"value\": {\n"
+                + "      \"optionalValue\": {}\n"
+                + "    }\n"
+                + "  }\n"
+                + "}");
+
+    Parent result = gson.fromJson(json, new TypeLiteral<Parent>() {}.getType());
+
+    assertThat(result.optionalChild).isPresent();
+    assertThat(result.optionalChild.get().optionalValue).isEmpty();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
index f105cf1..666b8fc 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
@@ -106,7 +106,7 @@
     ChangeNotes notes = newNotes(change).load();
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     PersonIdent author =
-        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent);
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.now(), serverIdent);
     try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
       CommitBuilder cb = new CommitBuilder();
       cb.setParentId(notes.getRevision());
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index fc8ab42..30d265f 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -32,6 +32,11 @@
 import org.junit.Before;
 import org.junit.Test;
 
+/**
+ * Tests for {@link ChangeNotesParser}.
+ *
+ * <p>When modifying storage format, please, add tests that both old and new data can be parsed.
+ */
 public class ChangeNotesParserTest extends AbstractChangeNotesTest {
   private TestRepository<InMemoryRepository> testRepo;
   private ChangeNotesRevWalk walk;
@@ -134,6 +139,7 @@
             + "Label: Label2=1\n"
             + "Label: Label3=0\n"
             + "Label: Label4=-1\n"
+            + "Label: Label1=+1 Gerrit User 1 (name,with, comma) <1@gerrit>\n"
             + "Subject: This is a test change\n");
     assertParseSucceeds(
         "Update change\n"
@@ -154,7 +160,6 @@
 
   @Test
   public void parseApprovalWithUUID() throws Exception {
-    // Introduced by https://gerrit-review.googlesource.com/c/gerrit/+/324937
     assertParseSucceeds(
         "Update change\n"
             + "\n"
@@ -164,20 +169,30 @@
             + "Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7\n"
             + "Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n"
             + "Label: Label1=0, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n"
-            + "Label: Label1=0, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 (name,with, comma) <2@gerrit>\n"
+            + "Label: Label1=0, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 3 (name,with, comma) <3@gerrit>\n"
             + "Subject: This is a test change\n");
-  }
 
-  @Test
-  public void parseApprovalNoUUIDUserWithComma() throws Exception {
     assertParseSucceeds(
         "Update change\n"
             + "\n"
             + "Branch: refs/heads/master\n"
             + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
             + "Patch-set: 1\n"
-            + "Label: Label1=+1 Gerrit User 1 (name,with, comma) <1@gerrit>\n"
+            + "Label: Label1=+1, non-SHA1_UUID\n"
+            + "Label: Label1=+1, non-SHA1_UUID Gerrit User 2 <2@gerrit>\n"
+            + "Label: Label1=0, non-SHA1_UUID Gerrit User 2 <2@gerrit>\n"
             + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1=+1, \n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1=+1,\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nLabel: Label1=-1,  577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nLabel: Label1=-1,  577fb248e474018276351785930358ec0450e9f7\n");
+    // UUID for removals is not supported.
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nLabel: -Label1, 577fb248e474018276351785930358ec0450e9f7\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nLabel: -Label1, 577fb248e474018276351785930358ec0450e9f7 Other Account <2@gerrit>\n");
   }
 
   @Test
@@ -192,16 +207,10 @@
             + "Copied-Label: Label2=+1 Account <1@gerrit>\n"
             + "Copied-Label: Label3=+1 Account <1@gerrit>,Other Account <2@Gerrit> :\"tag\"\n"
             + "Copied-Label: Label4=+1 Account <1@Gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label1=+1 Gerrit User 1 (name,with, comma) <1@gerrit>\n"
+            + "Copied-Label: Label2=+1 Gerrit User 1 (name,with, comma) <1@gerrit>,Gerrit User 2 (name,with, comma) <2@gerrit>\n"
             + "Subject: This is a test change\n");
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Label: -Label1\n"
-            + "Label: -Label4 Account <1@gerrit>\n"
-            + "Subject: This is a test change\n");
+
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=X\n");
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1 = 1\n");
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: X+Y\n");
@@ -219,7 +228,7 @@
 
   @Test
   public void parseCopiedApprovalWithUUID() throws Exception {
-    // Introduced by https://gerrit-review.googlesource.com/c/gerrit/+/324937
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1 ,\n");
     assertParseSucceeds(
         "Update change\n"
             + "\n"
@@ -233,21 +242,33 @@
             + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit> :\"tag with uuid delimiter , \"\n"
             + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
             + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with uuid delimiter , \"\n"
-            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 (name,with, comma) <1@gerrit>,Gerrit User 2 (name,with, comma) <2@gerrit>\n"
+            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 (name,with, comma) <2@gerrit>,Gerrit User 3 (name,with, comma) <3@gerrit>\n"
             + "Subject: This is a test change\n");
-  }
 
-  @Test
-  public void parseCopiedApprovalNoUUIDUserWithComma() throws Exception {
     assertParseSucceeds(
         "Update change\n"
             + "\n"
             + "Branch: refs/heads/master\n"
             + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
             + "Patch-set: 1\n"
-            + "Copied-Label: Label1=+1 Gerrit User 1 (name,with, comma) <1@gerrit>\n"
-            + "Copied-Label: Label2=+1 Gerrit User 1 (name,with, comma) <1@gerrit>,Gerrit User 2 (name,with, comma) <2@gerrit>\n"
+            + "Copied-Label: Label2=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>\n"
+            + "Copied-Label: Label1=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit>\n"
+            + "Copied-Label: Label3=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag\"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with uuid delimiter , \"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with uuid delimiter , \"\n"
             + "Subject: This is a test change\n");
+
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1,\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1,\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1 ,\n");
+    assertParseFails(
+        "Copied-Label: Label1=+1,  577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit>\n\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 :\"tag\"\n");
   }
 
   @Test
@@ -701,7 +722,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.now(), serverIdent),
         false);
   }
 
@@ -713,7 +734,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.now(), serverIdent),
         initWorkInProgress);
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 61002f9..3295828 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -92,8 +92,8 @@
     cols =
         ChangeColumns.builder()
             .changeKey(Change.key(CHANGE_KEY))
-            .createdOn(new Timestamp(123456L))
-            .lastUpdatedOn(new Timestamp(234567L))
+            .createdOn(Instant.ofEpochMilli(123456L))
+            .lastUpdatedOn(Instant.ofEpochMilli(234567L))
             .owner(Account.id(1000))
             .branch("refs/heads/master")
             .subject("Test change")
@@ -135,7 +135,9 @@
   @Test
   public void serializeCreatedOn() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().createdOn(new Timestamp(98765L)).build()).build(),
+        newBuilder()
+            .columns(cols.toBuilder().createdOn(Instant.ofEpochMilli(98765L)).build())
+            .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -146,7 +148,9 @@
   @Test
   public void serializeLastUpdatedOn() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().lastUpdatedOn(new Timestamp(98765L)).build()).build(),
+        newBuilder()
+            .columns(cols.toBuilder().lastUpdatedOn(Instant.ofEpochMilli(98765L)).build())
+            .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -376,7 +380,7 @@
                     PatchSet.id(ID, 1), Account.id(2001), LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .tag("tag")
-            .granted(new Timestamp(1212L))
+            .granted(Instant.ofEpochMilli(1212L))
             .build();
     Entities.PatchSetApproval psa1 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a1);
     ByteString a1Bytes = Protos.toByteString(psa1);
@@ -389,7 +393,7 @@
             .value(-1)
             .tag("tag")
             .copied(true)
-            .granted(new Timestamp(3434L))
+            .granted(Instant.ofEpochMilli(3434L))
             .build();
     Entities.PatchSetApproval psa2 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a2);
     ByteString a2Bytes = Protos.toByteString(psa2);
@@ -410,14 +414,62 @@
   }
 
   @Test
+  public void serializeApprovalsWithUUID() throws Exception {
+    PatchSetApproval a1 =
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(ID, 1), Account.id(2001), LabelId.create(LabelId.CODE_REVIEW)))
+            .uuid(Optional.of(PatchSetApproval.uuid("577fb248e474018276351785930358ec0450e9f7")))
+            .value(1)
+            .tag("tag")
+            .granted(Instant.ofEpochMilli(1212L))
+            .build();
+    Entities.PatchSetApproval psa1 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a1);
+    ByteString a1Bytes = Protos.toByteString(psa1);
+
+    PatchSetApproval a2 =
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(ID, 1), Account.id(2002), LabelId.create(LabelId.VERIFIED)))
+            .uuid(Optional.of(PatchSetApproval.uuid("577fb248e474018276351785930358ec0450e9f7")))
+            .value(-1)
+            .tag("tag")
+            .copied(true)
+            .granted(Instant.ofEpochMilli(3434L))
+            .build();
+    Entities.PatchSetApproval psa2 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a2);
+    ByteString a2Bytes = Protos.toByteString(psa2);
+    assertThat(a2Bytes.size()).isEqualTo(98);
+    assertThat(a2Bytes).isNotEqualTo(a1Bytes);
+
+    assertRoundTrip(
+        newBuilder()
+            .approvals(ImmutableListMultimap.of(a2.patchSetId(), a2, a1.patchSetId(), a1).entries())
+            .build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setColumns(colsProto)
+            .addApproval(psa2)
+            .addApproval(psa1)
+            .build());
+  }
+
+  @Test
   public void serializeReviewers() throws Exception {
     assertRoundTrip(
         newBuilder()
             .reviewers(
                 ReviewerSet.fromTable(
-                    ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                        .put(ReviewerStateInternal.CC, Account.id(2001), new Timestamp(1212L))
-                        .put(ReviewerStateInternal.REVIEWER, Account.id(2002), new Timestamp(3434L))
+                    ImmutableTable.<ReviewerStateInternal, Account.Id, Instant>builder()
+                        .put(
+                            ReviewerStateInternal.CC, Account.id(2001), Instant.ofEpochMilli(1212L))
+                        .put(
+                            ReviewerStateInternal.REVIEWER,
+                            Account.id(2002),
+                            Instant.ofEpochMilli(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -443,15 +495,15 @@
         newBuilder()
             .reviewersByEmail(
                 ReviewerByEmailSet.fromTable(
-                    ImmutableTable.<ReviewerStateInternal, Address, Timestamp>builder()
+                    ImmutableTable.<ReviewerStateInternal, Address, Instant>builder()
                         .put(
                             ReviewerStateInternal.CC,
                             Address.create("Name1", "email1@example.com"),
-                            new Timestamp(1212L))
+                            Instant.ofEpochMilli(1212L))
                         .put(
                             ReviewerStateInternal.REVIEWER,
                             Address.create("Name2", "email2@example.com"),
-                            new Timestamp(3434L))
+                            Instant.ofEpochMilli(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -481,7 +533,7 @@
                         ImmutableTable.of(
                             ReviewerStateInternal.CC,
                             Address.create("emailonly@example.com"),
-                            new Timestamp(1212L))))
+                            Instant.ofEpochMilli(1212L))))
                 .build(),
             ChangeNotesStateProto.newBuilder()
                 .setMetaId(SHA_BYTES)
@@ -509,9 +561,13 @@
         newBuilder()
             .pendingReviewers(
                 ReviewerSet.fromTable(
-                    ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                        .put(ReviewerStateInternal.CC, Account.id(2001), new Timestamp(1212L))
-                        .put(ReviewerStateInternal.REVIEWER, Account.id(2002), new Timestamp(3434L))
+                    ImmutableTable.<ReviewerStateInternal, Account.Id, Instant>builder()
+                        .put(
+                            ReviewerStateInternal.CC, Account.id(2001), Instant.ofEpochMilli(1212L))
+                        .put(
+                            ReviewerStateInternal.REVIEWER,
+                            Account.id(2002),
+                            Instant.ofEpochMilli(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -537,15 +593,15 @@
         newBuilder()
             .pendingReviewersByEmail(
                 ReviewerByEmailSet.fromTable(
-                    ImmutableTable.<ReviewerStateInternal, Address, Timestamp>builder()
+                    ImmutableTable.<ReviewerStateInternal, Address, Instant>builder()
                         .put(
                             ReviewerStateInternal.CC,
                             Address.create("Name1", "email1@example.com"),
-                            new Timestamp(1212L))
+                            Instant.ofEpochMilli(1212L))
                         .put(
                             ReviewerStateInternal.REVIEWER,
                             Address.create("Name2", "email2@example.com"),
-                            new Timestamp(3434L))
+                            Instant.ofEpochMilli(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -585,12 +641,12 @@
             .reviewerUpdates(
                 ImmutableList.of(
                     ReviewerStatusUpdate.create(
-                        new Timestamp(1212L),
+                        Instant.ofEpochMilli(1212L),
                         Account.id(1000),
                         Account.id(2002),
                         ReviewerStateInternal.CC),
                     ReviewerStatusUpdate.create(
-                        new Timestamp(3434L),
+                        Instant.ofEpochMilli(3434L),
                         Account.id(1000),
                         Account.id(2001),
                         ReviewerStateInternal.REVIEWER)))
@@ -696,7 +752,7 @@
                                 .setApplicabilityExpression(
                                     SubmitRequirementExpression.of("project:foo"))
                                 .setSubmittabilityExpression(
-                                    SubmitRequirementExpression.create("label:code-review=+2"))
+                                    SubmitRequirementExpression.create("label:Code-Review=+2"))
                                 .setAllowOverrideInChildProjects(false)
                                 .build())
                         .applicabilityExpressionResult(
@@ -708,10 +764,10 @@
                                     ImmutableList.of())))
                         .submittabilityExpressionResult(
                             SubmitRequirementExpressionResult.create(
-                                SubmitRequirementExpression.create("label:code-review=+2"),
+                                SubmitRequirementExpression.create("label:Code-Review=+2"),
                                 SubmitRequirementExpressionResult.Status.FAIL,
                                 ImmutableList.of(),
-                                ImmutableList.of("label:code-review=+2")))
+                                ImmutableList.of("label:Code-Review=+2")))
                         .build()))
             .build(),
         newProtoBuilder()
@@ -726,7 +782,7 @@
                         SubmitRequirementProto.newBuilder()
                             .setName("Code-Review")
                             .setApplicabilityExpression("project:foo")
-                            .setSubmittabilityExpression("label:code-review=+2")
+                            .setSubmittabilityExpression("label:Code-Review=+2")
                             .setAllowOverrideInChildProjects(false)
                             .build())
                     .setApplicabilityExpressionResult(
@@ -737,9 +793,9 @@
                             .build())
                     .setSubmittabilityExpressionResult(
                         SubmitRequirementExpressionResultProto.newBuilder()
-                            .setExpression("label:code-review=+2")
+                            .setExpression("label:Code-Review=+2")
                             .setStatus("FAIL")
-                            .addFailingAtoms("label:code-review=+2")
+                            .addFailingAtoms("label:Code-Review=+2")
                             .build())
                     .build())
             .build());
@@ -752,9 +808,11 @@
             .assigneeUpdates(
                 ImmutableList.of(
                     AssigneeStatusUpdate.create(
-                        new Timestamp(1212L), Account.id(1000), Optional.of(Account.id(2001))),
+                        Instant.ofEpochMilli(1212L),
+                        Account.id(1000),
+                        Optional.of(Account.id(2001))),
                     AssigneeStatusUpdate.create(
-                        new Timestamp(3434L), Account.id(1000), Optional.empty())))
+                        Instant.ofEpochMilli(3434L), Account.id(1000), Optional.empty())))
             .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
@@ -799,7 +857,7 @@
         ChangeMessage.create(
             ChangeMessage.key(ID, "uuid1"),
             Account.id(1000),
-            new Timestamp(1212L),
+            Instant.ofEpochMilli(1212L),
             PatchSet.id(ID, 1));
     Entities.ChangeMessage m1Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m1);
     ByteString m1Bytes = Protos.toByteString(m1Proto);
@@ -809,7 +867,7 @@
         ChangeMessage.create(
             ChangeMessage.key(ID, "uuid2"),
             Account.id(2000),
-            new Timestamp(3434L),
+            Instant.ofEpochMilli(3434L),
             PatchSet.id(ID, 2));
     Entities.ChangeMessage m2Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m2);
     ByteString m2Bytes = Protos.toByteString(m2Proto);
@@ -833,7 +891,7 @@
         new HumanComment(
             new Comment.Key("uuid1", "file1", 1),
             Account.id(1001),
-            new Timestamp(1212L),
+            Instant.ofEpochMilli(1212L),
             (short) 1,
             "message 1",
             "serverId",
@@ -845,7 +903,7 @@
         new HumanComment(
             new Comment.Key("uuid2", "file2", 2),
             Account.id(1002),
-            new Timestamp(3434L),
+            Instant.ofEpochMilli(3434L),
             (short) 2,
             "message 2",
             "serverId",
@@ -921,7 +979,7 @@
                     "submitRequirementsResult",
                     new TypeLiteral<ImmutableList<SubmitRequirementResult>>() {}.getType())
                 .put("updateCount", int.class)
-                .put("mergedOn", Timestamp.class)
+                .put("mergedOn", Instant.class)
                 .build());
   }
 
@@ -931,8 +989,8 @@
         .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("changeKey", Change.Key.class)
-                .put("createdOn", Timestamp.class)
-                .put("lastUpdatedOn", Timestamp.class)
+                .put("createdOn", Instant.class)
+                .put("lastUpdatedOn", Instant.class)
                 .put("owner", Account.Id.class)
                 .put("branch", String.class)
                 .put("currentPatchSetId", PatchSet.Id.class)
@@ -958,7 +1016,7 @@
                 .put("id", PatchSet.Id.class)
                 .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
-                .put("createdOn", Timestamp.class)
+                .put("createdOn", Instant.class)
                 .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
                 .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("description", new TypeLiteral<Optional<String>>() {}.getType())
@@ -978,8 +1036,9 @@
         .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("key", PatchSetApproval.Key.class)
+                .put("uuid", new TypeLiteral<Optional<PatchSetApproval.UUID>>() {}.getType())
                 .put("value", short.class)
-                .put("granted", Timestamp.class)
+                .put("granted", Instant.class)
                 .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("realAccountId", Account.Id.class)
                 .put("postSubmit", boolean.class)
@@ -995,7 +1054,7 @@
             ImmutableMap.of(
                 "table",
                 new TypeLiteral<
-                    ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp>>() {}.getType(),
+                    ImmutableTable<ReviewerStateInternal, Account.Id, Instant>>() {}.getType(),
                 "accounts",
                 new TypeLiteral<ImmutableSet<Account.Id>>() {}.getType()));
   }
@@ -1007,7 +1066,7 @@
             ImmutableMap.of(
                 "table",
                 new TypeLiteral<
-                    ImmutableTable<ReviewerStateInternal, Address, Timestamp>>() {}.getType(),
+                    ImmutableTable<ReviewerStateInternal, Address, Instant>>() {}.getType(),
                 "users",
                 new TypeLiteral<ImmutableSet<Address>>() {}.getType()));
   }
@@ -1017,7 +1076,7 @@
     assertThatSerializedClass(ReviewerStatusUpdate.class)
         .hasAutoValueMethods(
             ImmutableMap.of(
-                "date", Timestamp.class,
+                "date", Instant.class,
                 "updatedBy", Account.Id.class,
                 "reviewer", Account.Id.class,
                 "state", ReviewerStateInternal.class));
@@ -1029,7 +1088,7 @@
         .hasAutoValueMethods(
             ImmutableMap.of(
                 "date",
-                Timestamp.class,
+                Instant.class,
                 "updatedBy",
                 Account.Id.class,
                 "currentAssignee",
@@ -1067,7 +1126,7 @@
   @Test
   public void serializeMergedOn() throws Exception {
     assertRoundTrip(
-        newBuilder().mergedOn(new Timestamp(234567L)).build(),
+        newBuilder().mergedOn(Instant.ofEpochMilli(234567L)).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -1086,7 +1145,7 @@
             ImmutableMap.<String, Type>builder()
                 .put("key", ChangeMessage.Key.class)
                 .put("author", Account.Id.class)
-                .put("writtenOn", Timestamp.class)
+                .put("writtenOn", Instant.class)
                 .put("message", String.class)
                 .put("patchset", PatchSet.Id.class)
                 .put("tag", String.class)
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index c524c94..09c8059 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -61,7 +62,6 @@
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.TestChanges;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -131,7 +131,7 @@
             1,
             changeOwner,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "Comment",
             (short) 1,
             commit,
@@ -193,7 +193,7 @@
             1,
             changeOwner,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "Comment",
             (short) 1,
             commit,
@@ -244,12 +244,14 @@
     assertThat(psas.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(0).value()).isEqualTo((short) -1);
     assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
+    assertParsedUuid(psas.get(0));
 
     assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).accountId().get()).isEqualTo(1);
     assertThat(psas.get(1).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(psas.get(1).value()).isEqualTo((short) 1);
     assertThat(psas.get(1).granted()).isEqualTo(psas.get(0).granted());
+    assertParsedUuid(psas.get(1));
   }
 
   @Test
@@ -276,6 +278,7 @@
     assertThat(psa1.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa1.value()).isEqualTo((short) -1);
     assertThat(psa1.granted()).isEqualTo(truncate(after(c, 2000)));
+    assertParsedUuid(psa1);
 
     PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
     assertThat(psa2.patchSetId()).isEqualTo(ps2);
@@ -283,6 +286,7 @@
     assertThat(psa2.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa2.value()).isEqualTo((short) +1);
     assertThat(psa2.granted()).isEqualTo(truncate(after(c, 4000)));
+    assertParsedUuid(psa2);
   }
 
   @Test
@@ -297,6 +301,7 @@
         Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
+    assertParsedUuid(psa);
 
     update = newUpdate(c, changeOwner);
     update.putApproval(LabelId.CODE_REVIEW, (short) 1);
@@ -306,6 +311,7 @@
     psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) 1);
+    assertParsedUuid(psa);
   }
 
   @Test
@@ -329,12 +335,14 @@
     assertThat(psas.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(0).value()).isEqualTo((short) -1);
     assertThat(psas.get(0).granted()).isEqualTo(truncate(after(c, 2000)));
+    assertParsedUuid(psas.get(0));
 
     assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).accountId().get()).isEqualTo(2);
     assertThat(psas.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psas.get(1).value()).isEqualTo((short) 1);
     assertThat(psas.get(1).granted()).isEqualTo(truncate(after(c, 3000)));
+    assertParsedUuid(psas.get(1));
   }
 
   @Test
@@ -350,6 +358,7 @@
     assertThat(psa.accountId().get()).isEqualTo(1);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 1);
+    assertParsedUuid(psa);
 
     update = newUpdate(c, changeOwner);
     update.removeApproval("Not-For-Long");
@@ -368,6 +377,248 @@
   }
 
   @Test
+  public void approval_UUIDGenerated_forAllValues() throws Exception {
+    for (int value = -2; value <= 2; value++) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.putApproval(LabelId.CODE_REVIEW, (short) value);
+      update.commit();
+
+      ChangeNotes notes = newNotes(c);
+      PatchSetApproval psa =
+          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+      assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
+      assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
+      assertThat(psa.value()).isEqualTo((short) value);
+      assertParsedUuid(psa);
+    }
+  }
+
+  @Test
+  public void emptyApproval_uuidGenerated() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval psa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(psa.value()).isEqualTo((short) -1);
+    assertParsedUuid(psa);
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 0);
+    update.commit();
+
+    notes = newNotes(c);
+    PatchSetApproval emptyPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+    assertThat(emptyPsa.key()).isEqualTo(psa.key());
+    assertThat(emptyPsa.value()).isEqualTo((short) 0);
+    assertThat(emptyPsa.label()).isEqualTo(psa.label());
+    assertParsedUuid(emptyPsa);
+  }
+
+  @Test
+  public void removedApproval_noUuid() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval psa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(psa.value()).isEqualTo((short) -1);
+    assertParsedUuid(psa);
+
+    update = newUpdate(c, changeOwner);
+    update.removeApproval(LabelId.CODE_REVIEW);
+    update.commit();
+
+    notes = newNotes(c);
+    PatchSetApproval removedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+    assertThat(removedPsa.key()).isEqualTo(psa.key());
+    assertThat(removedPsa.value()).isEqualTo((short) 0);
+    assertThat(removedPsa.label()).isEqualTo(psa.label());
+    assertThat(removedPsa.uuid()).isEmpty();
+  }
+
+  @Test
+  public void reissuedApproval_samePatchSet_differentUUID() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    PatchSetApproval originalPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+
+    assertThat(originalPsa.patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(originalPsa.value()).isEqualTo((short) -1);
+    assertParsedUuid(originalPsa);
+
+    // Remove approval from current patch set
+    update = newUpdate(c, changeOwner);
+    update.removeApproval(LabelId.CODE_REVIEW);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).hasSize(1);
+    PatchSetApproval removedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().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
+    update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).hasSize(1);
+    PatchSetApproval reAddedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+
+    assertThat(reAddedPsa.key()).isEqualTo(originalPsa.key());
+    assertThat(reAddedPsa.value()).isEqualTo(originalPsa.value());
+    // The re-added approval has a different UUID
+    assertParsedUuid(reAddedPsa);
+    assertThat(reAddedPsa.uuid().get()).isNotEqualTo(originalPsa.uuid().get());
+  }
+
+  @Test
+  public void reissuedApproval_otherPatchSet_differentUUID() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    PatchSetApproval originalPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+
+    assertThat(originalPsa.patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(originalPsa.accountId().get()).isEqualTo(1);
+    assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(originalPsa.value()).isEqualTo((short) -1);
+    assertParsedUuid(originalPsa);
+
+    // Create new PatchSet and re-issue vote
+    incrementPatchSet(c);
+    update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).hasSize(2);
+    PatchSetApproval postUpdateOriginalPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(originalPsa.patchSetId()));
+    PatchSetApproval reAddedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().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
+    assertThat(postUpdateOriginalPsa).isEqualTo(originalPsa);
+
+    assertThat(reAddedPsa.accountId()).isEqualTo(originalPsa.accountId());
+    assertThat(reAddedPsa.label()).isEqualTo(originalPsa.label());
+    assertThat(reAddedPsa.value()).isEqualTo(originalPsa.value());
+
+    // The re-added approval has a different UUID
+    assertParsedUuid(reAddedPsa);
+    assertThat(reAddedPsa.uuid().get()).isNotEqualTo(originalPsa.uuid().get());
+  }
+
+  @Test
+  public void approvalUUID_samePatchSet_differentUsers() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.putApprovalFor(otherUserId, LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> patchSetApprovals = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(patchSetApprovals).hasSize(2);
+    assertThat(
+            patchSetApprovals.stream()
+                .filter(psa -> psa.value() == (short) -1 && psa.label().equals(LabelId.CODE_REVIEW))
+                .count())
+        .isEqualTo(2);
+    // Count UUIDs to make sure they are unique
+    assertThat(patchSetApprovals.stream().map(psa -> psa.uuid().get()).distinct().count())
+        .isEqualTo(2);
+    assertThat(patchSetApprovals.stream().map(psa -> psa.accountId()).collect(toImmutableSet()))
+        .containsExactly(changeOwner.getAccountId(), otherUserId);
+  }
+
+  @Test
+  public void approvalUUID_samePatchSet_differentLabels() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.VERIFIED, (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> patchSetApprovals = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(patchSetApprovals).hasSize(2);
+    assertThat(
+            patchSetApprovals.stream()
+                .filter(
+                    psa ->
+                        psa.value() == (short) -1
+                            && psa.accountId().equals(changeOwner.getAccountId()))
+                .count())
+        .isEqualTo(2);
+
+    // Count UUIDs to make sure they are unique
+    assertThat(patchSetApprovals.stream().map(psa -> psa.uuid().get()).distinct().count())
+        .isEqualTo(2);
+    assertThat(patchSetApprovals.stream().map(psa -> psa.label()).collect(toImmutableSet()))
+        .containsExactly(LabelId.VERIFIED, LabelId.CODE_REVIEW);
+  }
+
+  @Test
+  public void approvalUUID_differentChanges() throws Exception {
+    Change c1 = newChange();
+    ChangeUpdate update1 = newUpdate(c1, changeOwner);
+    update1.putApproval(LabelId.CODE_REVIEW, (short) +2);
+    update1.commit();
+
+    Change c2 = newChange();
+    ChangeUpdate update = newUpdate(c2, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) +2);
+    update.commit();
+
+    ChangeNotes notes1 = newNotes(c1);
+    PatchSetApproval psa1 =
+        Iterables.getOnlyElement(notes1.getApprovals().get(c1.currentPatchSetId()));
+    ChangeNotes notes2 = newNotes(c2);
+    PatchSetApproval psa2 =
+        Iterables.getOnlyElement(notes2.getApprovals().get(c2.currentPatchSetId()));
+    assertThat(psa1.label()).isEqualTo(psa2.label());
+    assertThat(psa1.accountId()).isEqualTo(psa2.accountId());
+    assertThat(psa1.value()).isEqualTo(psa2.value());
+
+    // UUID is global: different across changes.
+    assertThat(psa1.uuid()).isNotEqualTo(psa2.uuid());
+  }
+
+  @Test
   public void removeOtherUsersApprovals() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
@@ -380,21 +631,19 @@
     assertThat(psa.accountId()).isEqualTo(otherUserId);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 1);
+    assertParsedUuid(psa);
 
     update = newUpdate(c, changeOwner);
     update.removeApprovalFor(otherUserId, "Not-For-Long");
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals())
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                psa.patchSetId(),
-                PatchSetApproval.builder()
-                    .key(psa.key())
-                    .value(0)
-                    .granted(update.getWhen())
-                    .build()));
+    PatchSetApproval removedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(psa.patchSetId()));
+    assertThat(removedPsa.key()).isEqualTo(psa.key());
+    assertThat(removedPsa.value()).isEqualTo((short) 0);
+    assertThat(removedPsa.label()).isEqualTo(psa.label());
+    assertThat(removedPsa.uuid()).isEmpty();
 
     // Add back approval on same label.
     update = newUpdate(c, otherUser);
@@ -406,6 +655,7 @@
     assertThat(psa.accountId()).isEqualTo(otherUserId);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 2);
+    assertParsedUuid(psa);
   }
 
   @Test
@@ -426,10 +676,13 @@
     assertThat(approvals.get(0).accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
+    assertParsedUuid(approvals.get(0));
 
     assertThat(approvals.get(1).accountId()).isEqualTo(otherUser.getAccountId());
     assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo((short) -1);
+    assertThat(approvals.get(1).uuid()).isPresent();
+    assertParsedUuid(approvals.get(1));
   }
 
   @Test
@@ -462,9 +715,12 @@
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
     assertThat(approvals.get(0).postSubmit()).isFalse();
+    assertParsedUuid(approvals.get(1));
+
     assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo((short) 2);
     assertThat(approvals.get(1).postSubmit()).isTrue();
+    assertParsedUuid(approvals.get(1));
   }
 
   @Test
@@ -503,14 +759,154 @@
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo(1);
     assertThat(approvals.get(0).postSubmit()).isFalse();
+    assertParsedUuid(approvals.get(0));
+
     assertThat(approvals.get(1).accountId()).isEqualTo(ownerId);
     assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo(2);
     assertThat(approvals.get(1).postSubmit()).isFalse(); // During submit.
+    assertParsedUuid(approvals.get(1));
+
     assertThat(approvals.get(2).accountId()).isEqualTo(otherId);
     assertThat(approvals.get(2).label()).isEqualTo("Other-Label");
     assertThat(approvals.get(2).value()).isEqualTo(2);
     assertThat(approvals.get(2).postSubmit()).isTrue();
+    assertParsedUuid(approvals.get(2));
+  }
+
+  @Test
+  public void copiedApprovals_keepsUUID() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval originalPsa =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(originalPsa.value()).isEqualTo(2);
+    assertThat(originalPsa.tag()).isEmpty();
+    assertThat(originalPsa.realAccountId()).isEqualTo(changeOwner.getAccountId());
+    assertParsedUuid(originalPsa);
+
+    // Copied approvals are persisted at the patch set upload, add new patch set
+    incrementPatchSet(c);
+
+    addCopiedApproval(c, changeOwner, originalPsa);
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+    PatchSetApproval copiedApproval =
+        Iterables.getOnlyElement(
+            notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+                .filter(a -> a.copied())
+                .collect(toImmutableList()));
+    PatchSetApproval nonCopiedApproval =
+        Iterables.getOnlyElement(
+            notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+                .filter(a -> !a.copied())
+                .collect(toImmutableList()));
+
+    // Still same original PSA is returned
+    assertThat(nonCopiedApproval).isEqualTo(originalPsa);
+
+    // The copied approval matches the original approval, including UUID
+    assertCopiedApproval(originalPsa, copiedApproval);
+  }
+
+  @Test
+  public void copiedApprovals_withRealUserAndTag_keepsUUID() throws Exception {
+    ImmutableList<String> strangeTags =
+        ImmutableList.of(", ", ":\"", ",", "!@#$%^\0&*):\" \n: \r\"#$@,. :");
+    for (String strangeTag : strangeTags) {
+      Change c = newChange();
+      CurrentUser otherUserAsOwner = userFactory.runAs(null, changeOwner.getAccountId(), otherUser);
+      ChangeUpdate update = newUpdate(c, otherUserAsOwner);
+      update.putApproval(LabelId.CODE_REVIEW, (short) 2);
+      update.setTag(strangeTag);
+      update.commit();
+      ChangeNotes notes = newNotes(c);
+      PatchSetApproval originalPsa =
+          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+      assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
+      assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+      assertThat(originalPsa.value()).isEqualTo(2);
+      assertThat(originalPsa.tag()).hasValue(NoteDbUtil.sanitizeFooter(strangeTag));
+      assertThat(originalPsa.realAccountId()).isEqualTo(otherUserId);
+      assertParsedUuid(originalPsa);
+
+      // Copied approvals are persisted at the patch set upload, add new patch set
+      incrementPatchSet(c);
+
+      addCopiedApproval(c, changeOwner, originalPsa);
+
+      notes = newNotes(c);
+      assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+      PatchSetApproval copiedApproval =
+          Iterables.getOnlyElement(
+              notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+                  .filter(a -> a.copied())
+                  .collect(toImmutableList()));
+      PatchSetApproval nonCopiedApproval =
+          Iterables.getOnlyElement(
+              notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+                  .filter(a -> !a.copied())
+                  .collect(toImmutableList()));
+
+      // Still same original PSA is returned
+      assertThat(nonCopiedApproval).isEqualTo(originalPsa);
+
+      // The copied approval matches the original approval, including UUID
+      assertCopiedApproval(originalPsa, copiedApproval);
+    }
+  }
+
+  @Test
+  public void copiedApprovals_withTag_keepsUUID() throws Exception {
+    ImmutableList<String> strangeTags =
+        ImmutableList.of(", ", ":\"", ",", "!@#$%^\0&*):\" \n: \r\"#$@,. :");
+    for (String strangeTag : strangeTags) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.putApproval(LabelId.CODE_REVIEW, (short) 2);
+      update.setTag(strangeTag);
+      update.commit();
+
+      ChangeNotes notes = newNotes(c);
+      PatchSetApproval originalPsa =
+          Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+      assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
+      assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+      assertThat(originalPsa.value()).isEqualTo(2);
+      assertThat(originalPsa.tag()).hasValue(NoteDbUtil.sanitizeFooter(strangeTag));
+      assertThat(originalPsa.realAccountId()).isEqualTo(changeOwner.getAccountId());
+      assertParsedUuid(originalPsa);
+      // Copied approvals are persisted at the patch set upload, add new patch set
+      incrementPatchSet(c);
+
+      addCopiedApproval(c, changeOwner, originalPsa);
+
+      notes = newNotes(c);
+      assertThat(notes.getApprovalsWithCopied().keySet()).hasSize(2);
+      PatchSetApproval copiedApproval =
+          Iterables.getOnlyElement(
+              notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+                  .filter(a -> a.copied())
+                  .collect(toImmutableList()));
+      PatchSetApproval nonCopiedApproval =
+          Iterables.getOnlyElement(
+              notes.getApprovalsWithCopied().get(originalPsa.patchSetId()).stream()
+                  .filter(a -> !a.copied())
+                  .collect(toImmutableList()));
+
+      // Still same original PSA is returned
+      assertThat(nonCopiedApproval).isEqualTo(originalPsa);
+
+      // The copied approval matches the original approval, including UUID
+      assertCopiedApproval(originalPsa, copiedApproval);
+    }
   }
 
   @Test
@@ -526,7 +922,7 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .tag("tag")
             .realAccountId(otherUserId)
             .build());
@@ -578,7 +974,7 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .build());
     update.commit();
 
@@ -611,7 +1007,7 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .tag(strangeTag)
             .build());
     update.commit();
@@ -640,7 +1036,7 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .build());
     update.putCopiedApproval(
         PatchSetApproval.builder()
@@ -651,7 +1047,7 @@
                     LabelId.create(LabelId.VERIFIED)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .build());
     update.commit();
 
@@ -676,7 +1072,7 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(2)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .build());
     update.commit();
 
@@ -700,11 +1096,11 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    Instant ts = update.getWhen();
     assertThat(notes.getReviewers())
         .isEqualTo(
             ReviewerSet.fromTable(
-                ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
+                ImmutableTable.<ReviewerStateInternal, Account.Id, Instant>builder()
                     .put(REVIEWER, Account.id(1), ts)
                     .put(REVIEWER, Account.id(2), ts)
                     .build()));
@@ -719,11 +1115,11 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    Instant ts = update.getWhen();
     assertThat(notes.getReviewers())
         .isEqualTo(
             ReviewerSet.fromTable(
-                ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
+                ImmutableTable.<ReviewerStateInternal, Account.Id, Instant>builder()
                     .put(REVIEWER, Account.id(1), ts)
                     .put(CC, Account.id(2), ts)
                     .build()));
@@ -737,7 +1133,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    Instant ts = update.getWhen();
     assertThat(notes.getReviewers())
         .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(REVIEWER, Account.id(2), ts)));
 
@@ -746,7 +1142,7 @@
     update.commit();
 
     notes = newNotes(c);
-    ts = new Timestamp(update.getWhen().getTime());
+    ts = update.getWhen();
     assertThat(notes.getReviewers())
         .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(CC, Account.id(2), ts)));
   }
@@ -881,7 +1277,7 @@
     update.commit();
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getMergedOn()).isPresent();
-    Timestamp mergedOn = notes.getMergedOn().get();
+    Instant mergedOn = notes.getMergedOn().get();
     assertThat(mergedOn).isEqualTo(notes.getChange().getLastUpdatedOn());
 
     // Next update does not change mergedOn date.
@@ -907,7 +1303,7 @@
     update.commit();
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getMergedOn()).isPresent();
-    Timestamp mergedOn = notes.getMergedOn().get();
+    Instant mergedOn = notes.getMergedOn().get();
     assertThat(mergedOn).isEqualTo(notes.getChange().getLastUpdatedOn());
 
     incrementPatchSet(c);
@@ -1301,7 +1697,7 @@
   public void createdOnChangeNotes() throws Exception {
     Change c = newChange();
 
-    Timestamp createdOn = newNotes(c).getChange().getCreatedOn();
+    Instant createdOn = newNotes(c).getChange().getCreatedOn();
     assertThat(createdOn).isNotNull();
 
     // An update doesn't affect the createdOn timestamp.
@@ -1316,54 +1712,54 @@
     Change c = newChange();
 
     ChangeNotes notes = newNotes(c);
-    Timestamp ts1 = notes.getChange().getLastUpdatedOn();
+    Instant ts1 = notes.getChange().getLastUpdatedOn();
     assertThat(ts1).isEqualTo(notes.getChange().getCreatedOn());
 
     // Various kinds of updates that update the timestamp.
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setTopic("topic"); // Change something to get a new commit.
     update.commit();
-    Timestamp ts2 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts2 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts2).isGreaterThan(ts1);
 
     update = newUpdate(c, changeOwner);
     update.setChangeMessage("Some message");
     update.commit();
-    Timestamp ts3 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts3 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts3).isGreaterThan(ts2);
 
     update = newUpdate(c, changeOwner);
     update.setHashtags(ImmutableSet.of("foo"));
     update.commit();
-    Timestamp ts4 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts4 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts4).isGreaterThan(ts3);
 
     incrementPatchSet(c);
-    Timestamp ts5 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts5 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts5).isGreaterThan(ts4);
 
     update = newUpdate(c, changeOwner);
     update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
-    Timestamp ts6 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts6 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts6).isGreaterThan(ts5);
 
     update = newUpdate(c, changeOwner);
     update.setStatus(Change.Status.ABANDONED);
     update.commit();
-    Timestamp ts7 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts7 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts7).isGreaterThan(ts6);
 
     update = newUpdate(c, changeOwner);
     update.putReviewer(otherUser.getAccountId(), ReviewerStateInternal.REVIEWER);
     update.commit();
-    Timestamp ts8 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts8 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts8).isGreaterThan(ts7);
 
     update = newUpdate(c, changeOwner);
     update.setGroups(ImmutableList.of("a", "b"));
     update.commit();
-    Timestamp ts9 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts9 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts9).isGreaterThan(ts8);
 
     // Finish off by merging the change.
@@ -1377,7 +1773,7 @@
                 submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
-    Timestamp ts10 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts10 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts10).isGreaterThan(ts9);
   }
 
@@ -1490,7 +1886,7 @@
             1,
             changeOwner,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "Comment",
             (short) 1,
             commit,
@@ -1579,7 +1975,7 @@
     // comment on ps2
     update = newUpdate(c, changeOwner);
     update.setPatchSetId(psId2);
-    Timestamp ts = TimeUtil.nowTs();
+    Instant ts = TimeUtil.now();
     update.putComment(
         HumanComment.Status.PUBLISHED,
         newComment(
@@ -1647,7 +2043,7 @@
     String uuid1 = "uuid1";
     String message1 = "comment 1";
     CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
+    Instant time1 = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
     RevCommit tipCommit;
     try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
@@ -1896,7 +2292,7 @@
             0,
             otherUser,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "message",
             (short) 1,
             commitId,
@@ -1926,7 +2322,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "message",
             (short) 1,
             commitId,
@@ -1956,7 +2352,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "message",
             (short) 1,
             commitId,
@@ -1986,7 +2382,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "message",
             (short) 1,
             commitId,
@@ -2013,7 +2409,7 @@
     String message3 = "comment 3";
     CommentRange range1 = new CommentRange(1, 1, 2, 1);
     CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    Timestamp time = TimeUtil.nowTs();
+    Instant time = TimeUtil.now();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     HumanComment comment1 =
@@ -2083,7 +2479,7 @@
     String uuid = "uuid";
     String message = "comment";
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    Timestamp time = TimeUtil.nowTs();
+    Instant time = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
@@ -2113,7 +2509,7 @@
 
   @Test
   public void patchLineCommentNotesFormatWeirdUser() throws Exception {
-    Account.Builder account = Account.builder(Account.id(3), TimeUtil.nowTs());
+    Account.Builder account = Account.builder(Account.id(3), TimeUtil.now());
     account.setFullName("Weird\n\u0002<User>\n");
     account.setPreferredEmail(" we\r\nird@ex>ample<.com");
     accountCache.put(account.build());
@@ -2123,7 +2519,7 @@
     ChangeUpdate update = newUpdate(c, user);
     String uuid = "uuid";
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    Timestamp time = TimeUtil.nowTs();
+    Instant time = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     HumanComment comment =
@@ -2161,7 +2557,7 @@
     String messageForBase = "comment for base";
     String messageForPS = "comment for ps";
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     HumanComment commentForBase =
@@ -2220,8 +2616,8 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp timeForComment1 = TimeUtil.nowTs();
-    Timestamp timeForComment2 = TimeUtil.nowTs();
+    Instant timeForComment1 = TimeUtil.now();
+    Instant timeForComment2 = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             psId,
@@ -2279,7 +2675,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             psId,
@@ -2337,7 +2733,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2360,7 +2756,7 @@
     PatchSet.Id ps2 = c.currentPatchSetId();
 
     update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
+    now = TimeUtil.now();
     HumanComment comment2 =
         newComment(
             ps2,
@@ -2397,7 +2793,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2442,7 +2838,7 @@
     CommentRange range2 = new CommentRange(2, 2, 3, 3);
     String filename = "filename1";
     short side = (short) 1;
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     // Write two drafts on the same side of one patch set.
@@ -2512,7 +2908,7 @@
     CommentRange range1 = new CommentRange(1, 1, 2, 2);
     CommentRange range2 = new CommentRange(2, 2, 3, 3);
     String filename = "filename1";
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     // Write two drafts, one on each side of the patchset.
@@ -2587,7 +2983,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment =
         newComment(
             psId,
@@ -2632,7 +3028,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2655,7 +3051,7 @@
     PatchSet.Id ps2 = c.currentPatchSetId();
 
     update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
+    now = TimeUtil.now();
     HumanComment comment2 =
         newComment(
             ps2,
@@ -2700,7 +3096,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment =
         newComment(
             ps1,
@@ -2733,7 +3129,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment draft =
         newComment(
             ps1,
@@ -2783,7 +3179,7 @@
     String uuid = "uuid";
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     String messageForBase = "comment for base";
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     HumanComment comment =
@@ -2815,7 +3211,7 @@
     String uuid = "uuid";
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     String messageForBase = "comment for base";
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     HumanComment comment =
@@ -2856,7 +3252,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2914,7 +3310,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2988,7 +3384,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -3075,7 +3471,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            new Timestamp(update1.getWhen().getTime()),
+            update1.getWhen(),
             "comment 1",
             (short) 1,
             commitId,
@@ -3092,7 +3488,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            new Timestamp(update2.getWhen().getTime()),
+            update2.getWhen(),
             "comment 2",
             (short) 1,
             commitId,
@@ -3149,7 +3545,7 @@
             range.getEndLine(),
             changeOwner,
             null,
-            new Timestamp(update.getWhen().getTime()),
+            update.getWhen(),
             "comment",
             (short) 1,
             ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
@@ -3585,11 +3981,45 @@
   }
 
   private AttentionSetUpdate addTimestamp(AttentionSetUpdate attentionSetUpdate, Change c) {
-    Timestamp timestamp = newNotes(c).getChange().getLastUpdatedOn();
+    Instant timestamp = newNotes(c).getChange().getLastUpdatedOn();
     return AttentionSetUpdate.createFromRead(
-        timestamp.toInstant(),
+        timestamp,
         attentionSetUpdate.account(),
         attentionSetUpdate.operation(),
         attentionSetUpdate.reason());
   }
+
+  /**
+   * Assert UUID was parsed as generated by {@link
+   * com.google.gerrit.server.approval.testing.TestPatchSetApprovalUuidGenerator}.
+   */
+  private void assertParsedUuid(PatchSetApproval patchSetApproval) {
+    assertThat(patchSetApproval.uuid().get().get()).matches("^[0-9a-z_]+$");
+  }
+
+  private void assertCopiedApproval(PatchSetApproval originalPsa, PatchSetApproval copiedPsa) {
+    assertThat(copiedPsa.label()).isEqualTo(originalPsa.label());
+    assertThat(copiedPsa.value()).isEqualTo(originalPsa.value());
+    assertThat(copiedPsa.tag()).isEqualTo(originalPsa.tag());
+    assertThat(copiedPsa.accountId()).isEqualTo(originalPsa.accountId());
+    assertThat(copiedPsa.realAccountId()).isEqualTo(originalPsa.realAccountId());
+    assertThat(copiedPsa.uuid()).isEqualTo(originalPsa.uuid());
+    assertThat(copiedPsa.copied()).isTrue();
+  }
+
+  private void addCopiedApproval(Change c, CurrentUser user, PatchSetApproval originalPsa)
+      throws Exception {
+    ChangeUpdate update = newUpdate(c, user);
+    update.putCopiedApproval(
+        PatchSetApproval.builder()
+            .key(originalPsa.key())
+            .value(originalPsa.value())
+            .copied(true)
+            .granted(TimeUtil.now())
+            .tag(originalPsa.tag())
+            .uuid(originalPsa.uuid())
+            .realAccountId(originalPsa.realAccountId())
+            .build());
+    update.commit();
+  }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
index fa05adc..cf1b5ae 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
@@ -81,7 +81,7 @@
             1,
             changeOwner,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "Comment",
             (short) 1,
             commit,
diff --git a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
index 6080d98..2191f00 100644
--- a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
@@ -155,7 +155,7 @@
         new HumanComment(
             new Comment.Key("uuid", "filename", 1),
             Account.id(100),
-            NON_DST_TS,
+            NON_DST_TS.toInstant(),
             (short) 0,
             "message",
             "serverId",
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 68a1d9d..9d02067 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -27,8 +27,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
-import java.util.Date;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -61,21 +60,22 @@
             + "\n"
             + "Reviewer: Gerrit User 1 <1@gerrit>\n"
             + "CC: Gerrit User 2 <2@gerrit>\n"
-            + "Label: Code-Review=-1\n"
-            + "Label: Verified=+1\n",
+            + "Label: Code-Review=-1, 1_1_1_code_review__1_1\n"
+            + "Label: Verified=+1, 1_1_1_verified_1_2\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 1000));
-    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+    assertThat(author.getWhenAsInstant().toEpochMilli())
+        .isEqualTo(c.getCreatedOn().toEpochMilli() + 1000);
+    assertThat(author.getZoneId()).isEqualTo(ZoneId.of("GMT-7"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
     assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen()).isEqualTo(author.getWhen());
-    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
+    assertThat(committer.getWhenAsInstant()).isEqualTo(author.getWhenAsInstant());
+    assertThat(committer.getZoneId()).isEqualTo(author.getZoneId());
   }
 
   @Test
@@ -184,20 +184,21 @@
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 2000));
-    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+    assertThat(author.getWhenAsInstant().toEpochMilli())
+        .isEqualTo(c.getCreatedOn().toEpochMilli() + 2000);
+    assertThat(author.getZoneId()).isEqualTo(ZoneId.of("GMT-7"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
     assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen()).isEqualTo(author.getWhen());
-    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
+    assertThat(committer.getWhenAsInstant()).isEqualTo(author.getWhenAsInstant());
+    assertThat(committer.getZoneId()).isEqualTo(author.getZoneId());
   }
 
   @Test
   public void anonymousUser() throws Exception {
     Account anon =
-        Account.builder(Account.id(3), TimeUtil.nowTs())
+        Account.builder(Account.id(3), TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build();
     accountCache.put(anon);
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 98721fd..b3f9475 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -47,10 +47,12 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gson.Gson;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.IntStream;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
@@ -261,6 +263,7 @@
       Ref metaRefBeforeRewrite = repo.exactRef(refName);
       expectedSkippedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
     }
+    Set<String> invalidRefs = new HashSet<>();
     for (int i = 0; i < numberOfInvalidChanges; i++) {
       Change c = newChange();
       ChangeUpdate update = newUpdate(c, changeOwner);
@@ -270,12 +273,20 @@
       updateWithSubject.setSubjectForCommit("Update with subject");
       updateWithSubject.commit();
       String refName = RefNames.changeMetaRef(c.getId());
-      Ref metaRefBeforeRewrite = repo.exactRef(refName);
-      if (i < maxRefsToUpdate) {
-        expectedFixedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
-      } else {
-        expectedSkippedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
+      invalidRefs.add(refName);
+    }
+    int i = 0;
+    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
+      Ref metaRefBeforeRewrite = repo.exactRef(ref.getName());
+      if (!invalidRefs.contains(ref.getName())) {
+        continue;
       }
+      if (i < maxRefsToUpdate) {
+        expectedFixedRefsToOldMetaBuilder.put(ref.getName(), metaRefBeforeRewrite.getObjectId());
+      } else {
+        expectedSkippedRefsToOldMetaBuilder.put(ref.getName(), metaRefBeforeRewrite.getObjectId());
+      }
+      i++;
     }
     ImmutableMap<String, ObjectId> expectedFixedRefsToOldMeta =
         expectedFixedRefsToOldMetaBuilder.build();
@@ -312,13 +323,13 @@
   @Test
   public void fixAuthorIdent() throws Exception {
     Change c = newChange();
-    Timestamp when = TimeUtil.nowTs();
+    Instant when = TimeUtil.now();
     PersonIdent invalidAuthorIdent =
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
             when,
-            serverIdent.getTimeZone());
+            serverIdent.getZoneId());
     RevCommit invalidUpdateCommit =
         writeUpdate(
             RefNames.changeMetaRef(c.getId()),
@@ -359,7 +370,8 @@
     assertThat(fixedUpdateCommit.getAuthorIdent().getName())
         .isEqualTo("Gerrit User " + changeOwner.getAccountId());
     assertThat(originalAuthorIdent.getEmailAddress()).isEqualTo(fixedAuthorIdent.getEmailAddress());
-    assertThat(originalAuthorIdent.getWhen()).isEqualTo(fixedAuthorIdent.getWhen());
+    assertThat(originalAuthorIdent.getWhenAsInstant())
+        .isEqualTo(fixedAuthorIdent.getWhenAsInstant());
     assertThat(originalAuthorIdent.getTimeZone()).isEqualTo(fixedAuthorIdent.getTimeZone());
     assertThat(invalidUpdateCommit.getFullMessage()).isEqualTo(fixedUpdateCommit.getFullMessage());
     assertThat(invalidUpdateCommit.getCommitterIdent())
@@ -483,7 +495,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
             ReviewerStatusUpdate.create(
@@ -567,21 +579,15 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
             ReviewerStatusUpdate.create(
-                new Timestamp(addReviewerUpdate.when.getTime()),
-                changeOwner.getAccountId(),
-                otherUserId,
-                REVIEWER),
+                addReviewerUpdate.when, changeOwner.getAccountId(), otherUserId, REVIEWER),
             ReviewerStatusUpdate.create(
                 updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED),
             ReviewerStatusUpdate.create(
-                new Timestamp(addCcUpdate.when.getTime()),
-                changeOwner.getAccountId(),
-                otherUserId,
-                CC),
+                addCcUpdate.when, changeOwner.getAccountId(), otherUserId, CC),
             ReviewerStatusUpdate.create(
                 updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED));
     ChangeNotes notesAfterRewrite = newNotes(c);
@@ -703,7 +709,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<PatchSetApproval> expectedApprovals =
         ImmutableList.of(
             PatchSetApproval.builder()
@@ -840,7 +846,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<PatchSetApproval> expectedApprovals =
         ImmutableList.of(
             PatchSetApproval.builder()
@@ -915,10 +921,7 @@
     Change c = newChange();
     PersonIdent invalidAuthorIdent =
         new PersonIdent(
-            changeOwner.getName(),
-            "server@" + serverId,
-            TimeUtil.nowTs(),
-            serverIdent.getTimeZone());
+            changeOwner.getName(), "server@" + serverId, TimeUtil.now(), serverIdent.getZoneId());
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
         getChangeUpdateBody(
@@ -1053,7 +1056,7 @@
   public void fixRemoveVoteChangeMessageWithNoFooterLabel_matchDuplicateAccounts()
       throws Exception {
     Account duplicateCodeOwner =
-        Account.builder(Account.id(4), TimeUtil.nowTs())
+        Account.builder(Account.id(4), TimeUtil.now())
             .setFullName(changeOwner.getName())
             .setPreferredEmail("other@test.com")
             .build();
@@ -1243,46 +1246,46 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
     notesBeforeRewrite.getAttentionSetUpdates();
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<AttentionSetUpdate> attentionSetUpdatesBeforeRewrite =
         ImmutableList.of(
             AttentionSetUpdate.createFromRead(
-                invalidRemovedByClickUpdate.getWhen().toInstant(),
+                invalidRemovedByClickUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.REMOVE,
                 String.format("Removed by %s by clicking the attention icon", otherUser.getName())),
             AttentionSetUpdate.createFromRead(
-                validAttentionSetUpdate.getWhen().toInstant(),
+                validAttentionSetUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.ADD,
                 "Added by someone"),
             AttentionSetUpdate.createFromRead(
-                validAttentionSetUpdate.getWhen().toInstant(),
+                validAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.REMOVE,
                 "Removed by someone"),
             AttentionSetUpdate.createFromRead(
-                updateTimestamp.toInstant(),
+                updateTimestamp,
                 changeOwner.getAccountId(),
                 Operation.REMOVE,
                 String.format("%s replied on the change", otherUser.getName())),
             AttentionSetUpdate.createFromRead(
-                updateTimestamp.toInstant(),
+                updateTimestamp,
                 otherUserId,
                 Operation.ADD,
                 "Added by someone using the hovercard menu"),
             AttentionSetUpdate.createFromRead(
-                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                invalidMultipleAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.REMOVE,
                 String.format("Removed by %s using the hovercard menu", otherUser.getName())),
             AttentionSetUpdate.createFromRead(
-                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                invalidMultipleAttentionSetUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.ADD,
                 String.format("%s replied on the change", otherUser.getName())),
             AttentionSetUpdate.createFromRead(
-                invalidAttentionSetUpdate.getWhen().toInstant(),
+                invalidAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.ADD,
                 String.format("Added by %s using the hovercard menu", otherUser.getName())));
@@ -1290,42 +1293,42 @@
     ImmutableList<AttentionSetUpdate> attentionSetUpdatesAfterRewrite =
         ImmutableList.of(
             AttentionSetUpdate.createFromRead(
-                invalidRemovedByClickUpdate.getWhen().toInstant(),
+                invalidRemovedByClickUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.REMOVE,
                 "Removed by someone by clicking the attention icon"),
             AttentionSetUpdate.createFromRead(
-                validAttentionSetUpdate.getWhen().toInstant(),
+                validAttentionSetUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.ADD,
                 "Added by someone"),
             AttentionSetUpdate.createFromRead(
-                validAttentionSetUpdate.getWhen().toInstant(),
+                validAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.REMOVE,
                 "Removed by someone"),
             AttentionSetUpdate.createFromRead(
-                updateTimestamp.toInstant(),
+                updateTimestamp,
                 changeOwner.getAccountId(),
                 Operation.REMOVE,
                 "Someone replied on the change"),
             AttentionSetUpdate.createFromRead(
-                updateTimestamp.toInstant(),
+                updateTimestamp,
                 otherUserId,
                 Operation.ADD,
                 "Added by someone using the hovercard menu"),
             AttentionSetUpdate.createFromRead(
-                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                invalidMultipleAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.REMOVE,
                 "Removed by someone using the hovercard menu"),
             AttentionSetUpdate.createFromRead(
-                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                invalidMultipleAttentionSetUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.ADD,
                 "Someone replied on the change"),
             AttentionSetUpdate.createFromRead(
-                invalidAttentionSetUpdate.getWhen().toInstant(),
+                invalidAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.ADD,
                 "Added by someone using the hovercard menu"));
@@ -1420,22 +1423,22 @@
       thirdAttentionSetUpdate.commit();
       attentionSetUpdatesBeforeRewrite.add(
           AttentionSetUpdate.createFromRead(
-              thirdAttentionSetUpdate.getWhen().toInstant(),
+              thirdAttentionSetUpdate.getWhen(),
               changeOwner.getAccountId(),
               Operation.REMOVE,
               String.format("Removed by %s by clicking the attention icon", okAccountName)),
           AttentionSetUpdate.createFromRead(
-              secondAttentionSetUpdate.getWhen().toInstant(),
+              secondAttentionSetUpdate.getWhen(),
               otherUserId,
               Operation.REMOVE,
               String.format("Removed by %s using the hovercard menu", okAccountName)),
           AttentionSetUpdate.createFromRead(
-              secondAttentionSetUpdate.getWhen().toInstant(),
+              secondAttentionSetUpdate.getWhen(),
               changeOwner.getAccountId(),
               Operation.ADD,
               String.format("%s replied on the change", okAccountName)),
           AttentionSetUpdate.createFromRead(
-              firstAttentionSetUpdate.getWhen().toInstant(),
+              firstAttentionSetUpdate.getWhen(),
               otherUserId,
               Operation.ADD,
               String.format("Added by %s using the hovercard menu", okAccountName)));
@@ -1548,8 +1551,8 @@
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            TimeUtil.nowTs(),
-            serverIdent.getTimeZone());
+            TimeUtil.now(),
+            serverIdent.getZoneId());
     String changeOwnerIdentToFix = getAccountIdentToFix(changeOwner.getAccount());
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
@@ -1744,16 +1747,16 @@
   public void fixCodeOwnersOnAddReviewerChangeMessage() throws Exception {
 
     Account reviewer =
-        Account.builder(Account.id(3), TimeUtil.nowTs())
+        Account.builder(Account.id(3), TimeUtil.now())
             .setFullName("Reviewer User")
             .setPreferredEmail("reviewer@account.com")
             .build();
     accountCache.put(reviewer);
     Account duplicateCodeOwner =
-        Account.builder(Account.id(4), TimeUtil.nowTs()).setFullName(changeOwner.getName()).build();
+        Account.builder(Account.id(4), TimeUtil.now()).setFullName(changeOwner.getName()).build();
     accountCache.put(duplicateCodeOwner);
     Account duplicateReviewer =
-        Account.builder(Account.id(5), TimeUtil.nowTs()).setFullName(reviewer.getName()).build();
+        Account.builder(Account.id(5), TimeUtil.now()).setFullName(reviewer.getName()).build();
     accountCache.put(duplicateReviewer);
     Change c = newChange();
     ImmutableList.Builder<ObjectId> commitsToFix = new ImmutableList.Builder<>();
@@ -2211,7 +2214,7 @@
         getChangeUpdateBody(c, "Assignee deleted: " + otherUser.getName()),
         getAuthorIdent(changeOwner.getAccount()));
     Account reviewer =
-        Account.builder(Account.id(3), TimeUtil.nowTs())
+        Account.builder(Account.id(3), TimeUtil.now())
             .setFullName("Reviewer User")
             .setPreferredEmail("reviewer@account.com")
             .build();
@@ -2253,14 +2256,14 @@
   @Test
   public void singleRunFixesAll() throws Exception {
     Change c = newChange();
-    Timestamp when = TimeUtil.nowTs();
+    Instant when = TimeUtil.now();
     String assigneeIdentToFix = getAccountIdentToFix(otherUser.getAccount());
     PersonIdent authorIdentToFix =
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
             when,
-            serverIdent.getTimeZone());
+            serverIdent.getZoneId());
 
     RevCommit invalidUpdateCommit =
         writeUpdate(
@@ -2416,7 +2419,6 @@
   }
 
   private PersonIdent getAuthorIdent(Account account) {
-    Timestamp when = TimeUtil.nowTs();
-    return changeNoteUtil.newAccountIdIdent(account.id(), when, serverIdent);
+    return changeNoteUtil.newAccountIdIdent(account.id(), TimeUtil.now(), serverIdent);
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
index 041366c..31b1db0 100644
--- a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
@@ -89,7 +89,7 @@
         0,
         otherUser,
         null,
-        TimeUtil.nowTs(),
+        TimeUtil.now(),
         "comment",
         (short) 0,
         ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index d47afb0..95035ed 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -17,12 +17,15 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffOperationsTest.FileEntity.FileType;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
@@ -30,13 +33,16 @@
 import com.google.inject.Injector;
 import java.io.IOException;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
 import org.eclipse.jgit.lib.TreeFormatter;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
@@ -64,12 +70,15 @@
 
   @Test
   public void diffModifiedFileAgainstParent() throws Exception {
-    ImmutableMap<String, String> oldFiles =
-        ImmutableMap.of(fileName1, fileContent1, fileName2, fileContent2);
+    ImmutableList<FileEntity> oldFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1), new FileEntity(fileName2, fileContent2));
     ObjectId oldCommitId = createCommit(repo, null, oldFiles);
 
-    ImmutableMap<String, String> newFiles =
-        ImmutableMap.of(fileName1, fileContent1, fileName2, fileContent2 + "\nnew line here");
+    ImmutableList<FileEntity> newFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1),
+            new FileEntity(fileName2, fileContent2 + "\nnew line here"));
     ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
 
     FileDiffOutput diffOutput =
@@ -84,19 +93,18 @@
 
   @Test
   public void diffAgainstAutoMergePersistsAutoMergeInRepo() throws Exception {
-    ObjectId parent1 = createCommit(repo, null, ImmutableMap.of("file_1.txt", "file 1 content"));
-    ObjectId parent2 = createCommit(repo, null, ImmutableMap.of("file_2.txt", "file 2 content"));
+    ObjectId parent1 =
+        createCommit(repo, null, ImmutableList.of(new FileEntity("file_1.txt", "file 1 content")));
+    ObjectId parent2 =
+        createCommit(repo, null, ImmutableList.of(new FileEntity("file_2.txt", "file 2 content")));
 
     ObjectId merge =
         createMergeCommit(
             repo,
-            ImmutableMap.of(
-                "file_1.txt",
-                "file 1 content",
-                "file_2.txt",
-                "file 2 content",
-                "file_3.txt",
-                "file 3 content"),
+            ImmutableList.of(
+                new FileEntity("file_1.txt", "file 1 content"),
+                new FileEntity("file_2.txt", "file 2 content"),
+                new FileEntity("file_3.txt", "file 3 content")),
             parent1,
             parent2);
 
@@ -104,29 +112,145 @@
     assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNull();
 
     Map<String, FileDiffOutput> changedFiles =
-        diffOperations.listModifiedFilesAgainstParent(testProjectName, merge, /* parentNum=*/ 0);
+        diffOperations.listModifiedFilesAgainstParent(
+            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();
   }
 
+  @Test
+  public void loadModifiedFiles() throws Exception {
+    ImmutableList<FileEntity> oldFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1), new FileEntity(fileName2, fileContent2));
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    ImmutableList<FileEntity> newFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1),
+            new FileEntity(fileName2, fileContent2 + "\nnew line here"));
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    Repository repository = repoManager.openRepository(testProjectName);
+    ObjectReader objectReader = repository.newObjectReader();
+    RevWalk rw = new RevWalk(objectReader);
+    StoredConfig repoConfig = repository.getConfig();
+
+    // This call loads modified files directly without going through the diff cache.
+    Map<String, ModifiedFile> modifiedFiles =
+        diffOperations.loadModifiedFiles(
+            testProjectName, newCommitId, oldCommitId, DiffOptions.DEFAULTS, rw, repoConfig);
+
+    assertThat(modifiedFiles)
+        .containsExactly(
+            fileName2,
+            ModifiedFile.builder()
+                .changeType(ChangeType.MODIFIED)
+                .oldPath(Optional.of(fileName2))
+                .newPath(Optional.of(fileName2))
+                .build());
+  }
+
+  @Test
+  public void loadModifiedFiles_withSymlinkConvertedToRegularFile() throws Exception {
+    // Commit 1: Create a regular fileName1 with fileContent1
+    ImmutableList<FileEntity> oldFiles = ImmutableList.of(new FileEntity(fileName1, fileContent1));
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    // Commit 2: Create a symlink with name FileName1 pointing to target file "target"
+    ImmutableList<FileEntity> newFiles =
+        ImmutableList.of(new FileEntity(fileName1, "target", FileType.SYMLINK));
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    Repository repository = repoManager.openRepository(testProjectName);
+    ObjectReader objectReader = repository.newObjectReader();
+
+    Map<String, ModifiedFile> modifiedFiles =
+        diffOperations.loadModifiedFiles(
+            testProjectName,
+            newCommitId,
+            oldCommitId,
+            DiffOptions.DEFAULTS,
+            new RevWalk(objectReader),
+            repository.getConfig());
+
+    assertThat(modifiedFiles)
+        .containsExactly(
+            fileName1,
+            ModifiedFile.builder()
+                .changeType(ChangeType.REWRITE)
+                .oldPath(Optional.empty())
+                .newPath(Optional.of(fileName1))
+                .build());
+  }
+
+  @Test
+  public void loadModifiedFilesAgainstParent() throws Exception {
+    ImmutableList<FileEntity> oldFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1), new FileEntity(fileName2, fileContent2));
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    ImmutableList<FileEntity> newFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1),
+            new FileEntity(fileName2, fileContent2 + "\nnew line here"));
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    Repository repository = repoManager.openRepository(testProjectName);
+    ObjectReader objectReader = repository.newObjectReader();
+    RevWalk rw = new RevWalk(objectReader);
+    StoredConfig repoConfig = repository.getConfig();
+
+    // This call loads modified files directly without going through the diff cache.
+    Map<String, ModifiedFile> modifiedFiles =
+        diffOperations.loadModifiedFilesAgainstParent(
+            testProjectName, newCommitId, /* parentNum=*/ 0, DiffOptions.DEFAULTS, rw, repoConfig);
+
+    assertThat(modifiedFiles)
+        .containsExactly(
+            fileName2,
+            ModifiedFile.builder()
+                .changeType(ChangeType.MODIFIED)
+                .oldPath(Optional.of(fileName2))
+                .newPath(Optional.of(fileName2))
+                .build());
+  }
+
+  static class FileEntity {
+    String name;
+    String content;
+    FileType type;
+
+    enum FileType {
+      REGULAR,
+      SYMLINK
+    }
+
+    FileEntity(String name, String content) {
+      this(name, content, FileType.REGULAR);
+    }
+
+    FileEntity(String name, String content, FileType type) {
+      this.name = name;
+      this.content = content;
+      this.type = type;
+    }
+  }
+
   private ObjectId createMergeCommit(
-      Repository repo,
-      ImmutableMap<String, String> fileNameToContent,
-      ObjectId parent1,
-      ObjectId parent2)
+      Repository repo, ImmutableList<FileEntity> fileEntities, ObjectId parent1, ObjectId parent2)
       throws IOException {
-    ObjectId treeId = createTree(repo, fileNameToContent);
+    ObjectId treeId = createTree(repo, fileEntities);
     return createCommitInRepo(repo, treeId, parent1, parent2);
   }
 
   private ObjectId createCommit(
-      Repository repo,
-      @Nullable ObjectId parentCommit,
-      ImmutableMap<String, String> fileNameToContent)
+      Repository repo, @Nullable ObjectId parentCommit, ImmutableList<FileEntity> fileEntities)
       throws IOException {
-    ObjectId treeId = createTree(repo, fileNameToContent);
+    ObjectId treeId = createTree(repo, fileEntities);
     return parentCommit == null
         ? createCommitInRepo(repo, treeId)
         : createCommitInRepo(repo, treeId, parentCommit);
@@ -136,7 +260,7 @@
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent committer =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(committer);
@@ -152,17 +276,21 @@
     }
   }
 
-  private static ObjectId createTree(
-      Repository repo, ImmutableMap<String, String> fileNameToContent) throws IOException {
+  private static ObjectId createTree(Repository repo, ImmutableList<FileEntity> fileEntities)
+      throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter();
         ObjectReader reader = repo.newObjectReader();
         RevWalk rw = new RevWalk(reader); ) {
       TreeFormatter formatter = new TreeFormatter();
-      for (Map.Entry<String, String> entry : fileNameToContent.entrySet()) {
-        String fileName = entry.getKey();
-        String fileContent = entry.getValue();
+      for (FileEntity fileEntity : fileEntities) {
+        String fileName = fileEntity.name;
+        String fileContent = fileEntity.content;
         ObjectId fileObjId = createBlob(repo, fileContent);
-        formatter.append(fileName, rw.lookupBlob(fileObjId));
+        if (fileEntity.type.equals(FileType.REGULAR)) {
+          formatter.append(fileName, rw.lookupBlob(fileObjId));
+        } else {
+          formatter.append(fileName, FileMode.SYMLINK, fileObjId);
+        }
       }
       ObjectId treeId = oi.insert(formatter);
       oi.flush();
diff --git a/javatests/com/google/gerrit/server/patch/MagicFileTest.java b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
index 93928f0..b0050b0 100644
--- a/javatests/com/google/gerrit/server/patch/MagicFileTest.java
+++ b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
@@ -22,9 +22,8 @@
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.Month;
+import java.time.ZoneId;
 import java.time.ZoneOffset;
-import java.util.Date;
-import java.util.TimeZone;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -104,20 +103,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       ObjectId commit =
           testRepo
@@ -155,20 +146,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       RevCommit parent =
           testRepo.commit().message("Parent subject\n\nParent further details.").create();
@@ -211,20 +194,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       RevCommit parent1 = testRepo.commit().message("Parent 1\n\nExplanation 1.").create();
       RevCommit parent2 = testRepo.commit().message("Parent 2\n\nExplanation 2.").create();
diff --git a/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java b/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java
index 55c9bc3..ca4872d 100644
--- a/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java
+++ b/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java
@@ -27,11 +27,11 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.annotation.Annotation;
-import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
 import java.util.jar.Manifest;
+import java.util.stream.Stream;
 import org.junit.Test;
 
 public class AutoRegisterModulesTest {
@@ -94,7 +94,7 @@
     }
 
     @Override
-    public Enumeration<PluginEntry> entries() {
+    public Stream<PluginEntry> entries() {
       return null;
     }
   }
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 9df59c2..26f7d60 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -20,6 +20,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.RuntimeVersion;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountsSection;
@@ -217,7 +218,7 @@
                 "[submit-requirement \"Code-review\"]\n"
                     + "  description =  At least one Code Review +2\n"
                     + "  applicableIf =branch(refs/heads/master)\n"
-                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "  submittableIf =  label(Code-Review, +2)\n"
                     + "[submit-requirement \"api-review\"]\n"
                     + "  description =  Additional review required for API modifications\n"
                     + "  applicableIf =commit_filepath_contains(\\\"/api/.*\\\")\n"
@@ -237,7 +238,7 @@
                 .setApplicabilityExpression(
                     SubmitRequirementExpression.of("branch(refs/heads/master)"))
                 .setSubmittabilityExpression(
-                    SubmitRequirementExpression.create("label(code-review, +2)"))
+                    SubmitRequirementExpression.create("label(Code-Review, +2)"))
                 .setOverrideExpression(Optional.empty())
                 .setAllowOverrideInChildProjects(false)
                 .build(),
@@ -262,19 +263,19 @@
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submit-requirement \"code-review\"]\n"
-                    + "  submittableIf =  label(code-review, +2)\n")
+                "[submit-requirement \"Code-Review\"]\n"
+                    + "  submittableIf =  label(Code-Review, +2)\n")
             .create();
 
     ProjectConfig cfg = read(rev);
     Map<String, SubmitRequirement> submitRequirements = cfg.getSubmitRequirementSections();
     assertThat(submitRequirements)
         .containsExactly(
-            "code-review",
+            "Code-Review",
             SubmitRequirement.builder()
-                .setName("code-review")
+                .setName("Code-Review")
                 .setSubmittabilityExpression(
-                    SubmitRequirementExpression.create("label(code-review, +2)"))
+                    SubmitRequirementExpression.create("label(Code-Review, +2)"))
                 .setAllowOverrideInChildProjects(false)
                 .build());
   }
@@ -320,8 +321,8 @@
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submit-requirement \"code-review\"]\n"
-                    + "  applicableIf =label(code-review, +2)\n")
+                "[submit-requirement \"Code-Review\"]\n"
+                    + "  applicableIf =label(Code-Review, +2)\n")
             .create();
 
     ProjectConfig cfg = read(rev);
@@ -330,8 +331,9 @@
     assertThat(cfg.getValidationErrors()).hasSize(1);
     assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
         .isEqualTo(
-            "project.config: Submit requirement 'code-review' does not define a submittability"
-                + " expression.");
+            "project.config: Setting a submittability expression for submit requirement"
+                + " 'Code-Review' is required: Missing"
+                + " submit-requirement.Code-Review.submittableIf");
   }
 
   @Test
@@ -395,6 +397,31 @@
   }
 
   @Test
+  public void readConfigLabelInvalidBranchPattern() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[label \"CustomLabel\"]\n"
+                    + "  value = -1 Negative\n"
+                    + "  value = 0 No Score\n"
+                    + "  value =  1 Positive\n"
+                    + "  branch = ^***\n"
+                    + "  defaultValue = 0\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    assertThat(cfg.getValidationErrors()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
+        .isEqualTo(
+            "project.config: Invalid ref pattern \"^***\""
+                + " in label.CustomLabel.branch: Dangling meta character '*' near index 2\n"
+                + "^***\n"
+                + "  ^");
+  }
+
+  @Test
   public void readConfigLabelScores() throws Exception {
     RevCommit rev =
         tr.commit()
@@ -604,7 +631,7 @@
     PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
     assertThat(pluginCfg.getNames()).hasSize(2);
     assertThat(pluginCfg.getString("key1")).isEqualTo("value1");
-    assertThat(pluginCfg.getStringList(("key2"))).isEqualTo(new String[] {"value2a", "value2b"});
+    assertThat(pluginCfg.getStringList("key2")).isEqualTo(new String[] {"value2a", "value2b"});
   }
 
   @Test
@@ -729,6 +756,7 @@
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
         .containsExactly(StoredCommentLinkInfo.enabled("bugzilla"));
+    assertThat(Iterables.getOnlyElement(cfg.getCommentLinkSections()).getEnabled()).isNull();
   }
 
   @Test
@@ -740,6 +768,7 @@
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
         .containsExactly(StoredCommentLinkInfo.disabled("bugzilla"));
+    assertThat(Iterables.getOnlyElement(cfg.getCommentLinkSections()).getEnabled()).isFalse();
   }
 
   @Test
@@ -775,13 +804,19 @@
             .create();
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections()).isEmpty();
+
+    boolean atLeastJava17 = RuntimeVersion.isAtLeast17();
     assertThat(cfg.getValidationErrors())
         .containsExactly(
             ValidationError.create(
                 "project.config: Invalid pattern \"(bugs{+#?)(d+)\" in commentlink.bugzilla.match: "
-                    + "Illegal repetition near index 4\n"
+                    + "Illegal repetition near index "
+                    + (atLeastJava17 ? "6" : "4")
+                    + "\n"
                     + "(bugs{+#?)(d+)\n"
-                    + "    ^"));
+                    + "    "
+                    + (atLeastJava17 ? "  " : "")
+                    + "^"));
   }
 
   @Test
@@ -952,10 +987,10 @@
         tr.commit()
             .add(
                 "project.config",
-                "[submit-requirement \"code-review\"]\n"
+                "[submit-requirement \"Code-Review\"]\n"
                     + "  description =  At least one Code Review +2\n"
                     + "  applicableIf =branch(refs/heads/master)\n"
-                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "  submittableIf =  label(Code-Review, +2)\n"
                     + "[notify \"name\"]\n"
                     + "  email = example@example.com\n")
             .create();
@@ -1024,6 +1059,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 05eb6e0..b05f3c7 100644
--- a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.entities.SubmitRecord.Status;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.rules.DefaultSubmitRule;
 import java.util.Arrays;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
@@ -45,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();
 
@@ -55,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();
 
@@ -65,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();
 
@@ -75,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();
@@ -87,14 +88,15 @@
   public void defaultSubmitRule_withLabelsAllPass() {
     SubmitRecord submitRecord =
         createSubmitRecord(
-            "gerrit~DefaultSubmitRule",
+            DefaultSubmitRule.RULE_NAME,
             Status.OK,
             Arrays.asList(
                 createLabel("Code-Review", Label.Status.OK),
                 createLabel("Verified", Label.Status.OK)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(2);
     assertResult(
@@ -115,14 +117,15 @@
   public void defaultSubmitRule_withLabelsAllNeed() {
     SubmitRecord submitRecord =
         createSubmitRecord(
-            "gerrit~DefaultSubmitRule",
+            DefaultSubmitRule.RULE_NAME,
             Status.OK,
             Arrays.asList(
                 createLabel("Code-Review", Label.Status.NEED),
                 createLabel("Verified", Label.Status.NEED)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(2);
     assertResult(
@@ -140,15 +143,42 @@
   }
 
   @Test
+  public void defaultSubmitRule_withOneLabelForced() {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            DefaultSubmitRule.RULE_NAME,
+            Status.OK,
+            Arrays.asList(createLabel("Code-Review", Label.Status.NEED)));
+
+    // Submit records that are forced are written with their initial status in NoteDb, e.g. NEED.
+    // If we do a force submit, the gerrit server appends an extra marker record with status=FORCED
+    // to indicate that all other records were forced, that's why we explicitly pass isForced=true
+    // to the "submit requirements adapter". The resulting submit requirement result has a
+    // status=FORCED.
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ true);
+
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "Code-Review",
+        /* submitExpression= */ "label:Code-Review=MAX -label:Code-Review=MIN",
+        SubmitRequirementResult.Status.FORCED,
+        SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
   public void defaultSubmitRule_withLabelStatusNeed_labelHasIgnoreSelfApproval() throws Exception {
     SubmitRecord submitRecord =
         createSubmitRecord(
-            "gerrit~DefaultSubmitRule",
+            DefaultSubmitRule.RULE_NAME,
             Status.NOT_READY,
             Arrays.asList(createLabel("ISA-Label", Label.Status.NEED)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -163,12 +193,13 @@
   public void defaultSubmitRule_withLabelStatusOk_labelHasIgnoreSelfApproval() throws Exception {
     SubmitRecord submitRecord =
         createSubmitRecord(
-            "gerrit~DefaultSubmitRule",
+            DefaultSubmitRule.RULE_NAME,
             Status.OK,
             Arrays.asList(createLabel("ISA-Label", Label.Status.OK)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -180,12 +211,52 @@
   }
 
   @Test
+  public void defaultSubmitRule_withNonExistingLabel() throws Exception {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            DefaultSubmitRule.RULE_NAME,
+            Status.OK,
+            Arrays.asList(createLabel("Non-Existing", Label.Status.OK)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
+
+    assertThat(requirements).isEmpty();
+  }
+
+  @Test
+  public void defaultSubmitRule_withExistingAndNonExistingLabels() throws Exception {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            DefaultSubmitRule.RULE_NAME,
+            Status.OK,
+            Arrays.asList(
+                createLabel("Non-Existing", Label.Status.OK),
+                createLabel("Code-Review", Label.Status.OK)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
+
+    // The "Non-Existing" label was skipped since it does not exist in the project config.
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "Code-Review",
+        /* submitExpression= */ "label:Code-Review=MAX -label:Code-Review=MIN",
+        SubmitRequirementResult.Status.SATISFIED,
+        SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
   public void customSubmitRule_noLabels_withStatusOk() {
     SubmitRecord submitRecord =
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, Arrays.asList());
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -202,7 +273,8 @@
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, /* labels= */ null);
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -219,7 +291,8 @@
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.NOT_READY, Arrays.asList());
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -241,7 +314,8 @@
                 createLabel("custom-label-2", Label.Status.REJECT)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(2);
     assertResult(
@@ -269,7 +343,8 @@
                 createLabel("custom-label-2", Label.Status.REJECT)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(2);
     assertResult(
@@ -296,7 +371,7 @@
     assertThat(r.submitRequirement().submittabilityExpression().expressionString())
         .isEqualTo(submitExpression);
     assertThat(r.status()).isEqualTo(status);
-    assertThat(r.submittabilityExpressionResult().status()).isEqualTo(expressionStatus);
+    assertThat(r.submittabilityExpressionResult().get().status()).isEqualTo(expressionStatus);
   }
 
   private SubmitRecord createSubmitRecord(
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 d4c9aee..a820f86 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -26,6 +26,7 @@
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.HOURS;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
@@ -40,6 +41,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
 import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -49,6 +53,7 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
@@ -116,6 +121,7 @@
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -142,11 +148,11 @@
 import com.google.inject.Provider;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Iterator;
-import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
@@ -284,7 +290,10 @@
   @After
   public void resetTime() {
     TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
+    if (systemTimeZone != null) {
+      System.setProperty("user.timezone", systemTimeZone);
+      systemTimeZone = null;
+    }
   }
 
   @Test
@@ -347,8 +356,10 @@
     assertQuery("is:new", change1);
     assertQuery("status:merged", change2);
     assertQuery("is:merged", change2);
-    assertQuery("status:draft");
-    assertQuery("is:draft");
+    Exception thrown = assertThrows(BadRequestException.class, () -> assertQuery("is:draft"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: draft");
+    thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:draft"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: draft");
   }
 
   @Test
@@ -421,8 +432,10 @@
     assertQuery("status:N", change1);
     assertQuery("status:nE", change1);
     assertQuery("status:neW", change1);
-    assertQuery("status:nx");
-    assertQuery("status:newx");
+    Exception thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:newx"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: newx");
+    thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:nx"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: nx");
   }
 
   @Test
@@ -990,6 +1003,7 @@
 
   @Test
   public void byTopic() throws Exception {
+
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
     Change change1 = insert(repo, ins1);
@@ -1020,6 +1034,11 @@
     assertQuery("intopic:gerrit", change6, change5);
     assertQuery("topic:\"\"", change_no_topic);
     assertQuery("intopic:\"\"", change_no_topic);
+
+    assume().that(getSchema().hasField(ChangeField.PREFIX_TOPIC)).isTrue();
+    assertQuery("prefixtopic:feature", change4, change2, change1);
+    assertQuery("prefixtopic:Cher", change3);
+    assertQuery("prefixtopic:feature22");
   }
 
   @Test
@@ -1046,10 +1065,34 @@
     Change change1 = insert(repo, newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("two").create());
     Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit().message("A great \"fix\" to my bug").create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
 
     assertQuery("message:foo");
     assertQuery("message:one", change1);
     assertQuery("message:two", change2);
+    assertQuery("message:\"great \\\"fix\\\" to\"", change3);
+  }
+
+  @Test
+  public void byMessageRegEx() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.COMMIT_MESSAGE_EXACT)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("aaaabcc").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("aaaacc").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit().message("Title\n\nHELLO WORLD").create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    RevCommit commit4 =
+        repo.parseBody(repo.commit().message("Title\n\nfoobar hello WORLD").create());
+    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+
+    assertQuery("message:\"^aaaa(b|c)*\"", change2, change1);
+    assertQuery("message:\"^aaaa(c)*c.*\"", change2);
+    assertQuery("message:\"^.*HELLO WORLD.*\"", change3);
+    assertQuery(
+        "message:\"^.*(H|h)(E|e)(L|l)(L|l)(O|o) (W|w)(O|o)(R|r)(L|l)(D|d).*\"", change4, change3);
   }
 
   @Test
@@ -1109,6 +1152,7 @@
     ChangeInserter ins3 = newChange(repo);
     ChangeInserter ins4 = newChange(repo);
     ChangeInserter ins5 = newChange(repo);
+    ChangeInserter ins6 = newChange(repo);
 
     Change reviewMinus2Change = insert(repo, ins);
     gApi.changes().id(reviewMinus2Change.getId().get()).current().review(ReviewInput.reject());
@@ -1121,7 +1165,13 @@
     Change reviewPlus1Change = insert(repo, ins4);
     gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
 
-    Change reviewPlus2Change = insert(repo, ins5);
+    Change reviewTwoPlus1Change = insert(repo, ins5);
+    gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    requestContext.setContext(newRequestContext(createAccount("user1")));
+    gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    requestContext.setContext(newRequestContext(userId));
+
+    Change reviewPlus2Change = insert(repo, ins6);
     gApi.changes().id(reviewPlus2Change.getId().get()).current().review(ReviewInput.approve());
 
     Map<String, Short> m =
@@ -1132,8 +1182,10 @@
     assertThat(m).hasSize(1);
     assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 1));
 
-    Map<Integer, Change> changes = new LinkedHashMap<>(5);
+    Multimap<Integer, Change> changes =
+        Multimaps.newListMultimap(Maps.newLinkedHashMap(), () -> Lists.newArrayList());
     changes.put(2, reviewPlus2Change);
+    changes.put(1, reviewTwoPlus1Change);
     changes.put(1, reviewPlus1Change);
     changes.put(0, noLabelChange);
     changes.put(-1, reviewMinus1Change);
@@ -1145,9 +1197,9 @@
     assertQuery("label:Code-Review=-1", reviewMinus1Change);
     assertQuery("label:Code-Review-1", reviewMinus1Change);
     assertQuery("label:Code-Review=0", noLabelChange);
-    assertQuery("label:Code-Review=+1", reviewPlus1Change);
-    assertQuery("label:Code-Review=1", reviewPlus1Change);
-    assertQuery("label:Code-Review+1", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=1", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review+1", reviewTwoPlus1Change, reviewPlus1Change);
     assertQuery("label:Code-Review=+2", reviewPlus2Change);
     assertQuery("label:Code-Review=2", reviewPlus2Change);
     assertQuery("label:Code-Review+2", reviewPlus2Change);
@@ -1155,6 +1207,7 @@
     assertQuery(
         "label:Code-Review=ANY",
         reviewPlus2Change,
+        reviewTwoPlus1Change,
         reviewPlus1Change,
         reviewMinus1Change,
         reviewMinus2Change);
@@ -1183,14 +1236,70 @@
     assertQuery("label:Code-Review<-2");
 
     assertQuery("label:Code-Review=+1,anotheruser");
-    assertQuery("label:Code-Review=+1,user", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,user=user", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,Administrators", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,group=Administrators", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,user=owner", reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,owner", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=user", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,Administrators", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=+1,group=Administrators", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=owner", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,owner", reviewTwoPlus1Change, reviewPlus1Change);
     assertQuery("label:Code-Review=+2,owner", reviewPlus2Change);
     assertQuery("label:Code-Review=-2,owner", reviewMinus2Change);
+
+    // count=0 is not allowed
+    Exception thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("label:Code-Review=+2,count=0"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Argument count=0 is not allowed.");
+    assertQuery("label:Code-Review=1,count=1", reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count=2", reviewTwoPlus1Change);
+    assertQuery("label:Code-Review=1,count>=2", reviewTwoPlus1Change);
+    assertQuery("label:Code-Review=1,count>1", reviewTwoPlus1Change);
+    assertQuery("label:Code-Review=1,count>=1", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count=3");
+    thrown =
+        assertThrows(BadRequestException.class, () -> assertQuery("label:Code-Review=1,count=7"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("count=7 is not allowed. Maximum allowed value for count is 5.");
+
+    // Less than operator does not match with changes having count=0 for a specific vote value (i.e.
+    // no votes for that specific value). We do that deliberately since the computation of count=0
+    // for label values is expensive when the change is re-indexed. This is because the operation
+    // requires generating all formats for all {label-type, vote}=0 values that are non-voted for
+    // the change and storing them with the 'count=0' format.
+    assertQuery("label:Code-Review=1,count<5", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count<=5", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=1,count<=1", // reviewTwoPlus1Change is not matched since its count=2
+        reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=1,count<5 label:Code-Review=1,count>=1",
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
+    assertQuery(
+        "label:Code-Review=1,count<=5 label:Code-Review=1,count>=1",
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
+    assertQuery("label:Code-Review=1,count<=1 label:Code-Review=1,count>=1", reviewPlus1Change);
+
+    assertQuery("label:Code-Review=MAX,count=1", reviewPlus2Change);
+    assertQuery("label:Code-Review=MAX,count=2");
+    assertQuery("label:Code-Review=MIN,count=1", reviewMinus2Change);
+    assertQuery("label:Code-Review=MIN,count>1");
+    assertQuery("label:Code-Review=MAX,count<2", reviewPlus2Change);
+    assertQuery("label:Code-Review=MIN,count<1");
+    assertQuery("label:Code-Review=MAX,count<2 label:Code-Review=MAX,count>=1", reviewPlus2Change);
+    assertQuery("label:Code-Review=MIN,count<1 label:Code-Review=MIN,count>=1");
+    assertQuery("label:Code-Review>=+1,count=2", reviewTwoPlus1Change);
+
+    // "count" and "user" args cannot be used simultaneously.
+    assertThrows(
+        BadRequestException.class,
+        () -> assertQuery("label:Code-Review=+1,user=non_uploader,count=2"));
+
+    // "count" and "group" args cannot be used simultaneously.
+    assertThrows(
+        BadRequestException.class, () -> assertQuery("label:Code-Review=+1,group=gerrit,count=2"));
   }
 
   @Test
@@ -1295,16 +1404,15 @@
     assertQuery("label:Code-Review=+1,non_uploader", reviewPlus1Change);
   }
 
-  private Change[] codeReviewInRange(Map<Integer, Change> changes, int start, int end) {
-    int size = 0;
-    Change[] range = new Change[end - start + 1];
-    for (int i : changes.keySet()) {
+  private Change[] codeReviewInRange(Multimap<Integer, Change> changes, int start, int end) {
+    List<Change> range = new ArrayList<>();
+    for (Map.Entry<Integer, Change> entry : changes.entries()) {
+      int i = entry.getKey();
       if (i >= start && i <= end) {
-        range[size] = changes.get(i);
-        size++;
+        range.add(entry.getValue());
       }
     }
-    return range;
+    return range.toArray(new Change[0]);
   }
 
   private String createGroup(String name, String owner) throws Exception {
@@ -1730,6 +1838,27 @@
   }
 
   @Test
+  public void byFooterName() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.FOOTER_NAME)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nBaR: baz").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+
+    // create a changes with lines that look like footers, but which are not
+    RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
+    insert(repo, newChangeForCommit(repo, commit6));
+
+    // matching by 'key=value' works
+    assertQuery("hasfooter:foo", change1);
+
+    // case matters
+    assertQuery("hasfooter:BaR", change2);
+    assertQuery("hasfooter:Bar");
+  }
+
+  @Test
   public void byDirectory() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
@@ -1844,8 +1973,9 @@
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
-    Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
+    Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change2 =
+        insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
 
     // Stop time so age queries use the same endpoint.
     TestTimeUtil.setClockStep(0, MILLISECONDS);
@@ -1884,8 +2014,9 @@
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
-    Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
+    Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change2 =
+        insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     // Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -1935,8 +2066,9 @@
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
     TestRepository<Repo> repo = createProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
-    Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
+    Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change2 =
+        insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     // Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -2003,12 +2135,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);
 
@@ -2027,7 +2160,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"));
@@ -2040,7 +2173,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
@@ -2065,13 +2198,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);
 
@@ -2079,36 +2213,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);
   }
 
@@ -2131,14 +2265,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.
@@ -2246,6 +2381,15 @@
   }
 
   @Test
+  public void byHashtagPrefix() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.PREFIX_HASHTAG)).isTrue();
+    List<Change> changes = setUpHashtagChanges();
+    assertQuery("prefixhashtag:a", changes.get(1), changes.get(0));
+    assertQuery("prefixhashtag:aa", changes.get(0));
+    assertQuery("prefixhashtag:bar", changes.get(1));
+  }
+
+  @Test
   public void byHashtagRegex() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
@@ -2464,18 +2608,17 @@
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
       TestRepository<Repo> repo = createProject("repo");
       Change change = insert(repo, newChange(repo));
-      assertQuery("rule:gerrit~FakeSubmitRule");
+      // The fake submit rule exports its ruleName as "FakeSubmitRule"
+      assertQuery("rule:FakeSubmitRule");
 
       // FakeSubmitRule returns true if change has one or more hashtags.
       HashtagsInput hashtag = new HashtagsInput();
       hashtag.add = ImmutableSet.of("Tag1");
       gApi.changes().id(change.getId().get()).setHashtags(hashtag);
-      assertQuery("rule:gerrit~FakeSubmitRule", change);
-      assertQuery("rule:gerrit~FakeSubmitRule=OK", change);
-      assertQuery("rule:gerrit~FakeSubmitRule=NOT_READY");
 
-      // The 'gerrit~' prefix can be omitted for core submit rules
       assertQuery("rule:FakeSubmitRule", change);
+      assertQuery("rule:FakeSubmitRule=OK", change);
+      assertQuery("rule:FakeSubmitRule=NOT_READY");
     }
   }
 
@@ -2497,7 +2640,21 @@
   }
 
   @Test
-  public void byHasDraft() throws Exception {
+  public void byHasDraft_draftsComputedFromIndex() throws Exception {
+    byHasDraft();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  public void byHasDraft_draftsComputedFromAllUsersRepository() throws Exception {
+    byHasDraft();
+  }
+
+  private void byHasDraft() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
@@ -2525,7 +2682,11 @@
     assertQuery("has:draft");
   }
 
-  @Test
+  /**
+   * This test does not have a test about drafts computed from All-Users Repository because zombie
+   * drafts can't be filtered when computing from All-Users repository. TODO(paiking): During
+   * rollout, we should find a way to fix zombie drafts.
+   */
   public void byHasDraftExcludesZombieDrafts() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
@@ -2563,8 +2724,62 @@
     assertQuery("has:draft");
   }
 
+  public void byHasDraftWithManyDrafts_draftsComputedFromIndex() throws Exception {
+    byHasDraftWithManyDrafts();
+  }
+
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  public void byHasDraftWithManyDrafts_draftsComputedFromAllUsersRepository() throws Exception {
+    byHasDraftWithManyDrafts();
+  }
+
+  private void byHasDraftWithManyDrafts() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change[] changesWithDrafts = new Change[30];
+
+    // unrelated change not shown in the result.
+    insert(repo, newChange(repo));
+
+    for (int i = 0; i < changesWithDrafts.length; i++) {
+      // put the changes in reverse order since this is the order we receive them from the index.
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+      DraftInput in = new DraftInput();
+      in.line = 1;
+      in.message = "nit: trailing whitespace";
+      in.path = Patch.COMMIT_MSG;
+      gApi.changes()
+          .id(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().get())
+          .current()
+          .createDraft(in);
+    }
+    assertQuery("has:draft", changesWithDrafts);
+
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("has:draft");
+  }
+
   @Test
-  public void byStarredBy() throws Exception {
+  public void byStarredBy_starsComputedFromIndex() throws Exception {
+    byStarredBy();
+  }
+
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  @Test
+  public void byStarredBy_starsComputedFromAllUsersRepository() throws Exception {
+    byStarredBy();
+  }
+
+  private void byStarredBy() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
@@ -2585,7 +2800,21 @@
   }
 
   @Test
-  public void byStar() throws Exception {
+  public void byStar_starsComputedFromIndex() throws Exception {
+    byStar();
+  }
+
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  @Test
+  public void byStar_starsComputedFromAllUsersRepository() throws Exception {
+    byStar();
+  }
+
+  private void byStar() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
     Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
@@ -2611,7 +2840,21 @@
   }
 
   @Test
-  public void byIgnore() throws Exception {
+  public void byIgnore_starsComputedFromIndex() throws Exception {
+    byIgnore();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  public void byIgnore_starsComputedFromAllUsersRepository() throws Exception {
+    byIgnore();
+  }
+
+  private void byIgnore() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
@@ -2631,6 +2874,42 @@
     assertQuery("-star:ignore", change2, change1);
   }
 
+  public void byStarWithManyStars_starsComputedFromIndex() throws Exception {
+    byStarWithManyStars();
+  }
+
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  public void byStarWithManyStars_starsComputedFromAllUsersRepository() throws Exception {
+    byStarWithManyStars();
+  }
+
+  private void byStarWithManyStars() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change[] changesWithDrafts = new Change[30];
+    for (int i = 0; i < changesWithDrafts.length; i++) {
+      // put the changes in reverse order since this is the order we receive them from the index.
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+
+      // star the change
+      gApi.accounts()
+          .self()
+          .starChange(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().toString());
+
+      // ignore the change
+      gApi.changes()
+          .id(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().toString())
+          .ignore(true);
+    }
+
+    // all changes are both starred and ignored.
+    assertQuery("is:ignored", changesWithDrafts);
+    assertQuery("is:starred", changesWithDrafts);
+  }
+
   @Test
   public void byFrom() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
@@ -2963,8 +3242,6 @@
 
     assertQuery("is:submittable", change1);
     assertQuery("-is:submittable", change2);
-    assertQuery("submittable:ok", change1);
-    assertQuery("submittable:not_ready", change2);
 
     assertQuery("label:CodE-RevieW=ok", change1);
     assertQuery("label:CodE-RevieW=ok,user=user", change1);
@@ -2978,8 +3255,8 @@
     assertQuery("label:CodE-RevieW=need,user");
 
     gApi.changes().id(change1.getId().get()).current().submit();
-    assertQuery("submittable:ok");
-    assertQuery("submittable:closed", change1);
+    assertQuery("is:submittable");
+    assertQuery("-is:submittable", change1, change2);
   }
 
   @Test
@@ -3893,6 +4170,33 @@
   }
 
   @Test
+  public void isPureRevert() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.IS_PURE_REVERT)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    // Create two commits and revert second commit (initial commit can't be reverted)
+    Change initial = insert(repo, newChange(repo));
+    gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(initial.getChangeId()).current().submit();
+
+    ChangeInfo changeToRevert =
+        gApi.changes().create(new ChangeInput("repo", "master", "commit to revert")).get();
+    gApi.changes().id(changeToRevert.id).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToRevert.id).current().submit();
+
+    ChangeInfo changeThatReverts = gApi.changes().id(changeToRevert.id).revert().get();
+    Change.Id changeThatRevertsId = Change.id(changeThatReverts._number);
+    assertQueryByIds("is:pure-revert", changeThatRevertsId);
+
+    // Update the change that reverts such that it's not a pure revert
+    gApi.changes()
+        .id(changeThatReverts.id)
+        .edit()
+        .modifyFile("some-file.txt", RawInputUtil.create("newcontent".getBytes(UTF_8)));
+    gApi.changes().id(changeThatReverts.id).edit().publish();
+    assertQueryByIds("is:pure-revert");
+  }
+
+  @Test
   public void selfFailsForAnonymousUser() throws Exception {
     for (String query : ImmutableList.of("assignee:self", "has:star", "is:starred", "star:star")) {
       assertQuery(query);
@@ -4042,19 +4346,16 @@
   }
 
   protected Change insert(TestRepository<Repo> repo, ChangeInserter ins) throws Exception {
-    return insert(repo, ins, null, TimeUtil.nowTs());
+    return insert(repo, ins, null, TimeUtil.now());
   }
 
   protected Change insert(TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner)
       throws Exception {
-    return insert(repo, ins, owner, TimeUtil.nowTs());
+    return insert(repo, ins, owner, TimeUtil.now());
   }
 
   protected Change insert(
-      TestRepository<Repo> repo,
-      ChangeInserter ins,
-      @Nullable Account.Id owner,
-      Timestamp createdOn)
+      TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner, Instant createdOn)
       throws Exception {
     Project.NameKey project =
         Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
@@ -4080,7 +4381,7 @@
             .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit)
             .setFireRevisionCreated(false)
             .setValidate(false);
-    try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.nowTs());
+    try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.now());
         ObjectInserter oi = repo.getRepository().newObjectInserter();
         ObjectReader reader = oi.newReader();
         RevWalk rw = new RevWalk(reader)) {
@@ -4219,7 +4520,7 @@
   }
 
   protected static long lastUpdatedMs(Change c) {
-    return c.getLastUpdatedOn().getTime();
+    return c.getLastUpdatedOn().toEpochMilli();
   }
 
   // Get the last  updated time from ChangeApi
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index a7226ee..57a3c4b 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -28,7 +28,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/group/testing",
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index e42230f..e48d4af 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -46,7 +46,7 @@
         .id(PatchSet.id(changeId, num))
         .commitId(ObjectId.zeroId())
         .uploader(Account.id(1234))
-        .createdOn(TimeUtil.nowTs())
+        .createdOn(TimeUtil.now())
         .build();
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index 2f5ae17..8b7edac 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -15,11 +15,14 @@
 package com.google.gerrit.server.query.change;
 
 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.block;
 import static com.google.gerrit.common.data.GlobalCapability.QUERY_LIMIT;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
 
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.entities.Account;
@@ -28,8 +31,10 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.index.PaginationType;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.testing.AbstractFakeIndex;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
@@ -82,7 +87,7 @@
     newQuery("status:new").withLimit(5).get();
     // Since the limit of the query (i.e. 5) is more than the total number of changes (i.e. 4),
     // only 1 index search is expected.
-    assertThat(idx.getQueryCount()).isEqualTo(1);
+    assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
   }
 
   @Test
@@ -107,7 +112,7 @@
     gApi.changes().id(invisibleChange5.getChangeId()).setPrivate(true, null);
 
     AbstractFakeIndex idx = (AbstractFakeIndex) changeIndexCollection.getSearchIndex();
-    int queriesBeforeExecution = idx.getQueryCount();
+    idx.resetQueryCount();
     List<ChangeInfo> queryResult = newQuery("status:new").withLimit(2).get();
     assertThat(queryResult).hasSize(1);
     assertThat(queryResult.get(0).changeId).isEqualTo(visibleChange1.getKey().get());
@@ -117,7 +122,7 @@
     // 2: Another query is needed to back-fill the limit requested by the user.
     // even if one result in the second query is skipped because it is not visible,
     // there are no more results to query.
-    assertThat(idx.getQueryCount() - queriesBeforeExecution).isEqualTo(2);
+    assertThatSearchQueryWasPaginated(idx.getQueryCount(), 2);
   }
 
   @Test
@@ -125,27 +130,61 @@
   @SuppressWarnings("unchecked")
   public void noLimitQueryPaginates() throws Exception {
     assumeFalse(PaginationType.NONE == getCurrentPaginationType());
+
     TestRepository<InMemoryRepositoryManager.Repo> testRepo = createProject("repo");
     // create 4 changes
     insert(testRepo, newChange(testRepo));
     insert(testRepo, newChange(testRepo));
     insert(testRepo, newChange(testRepo));
     insert(testRepo, newChange(testRepo));
-
     // Set queryLimit to 2
     projectOperations
         .project(allProjects)
         .forUpdate()
         .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 2))
         .update();
-
     AbstractFakeIndex idx = (AbstractFakeIndex) changeIndexCollection.getSearchIndex();
-
     // 2 index searches are expected. The first index search will run with size 3 (i.e.
     // the configured query-limit+1), and then we will paginate to get the remaining
     // changes with the second index search.
     newQuery("status:new").withNoLimit().get();
-    assertThat(idx.getQueryCount()).isEqualTo(2);
+    assertThatSearchQueryWasPaginated(idx.getQueryCount(), 2);
+  }
+
+  @Test
+  @UseClockStep
+  public void noLimitQueryDoesNotPaginatesWithNonePaginationType() throws Exception {
+    assumeTrue(PaginationType.NONE == getCurrentPaginationType());
+    AbstractFakeIndex idx = setupRepoWithFourChanges();
+    newQuery("status:new").withNoLimit().get();
+    assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
+  }
+
+  @Test
+  @UseClockStep
+  public void invisibleChangesPaginatedWithPagination() throws Exception {
+    AbstractFakeIndex idx = setupRepoWithFourChanges();
+    final int LIMIT = 3;
+
+    projectOperations
+        .project(allProjectsName)
+        .forUpdate()
+        .removeAllAccessSections()
+        .add(allow(Permission.READ).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
+
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowCapability(QUERY_LIMIT).group(ANONYMOUS_USERS).range(0, LIMIT))
+        .update();
+
+    requestContext.setContext(anonymousUserProvider::get);
+    List<ChangeInfo> result = newQuery("status:new").withLimit(LIMIT).get();
+    assertThat(result.size()).isEqualTo(0);
+    assertThatSearchQueryWasPaginated(idx.getQueryCount(), 2);
+    assertThat(idx.getResultsSizes().get(0)).isEqualTo(LIMIT + 1);
+    assertThat(idx.getResultsSizes().get(1)).isEqualTo(0); // Second query size
   }
 
   @Test
@@ -153,8 +192,55 @@
   @SuppressWarnings("unchecked")
   public void internalQueriesPaginate() throws Exception {
     assumeFalse(PaginationType.NONE == getCurrentPaginationType());
+    final int LIMIT = 2;
+
+    TestRepository<Repo> testRepo = createProject("repo");
     // create 4 changes
-    TestRepository<InMemoryRepositoryManager.Repo> testRepo = createProject("repo");
+    insert(testRepo, newChange(testRepo));
+    insert(testRepo, newChange(testRepo));
+    insert(testRepo, newChange(testRepo));
+    insert(testRepo, newChange(testRepo));
+    // Set queryLimit to 2
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, LIMIT))
+        .update();
+    AbstractFakeIndex idx = (AbstractFakeIndex) changeIndexCollection.getSearchIndex();
+    // 2 index searches are expected. The first index search will run with size 3 (i.e.
+    // the configured query-limit+1), and then we will paginate to get the remaining
+    // changes with the second index search.
+    queryProvider.get().query(queryBuilderProvider.get().parse("status:new"));
+    assertThat(idx.getQueryCount()).isEqualTo(LIMIT);
+  }
+
+  @Test
+  @UseClockStep
+  @SuppressWarnings("unchecked")
+  public void internalQueriesDoNotPaginateWithNonePaginationType() throws Exception {
+    assumeTrue(PaginationType.NONE == getCurrentPaginationType());
+
+    AbstractFakeIndex idx = setupRepoWithFourChanges();
+    // 1 index search is expected since we are not paginating.
+    executeQuery("status:new");
+    assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
+  }
+
+  private void executeQuery(String query) throws QueryParseException {
+    queryProvider.get().query(queryBuilderProvider.get().parse(query));
+  }
+
+  private void assertThatSearchQueryWasNotPaginated(int queryCount) {
+    assertThat(queryCount).isEqualTo(1);
+  }
+
+  private void assertThatSearchQueryWasPaginated(int queryCount, int expectedPages) {
+    assertThat(queryCount).isEqualTo(expectedPages);
+  }
+
+  private AbstractFakeIndex setupRepoWithFourChanges() throws Exception {
+    TestRepository<Repo> testRepo = createProject("repo");
+    // create 4 changes
     insert(testRepo, newChange(testRepo));
     insert(testRepo, newChange(testRepo));
     insert(testRepo, newChange(testRepo));
@@ -167,12 +253,6 @@
         .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 2))
         .update();
 
-    AbstractFakeIndex idx = (AbstractFakeIndex) changeIndexCollection.getSearchIndex();
-
-    // 2 index searches are expected. The first index search will run with size 3 (i.e.
-    // the configured query-limit+1), and then we will paginate to get the remaining
-    // changes with the second index search.
-    queryProvider.get().query(queryBuilderProvider.get().parse("status:new"));
-    assertThat(idx.getQueryCount()).isEqualTo(2);
+    return (AbstractFakeIndex) changeIndexCollection.getSearchIndex();
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java
new file mode 100644
index 0000000..bf224f0
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.group;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assume.assumeTrue;
+
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.index.PaginationType;
+import com.google.gerrit.index.testing.AbstractFakeIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public abstract class AbstractFakeQueryGroupsTest extends AbstractQueryGroupsTest {
+
+  @Inject private GroupIndexCollection groupIndexCollection;
+
+  @Override
+  protected Injector createInjector() {
+    Config fakeConfig = new Config(config);
+    InMemoryModule.setDefaults(fakeConfig);
+    fakeConfig.setString("index", null, "type", "fake");
+    return Guice.createInjector(new InMemoryModule(fakeConfig));
+  }
+
+  @Before
+  public void resetQueryCount() {
+    ((AbstractFakeIndex<?, ?, ?>) groupIndexCollection.getSearchIndex()).resetQueryCount();
+  }
+
+  @Test
+  public void internalQueriesDoNotPaginateWithNonePaginationType() throws Exception {
+    assumeTrue(PaginationType.NONE == getCurrentPaginationType());
+
+    final int GROUPS_CREATED_SIZE = 2;
+    List<GroupInfo> groupsCreated = new ArrayList<>();
+    for (int i = 0; i < GROUPS_CREATED_SIZE; i++) {
+      groupsCreated.add(createGroupThatIsVisibleToAll(name("group-" + i)));
+    }
+
+    List<GroupInfo> result = assertQuery(newQuery("is:visibletoall"), groupsCreated);
+    assertThat(result.size()).isEqualTo(GROUPS_CREATED_SIZE);
+    assertThat(result.get(result.size() - 1)._moreGroups).isNull();
+    assertThatSearchQueryWasNotPaginated();
+  }
+
+  PaginationType getCurrentPaginationType() {
+    return config.getEnum("index", null, "paginationType", PaginationType.OFFSET);
+  }
+
+  private void assertThatSearchQueryWasNotPaginated() {
+    assertThat(getQueryCount()).isEqualTo(1);
+  }
+
+  private int getQueryCount() {
+    AbstractFakeIndex<?, ?, ?> idx =
+        (AbstractFakeIndex<?, ?, ?>) groupIndexCollection.getSearchIndex();
+    return idx.getQueryCount();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index 4b74325..0094bd6 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -1,7 +1,10 @@
 load("@rules_java//java:defs.bzl", "java_library")
 load("//tools/bzl:junit.bzl", "junit_tests")
 
-ABSTRACT_QUERY_TEST = ["AbstractQueryGroupsTest.java"]
+ABSTRACT_QUERY_TEST = [
+    "AbstractQueryGroupsTest.java",
+    "AbstractFakeQueryGroupsTest.java",
+]
 
 java_library(
     name = "abstract_query_tests",
@@ -13,6 +16,7 @@
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index/testing",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/schema",
@@ -54,6 +58,5 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:jgit",
         "//lib/guice",
-        "@truth//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
index e4f228a..d347716 100644
--- a/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
@@ -25,12 +25,26 @@
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
-public class FakeQueryGroupsTest extends AbstractQueryGroupsTest {
+public class FakeQueryGroupsTest extends AbstractFakeQueryGroupsTest {
   @ConfigSuite.Default
   public static Config defaultConfig() {
     return IndexConfig.createForFake();
   }
 
+  @ConfigSuite.Config
+  public static Config searchAfterPaginationType() {
+    Config config = defaultConfig();
+    config.setString("index", null, "paginationType", "SEARCH_AFTER");
+    return config;
+  }
+
+  @ConfigSuite.Config
+  public static Config nonePaginationType() {
+    Config config = defaultConfig();
+    config.setString("index", null, "paginationType", "NONE");
+    return config;
+  }
+
   @ConfigSuite.Configs
   public static Map<String, Config> againstPreviousIndexVersion() {
     // the current schema version is already tested by the inherited default config suite
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/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
index 46f9c5a..4c8750a 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -38,9 +38,10 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 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.restapi.change.CommentPorter.Metrics;
 import com.google.gerrit.truth.NullAwareCorrespondence;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
@@ -78,7 +79,10 @@
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenThrow(DiffNotAvailableException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -101,7 +105,10 @@
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -144,7 +151,10 @@
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -173,7 +183,10 @@
         .thenReturn(Optional.of(dummyObjectId));
     // Throw an exception on the first diff request but return an actual value on the second.
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenThrow(IllegalStateException.class)
         .thenReturn(ImmutableMap.of());
     ImmutableList<HumanComment> portedComments =
@@ -200,7 +213,10 @@
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenReturn(ImmutableMap.of());
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -215,7 +231,7 @@
         changeId,
         Account.id(123),
         BranchNameKey.create(project, "myBranch"),
-        new Timestamp(12345));
+        Instant.ofEpochMilli(12345));
   }
 
   private PatchSet createPatchset(PatchSet.Id id) {
@@ -223,7 +239,7 @@
         .id(id)
         .commitId(dummyObjectId)
         .uploader(Account.id(123))
-        .createdOn(new Timestamp(12345))
+        .createdOn(Instant.ofEpochMilli(12345))
         .build();
   }
 
@@ -246,7 +262,7 @@
     return new HumanComment(
         new Comment.Key(getUniqueUuid(), filePath, patchsetId.get()),
         Account.id(100),
-        new Timestamp(1234),
+        Instant.ofEpochMilli(1234),
         (short) 1,
         "Comment text",
         "serverId",
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index 44c3cef..2685a8b 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -23,6 +23,9 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
@@ -124,9 +127,11 @@
   /** Create a new change message with an id, message, timestamp and tag */
   private static ChangeMessage newChangeMessage(String id, String message, String ts, String tag) {
     ChangeMessage.Key key = ChangeMessage.key(Change.id(1), id);
-    ChangeMessage cm =
-        ChangeMessage.create(
-            key, null, Timestamp.valueOf("2000-01-01 00:00:" + ts), null, message, null, tag);
+    Instant timestamp =
+        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
+            .withZone(ZoneId.systemDefault())
+            .parse("2000-01-01 00:00:" + ts, Instant::from);
+    ChangeMessage cm = ChangeMessage.create(key, null, timestamp, null, message, null, tag);
     return cm;
   }
 
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
index e5dd817..509447a 100644
--- a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -27,7 +27,6 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Date;
 import java.util.List;
 import org.junit.Test;
 
@@ -84,7 +83,7 @@
     return PatchSetApproval.builder()
         .key(PatchSetApproval.key(PS_ID, accountId, labelId))
         .value(value)
-        .granted(Date.from(Instant.now()))
+        .granted(Instant.now())
         .build();
   }
 
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
index 646f0cd..767ac28 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
@@ -34,8 +34,6 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestUpdateUI;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.junit.TestRepository;
@@ -84,7 +82,7 @@
     protected final NoteDbSchemaUpdater updater;
     protected final GitRepositoryManager repoManager;
     protected final NoteDbSchemaVersion.Arguments args;
-    private final List<String> messages;
+    private final ImmutableList.Builder<String> messages;
 
     TestUpdate(Optional<Integer> initialVersion) {
       cfg = new Config();
@@ -106,7 +104,7 @@
               versionManager,
               args,
               ImmutableSortedMap.of(10, TestSchema_10.class, 11, TestSchema_11.class));
-      messages = new ArrayList<>();
+      messages = ImmutableList.builder();
     }
 
     private class TestSchemaCreator implements SchemaCreator {
@@ -173,7 +171,7 @@
     }
 
     ImmutableList<String> getMessages() {
-      return ImmutableList.copyOf(messages);
+      return messages.build();
     }
 
     Optional<Integer> readVersion() throws Exception {
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 10599c6..1f22564 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -95,7 +95,7 @@
     RevCommit masterCommit = repo.branch("master").commit().create();
     RevCommit branchCommit = repo.branch("branch").commit().parent(masterCommit).create();
 
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addRepoOnlyOp(
           new RepoOnlyOp() {
             @Override
@@ -114,7 +114,7 @@
   public void cannotExceedMaxUpdates() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(id, new AddMessageOp("Excessive update"));
       ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute);
       assertThat(thrown)
@@ -130,7 +130,7 @@
     Change.Id id = createChangeWithPatchSets(2);
 
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(id, new AddMessageOp("Update on PS1", PatchSet.id(id, 1)));
       bu.addOp(id, new AddMessageOp("Update on PS2", PatchSet.id(id, 2)));
       ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute);
@@ -146,7 +146,7 @@
   public void exceedingMaxUpdatesAllowedWithCompleteNoOp() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(
           id,
           new BatchUpdateOp() {
@@ -165,7 +165,7 @@
   public void exceedingMaxUpdatesAllowedWithNoOpAfterPopulatingUpdate() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(
           id,
           new BatchUpdateOp() {
@@ -185,7 +185,7 @@
   public void exceedingMaxUpdatesAllowedWithSubmit() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(id, new SubmitOp());
       bu.execute();
     }
@@ -197,7 +197,7 @@
   public void exceedingMaxUpdatesAllowedWithSubmitAfterOtherOp() throws Exception {
     Change.Id id = createChangeWithPatchSets(2);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(id, new AddMessageOp("Message on PS1", PatchSet.id(id, 1)));
       bu.addOp(id, new SubmitOp());
       bu.execute();
@@ -212,7 +212,7 @@
   public void exceedingMaxUpdatesAllowedWithAbandon() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(
           id,
           new BatchUpdateOp() {
@@ -235,7 +235,7 @@
     Change.Id changeId = createChangeWithPatchSets(MAX_PATCH_SETS);
     ObjectId oldMetaId = getMetaId(changeId);
     ChangeNotes notes = changeNotesFactory.create(project, changeId);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       ObjectId commitId =
           repo.amend(notes.getCurrentPatchSet().commitId()).message("kaboom").create();
       bu.addOp(
@@ -257,7 +257,7 @@
     Change.Id changeId = createChangeWithUpdates(1);
     ChangeNotes notes = changeNotesFactory.create(project, changeId);
 
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       ObjectId commitId =
           repo.amend(notes.getCurrentPatchSet().commitId())
               .add("bar.txt", "bar")
@@ -285,7 +285,7 @@
     int cacheSizeBefore = diffSummaryCache.asMap().size();
 
     // We don't want to depend on the test helper used above so we perform an explicit commit here.
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       ObjectId commitId =
           repo.amend(notes.getCurrentPatchSet().commitId())
               .add("bar.txt", "bar")
@@ -309,7 +309,7 @@
     checkArgument(totalUpdates > 0);
     checkArgument(totalUpdates <= MAX_UPDATES);
     Change.Id id = Change.id(sequences.nextChangeId());
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.insertChange(
           changeInserterFactory.create(
               id, repo.commit().message("Change").insertChangeId().create(), "refs/heads/master"));
@@ -317,7 +317,7 @@
     }
     assertThat(getUpdateCount(id)).isEqualTo(1);
     for (int i = 2; i <= totalUpdates; i++) {
-      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
         bu.addOp(id, new AddMessageOp("Update " + i));
         bu.execute();
       }
@@ -331,7 +331,7 @@
     Change.Id id = createChangeWithUpdates(MAX_UPDATES - 2);
     ChangeNotes notes = changeNotesFactory.create(project, id);
     for (int i = 2; i <= patchSets; ++i) {
-      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
         ObjectId commitId =
             repo.amend(notes.getCurrentPatchSet().commitId()).message("PS" + i).create();
         bu.addOp(
diff --git a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
index 0bb4de4..ebdf2d9 100644
--- a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
+++ b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -269,8 +269,8 @@
         .filter(s -> !s.isEmpty())
         .map(
             (String cookieValue) -> {
-              String[] kv = cookieValue.split("=");
-              return new Cookie(kv[0], kv[1]);
+              List<String> kv = Splitter.on("=").splitToList(cookieValue);
+              return new Cookie(kv.get(0), kv.get(1));
             })
         .collect(toList())
         .toArray(new Cookie[0]);
diff --git a/lib/BUILD b/lib/BUILD
index b39d001..7aa9a45 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -47,13 +47,6 @@
 )
 
 java_library(
-    name = "jgit-ssh-jsch",
-    data = ["//lib:LICENSE-jgit"],
-    visibility = ["//visibility:public"],
-    exports = ["@jgit//org.eclipse.jgit.ssh.jsch:ssh-jsch"],
-)
-
-java_library(
     name = "jgit-ssh-apache",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
@@ -99,7 +92,10 @@
     name = "protobuf",
     data = ["//lib:LICENSE-protobuf"],
     visibility = ["//visibility:public"],
-    exports = ["@com_google_protobuf//:protobuf_java"],
+    exports = [
+        "@com_google_protobuf//:protobuf_java",
+        "@com_google_protobuf//:protobuf_javalite",
+    ],
 )
 
 java_library(
@@ -128,6 +124,15 @@
 )
 
 java_library(
+    name = "guava-testlib",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = [
+        "@guava-testlib//jar",
+    ],
+)
+
+java_library(
     name = "caffeine",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = [
@@ -153,13 +158,6 @@
 )
 
 java_library(
-    name = "jsch",
-    data = ["//lib:LICENSE-jsch"],
-    visibility = ["//visibility:public"],
-    exports = ["@jsch//jar"],
-)
-
-java_library(
     name = "juniversalchardet",
     data = ["//lib:LICENSE-MPL1.1"],
     visibility = ["//visibility:public"],
diff --git a/lib/commons/BUILD b/lib/commons/BUILD
index 38b1b6d..091ea07 100644
--- a/lib/commons/BUILD
+++ b/lib/commons/BUILD
@@ -15,12 +15,6 @@
 )
 
 java_library(
-    name = "lang",
-    data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@commons-lang//jar"],
-)
-
-java_library(
     name = "lang3",
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@commons-lang3//jar"],
diff --git a/lib/highlightjs/BUILD b/lib/highlightjs/BUILD
index b10bc55..4105d85 100644
--- a/lib/highlightjs/BUILD
+++ b/lib/highlightjs/BUILD
@@ -1,3 +1,28 @@
-exports_files([
-    "highlight.min.js",
-])
+# build highlight.min.js from node modules
+
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+
+package(
+    default_visibility = ["//visibility:public"],
+    licenses = ["notice"],
+)
+
+rollup_bundle(
+    name = "highlight.min",
+    srcs = [
+        "@ui_npm//highlight.js",
+        "@ui_npm//highlightjs-closure-templates",
+        "@ui_npm//highlightjs-structured-text",
+    ],
+    config_file = "rollup.config.js",
+    entry_point = "index.js",
+    format = "iife",
+    rollup_bin = "//tools/node_tools:rollup-bin",
+    silent = True,
+    sourcemap = "hidden",
+    deps = [
+        "@tools_npm//rollup",
+        "@tools_npm//rollup-plugin-commonjs",
+        "@tools_npm//rollup-plugin-node-resolve",
+    ],
+)
diff --git a/lib/highlightjs/building.md b/lib/highlightjs/building.md
deleted file mode 100644
index 18c5746..0000000
--- a/lib/highlightjs/building.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Building Highlight.js for Gerrit
-
-Highlight JS needs to be built with specific language support. Here are the
-steps to build the minified file that appears here.
-
-NOTE: If you are adding support for a language to Highlight.js make sure to add
-it to the list of languages in the build command below.
-
-## Prerequisites
-
-You will need:
-
-* nodejs
-* closure-compiler
-* git
-
-## Steps to Create the Pack File
-
-The packed version of Highlight.js is an un-minified JS file with all of the
-languages included. Build it with the following:
-
-    $>  # start in some temp directory
-    $>  git clone https://github.com/highlightjs/highlight.js
-    $>  cd highlight.js
-    $>  git clone https://github.com/highlightjs/highlightjs-closure-templates
-    $>  ln -s ../../highlightjs-closure-templates/soy.js src/languages/soy.js
-    $>  mkdir test/detect/soy && ln -s ../../../highlightjs-closure-templates/test/detect/soy/default.txt test/detect/soy/default.txt
-    $>  npm install
-    $>  node tools/build.js
-
-The resulting minified JS file will appear in the "build" directory of the Highlight.js
-repo under the name "highlight.min.js".
-
-## Finish
-
-Copy the resulting build/highlight.min.js file to lib/highlightjs
diff --git a/lib/highlightjs/highlight.min.js b/lib/highlightjs/highlight.min.js
deleted file mode 100644
index ad4d1fe..0000000
--- a/lib/highlightjs/highlight.min.js
+++ /dev/null
@@ -1,3712 +0,0 @@
-/*
-  Highlight.js 10.7.2 (00233d63)
-  License: BSD-3-Clause
-  Copyright (c) 2006-2021, Ivan Sagalaev
-*/
-var hljs=function(){"use strict";function e(t){
-return t instanceof Map?t.clear=t.delete=t.set=()=>{
-throw Error("map is read-only")}:t instanceof Set&&(t.add=t.clear=t.delete=()=>{
-throw Error("set is read-only")
-}),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((n=>{var i=t[n]
-;"object"!=typeof i||Object.isFrozen(i)||e(i)})),t}var t=e,n=e;t.default=n
-;class i{constructor(e){
-void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1}
-ignoreMatch(){this.isMatchIgnored=!0}}function s(e){
-return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;")
-}function a(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t]
-;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const r=e=>!!e.kind
-;class l{constructor(e,t){
-this.buffer="",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){
-this.buffer+=s(e)}openNode(e){if(!r(e))return;let t=e.kind
-;e.sublanguage||(t=`${this.classPrefix}${t}`),this.span(t)}closeNode(e){
-r(e)&&(this.buffer+="</span>")}value(){return this.buffer}span(e){
-this.buffer+=`<span class="${e}">`}}class o{constructor(){this.rootNode={
-children:[]},this.stack=[this.rootNode]}get top(){
-return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){
-this.top.children.push(e)}openNode(e){const t={kind:e,children:[]}
-;this.add(t),this.stack.push(t)}closeNode(){
-if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){
-for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}
-walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,t){
-return"string"==typeof t?e.addText(t):t.children&&(e.openNode(t),
-t.children.forEach((t=>this._walk(e,t))),e.closeNode(t)),e}static _collapse(e){
-"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{
-o._collapse(e)})))}}class c extends o{constructor(e){super(),this.options=e}
-addKeyword(e,t){""!==e&&(this.openNode(t),this.addText(e),this.closeNode())}
-addText(e){""!==e&&this.add(e)}addSublanguage(e,t){const n=e.root
-;n.kind=t,n.sublanguage=!0,this.add(n)}toHTML(){
-return new l(this,this.options).value()}finalize(){return!0}}function g(e){
-return e?"string"==typeof e?e:e.source:null}
-const u=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./,h="[a-zA-Z]\\w*",d="[a-zA-Z_]\\w*",f="\\b\\d+(\\.\\d+)?",p="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",m="\\b(0b[01]+)",b={
-begin:"\\\\[\\s\\S]",relevance:0},E={className:"string",begin:"'",end:"'",
-illegal:"\\n",contains:[b]},x={className:"string",begin:'"',end:'"',
-illegal:"\\n",contains:[b]},v={
-begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/
-},w=(e,t,n={})=>{const i=a({className:"comment",begin:e,end:t,contains:[]},n)
-;return i.contains.push(v),i.contains.push({className:"doctag",
-begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),i
-},y=w("//","$"),N=w("/\\*","\\*/"),R=w("#","$");var _=Object.freeze({
-__proto__:null,MATCH_NOTHING_RE:/\b\B/,IDENT_RE:h,UNDERSCORE_IDENT_RE:d,
-NUMBER_RE:f,C_NUMBER_RE:p,BINARY_NUMBER_RE:m,
-RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",
-SHEBANG:(e={})=>{const t=/^#![ ]*\//
-;return e.binary&&(e.begin=((...e)=>e.map((e=>g(e))).join(""))(t,/.*\b/,e.binary,/\b.*/)),
-a({className:"meta",begin:t,end:/$/,relevance:0,"on:begin":(e,t)=>{
-0!==e.index&&t.ignoreMatch()}},e)},BACKSLASH_ESCAPE:b,APOS_STRING_MODE:E,
-QUOTE_STRING_MODE:x,PHRASAL_WORDS_MODE:v,COMMENT:w,C_LINE_COMMENT_MODE:y,
-C_BLOCK_COMMENT_MODE:N,HASH_COMMENT_MODE:R,NUMBER_MODE:{className:"number",
-begin:f,relevance:0},C_NUMBER_MODE:{className:"number",begin:p,relevance:0},
-BINARY_NUMBER_MODE:{className:"number",begin:m,relevance:0},CSS_NUMBER_MODE:{
-className:"number",
-begin:f+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",
-relevance:0},REGEXP_MODE:{begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp",
-begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[b,{begin:/\[/,end:/\]/,
-relevance:0,contains:[b]}]}]},TITLE_MODE:{className:"title",begin:h,relevance:0
-},UNDERSCORE_TITLE_MODE:{className:"title",begin:d,relevance:0},METHOD_GUARD:{
-begin:"\\.\\s*[a-zA-Z_]\\w*",relevance:0},END_SAME_AS_BEGIN:e=>Object.assign(e,{
-"on:begin":(e,t)=>{t.data._beginMatch=e[1]},"on:end":(e,t)=>{
-t.data._beginMatch!==e[1]&&t.ignoreMatch()}})});function k(e,t){
-"."===e.input[e.index-1]&&t.ignoreMatch()}function M(e,t){
-t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",
-e.__beforeBegin=k,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords,
-void 0===e.relevance&&(e.relevance=0))}function O(e,t){
-Array.isArray(e.illegal)&&(e.illegal=((...e)=>"("+e.map((e=>g(e))).join("|")+")")(...e.illegal))
-}function A(e,t){if(e.match){
-if(e.begin||e.end)throw Error("begin & end are not supported with match")
-;e.begin=e.match,delete e.match}}function L(e,t){
-void 0===e.relevance&&(e.relevance=1)}
-const I=["of","and","for","in","not","or","if","then","parent","list","value"]
-;function j(e,t,n="keyword"){const i={}
-;return"string"==typeof e?s(n,e.split(" ")):Array.isArray(e)?s(n,e):Object.keys(e).forEach((n=>{
-Object.assign(i,j(e[n],t,n))})),i;function s(e,n){
-t&&(n=n.map((e=>e.toLowerCase()))),n.forEach((t=>{const n=t.split("|")
-;i[n[0]]=[e,B(n[0],n[1])]}))}}function B(e,t){
-return t?Number(t):(e=>I.includes(e.toLowerCase()))(e)?0:1}
-function T(e,{plugins:t}){function n(t,n){
-return RegExp(g(t),"m"+(e.case_insensitive?"i":"")+(n?"g":""))}class i{
-constructor(){
-this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}
-addRule(e,t){
-t.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]),
-this.matchAt+=(e=>RegExp(e.toString()+"|").exec("").length-1)(e)+1}compile(){
-0===this.regexes.length&&(this.exec=()=>null)
-;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(((e,t="|")=>{let n=0
-;return e.map((e=>{n+=1;const t=n;let i=g(e),s="";for(;i.length>0;){
-const e=u.exec(i);if(!e){s+=i;break}
-s+=i.substring(0,e.index),i=i.substring(e.index+e[0].length),
-"\\"===e[0][0]&&e[1]?s+="\\"+(Number(e[1])+t):(s+=e[0],"("===e[0]&&n++)}return s
-})).map((e=>`(${e})`)).join(t)})(e),!0),this.lastIndex=0}exec(e){
-this.matcherRe.lastIndex=this.lastIndex;const t=this.matcherRe.exec(e)
-;if(!t)return null
-;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),i=this.matchIndexes[n]
-;return t.splice(0,n),Object.assign(t,i)}}class s{constructor(){
-this.rules=[],this.multiRegexes=[],
-this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){
-if(this.multiRegexes[e])return this.multiRegexes[e];const t=new i
-;return this.rules.slice(e).forEach((([e,n])=>t.addRule(e,n))),
-t.compile(),this.multiRegexes[e]=t,t}resumingScanAtSamePosition(){
-return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,t){
-this.rules.push([e,t]),"begin"===t.type&&this.count++}exec(e){
-const t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex
-;let n=t.exec(e)
-;if(this.resumingScanAtSamePosition())if(n&&n.index===this.lastIndex);else{
-const t=this.getMatcher(0);t.lastIndex=this.lastIndex+1,n=t.exec(e)}
-return n&&(this.regexIndex+=n.position+1,
-this.regexIndex===this.count&&this.considerAll()),n}}
-if(e.compilerExtensions||(e.compilerExtensions=[]),
-e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language.  See documentation.")
-;return e.classNameAliases=a(e.classNameAliases||{}),function t(i,r){const l=i
-;if(i.isCompiled)return l
-;[A].forEach((e=>e(i,r))),e.compilerExtensions.forEach((e=>e(i,r))),
-i.__beforeBegin=null,[M,O,L].forEach((e=>e(i,r))),i.isCompiled=!0;let o=null
-;if("object"==typeof i.keywords&&(o=i.keywords.$pattern,
-delete i.keywords.$pattern),
-i.keywords&&(i.keywords=j(i.keywords,e.case_insensitive)),
-i.lexemes&&o)throw Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ")
-;return o=o||i.lexemes||/\w+/,
-l.keywordPatternRe=n(o,!0),r&&(i.begin||(i.begin=/\B|\b/),
-l.beginRe=n(i.begin),i.endSameAsBegin&&(i.end=i.begin),
-i.end||i.endsWithParent||(i.end=/\B|\b/),
-i.end&&(l.endRe=n(i.end)),l.terminatorEnd=g(i.end)||"",
-i.endsWithParent&&r.terminatorEnd&&(l.terminatorEnd+=(i.end?"|":"")+r.terminatorEnd)),
-i.illegal&&(l.illegalRe=n(i.illegal)),
-i.contains||(i.contains=[]),i.contains=[].concat(...i.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>a(e,{
-variants:null},t)))),e.cachedVariants?e.cachedVariants:S(e)?a(e,{
-starts:e.starts?a(e.starts):null
-}):Object.isFrozen(e)?a(e):e))("self"===e?i:e)))),i.contains.forEach((e=>{t(e,l)
-})),i.starts&&t(i.starts,r),l.matcher=(e=>{const t=new s
-;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:"begin"
-}))),e.terminatorEnd&&t.addRule(e.terminatorEnd,{type:"end"
-}),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t})(l),l}(e)}function S(e){
-return!!e&&(e.endsWithParent||S(e.starts))}function P(e){const t={
-props:["language","code","autodetect"],data:()=>({detectedLanguage:"",
-unknownLanguage:!1}),computed:{className(){
-return this.unknownLanguage?"":"hljs "+this.detectedLanguage},highlighted(){
-if(!this.autoDetect&&!e.getLanguage(this.language))return console.warn(`The language "${this.language}" you specified could not be found.`),
-this.unknownLanguage=!0,s(this.code);let t={}
-;return this.autoDetect?(t=e.highlightAuto(this.code),
-this.detectedLanguage=t.language):(t=e.highlight(this.language,this.code,this.ignoreIllegals),
-this.detectedLanguage=this.language),t.value},autoDetect(){
-return!(this.language&&(e=this.autodetect,!e&&""!==e));var e},
-ignoreIllegals:()=>!0},render(e){return e("pre",{},[e("code",{
-class:this.className,domProps:{innerHTML:this.highlighted}})])}};return{
-Component:t,VuePlugin:{install(e){e.component("highlightjs",t)}}}}const D={
-"after:highlightElement":({el:e,result:t,text:n})=>{const i=H(e)
-;if(!i.length)return;const a=document.createElement("div")
-;a.innerHTML=t.value,t.value=((e,t,n)=>{let i=0,a="";const r=[];function l(){
-return e.length&&t.length?e[0].offset!==t[0].offset?e[0].offset<t[0].offset?e:t:"start"===t[0].event?e:t:e.length?e:t
-}function o(e){a+="<"+C(e)+[].map.call(e.attributes,(function(e){
-return" "+e.nodeName+'="'+s(e.value)+'"'})).join("")+">"}function c(e){
-a+="</"+C(e)+">"}function g(e){("start"===e.event?o:c)(e.node)}
-for(;e.length||t.length;){let t=l()
-;if(a+=s(n.substring(i,t[0].offset)),i=t[0].offset,t===e){r.reverse().forEach(c)
-;do{g(t.splice(0,1)[0]),t=l()}while(t===e&&t.length&&t[0].offset===i)
-;r.reverse().forEach(o)
-}else"start"===t[0].event?r.push(t[0].node):r.pop(),g(t.splice(0,1)[0])}
-return a+s(n.substr(i))})(i,H(a),n)}};function C(e){
-return e.nodeName.toLowerCase()}function H(e){const t=[];return function e(n,i){
-for(let s=n.firstChild;s;s=s.nextSibling)3===s.nodeType?i+=s.nodeValue.length:1===s.nodeType&&(t.push({
-event:"start",offset:i,node:s}),i=e(s,i),C(s).match(/br|hr|img|input/)||t.push({
-event:"stop",offset:i,node:s}));return i}(e,0),t}const $={},U=e=>{
-console.error(e)},z=(e,...t)=>{console.log("WARN: "+e,...t)},K=(e,t)=>{
-$[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),$[`${e}/${t}`]=!0)
-},G=s,V=a,W=Symbol("nomatch");return(e=>{
-const n=Object.create(null),s=Object.create(null),a=[];let r=!0
-;const l=/(^(<[^>]+>|\t|)+|\n)/gm,o="Could not find the language '{}', did you forget to load/include a language module?",g={
-disableAutodetect:!0,name:"Plain text",contains:[]};let u={
-noHighlightRe:/^(no-?highlight)$/i,
-languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",
-tabReplace:null,useBR:!1,languages:null,__emitter:c};function h(e){
-return u.noHighlightRe.test(e)}function d(e,t,n,i){let s="",a=""
-;"object"==typeof t?(s=e,
-n=t.ignoreIllegals,a=t.language,i=void 0):(K("10.7.0","highlight(lang, code, ...args) has been deprecated."),
-K("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"),
-a=e,s=t);const r={code:s,language:a};M("before:highlight",r)
-;const l=r.result?r.result:f(r.language,r.code,n,i)
-;return l.code=r.code,M("after:highlight",l),l}function f(e,t,s,l){
-function c(e,t){const n=v.case_insensitive?t[0].toLowerCase():t[0]
-;return Object.prototype.hasOwnProperty.call(e.keywords,n)&&e.keywords[n]}
-function g(){null!=R.subLanguage?(()=>{if(""===M)return;let e=null
-;if("string"==typeof R.subLanguage){
-if(!n[R.subLanguage])return void k.addText(M)
-;e=f(R.subLanguage,M,!0,_[R.subLanguage]),_[R.subLanguage]=e.top
-}else e=p(M,R.subLanguage.length?R.subLanguage:null)
-;R.relevance>0&&(O+=e.relevance),k.addSublanguage(e.emitter,e.language)
-})():(()=>{if(!R.keywords)return void k.addText(M);let e=0
-;R.keywordPatternRe.lastIndex=0;let t=R.keywordPatternRe.exec(M),n="";for(;t;){
-n+=M.substring(e,t.index);const i=c(R,t);if(i){const[e,s]=i
-;if(k.addText(n),n="",O+=s,e.startsWith("_"))n+=t[0];else{
-const n=v.classNameAliases[e]||e;k.addKeyword(t[0],n)}}else n+=t[0]
-;e=R.keywordPatternRe.lastIndex,t=R.keywordPatternRe.exec(M)}
-n+=M.substr(e),k.addText(n)})(),M=""}function h(e){
-return e.className&&k.openNode(v.classNameAliases[e.className]||e.className),
-R=Object.create(e,{parent:{value:R}}),R}function d(e,t,n){let s=((e,t)=>{
-const n=e&&e.exec(t);return n&&0===n.index})(e.endRe,n);if(s){if(e["on:end"]){
-const n=new i(e);e["on:end"](t,n),n.isMatchIgnored&&(s=!1)}if(s){
-for(;e.endsParent&&e.parent;)e=e.parent;return e}}
-if(e.endsWithParent)return d(e.parent,t,n)}function m(e){
-return 0===R.matcher.regexIndex?(M+=e[0],1):(I=!0,0)}function b(e){
-const n=e[0],i=t.substr(e.index),s=d(R,e,i);if(!s)return W;const a=R
-;a.skip?M+=n:(a.returnEnd||a.excludeEnd||(M+=n),g(),a.excludeEnd&&(M=n));do{
-R.className&&k.closeNode(),R.skip||R.subLanguage||(O+=R.relevance),R=R.parent
-}while(R!==s.parent)
-;return s.starts&&(s.endSameAsBegin&&(s.starts.endRe=s.endRe),
-h(s.starts)),a.returnEnd?0:n.length}let E={};function x(n,a){const l=a&&a[0]
-;if(M+=n,null==l)return g(),0
-;if("begin"===E.type&&"end"===a.type&&E.index===a.index&&""===l){
-if(M+=t.slice(a.index,a.index+1),!r){const t=Error("0 width match regex")
-;throw t.languageName=e,t.badRule=E.rule,t}return 1}
-if(E=a,"begin"===a.type)return function(e){
-const t=e[0],n=e.rule,s=new i(n),a=[n.__beforeBegin,n["on:begin"]]
-;for(const n of a)if(n&&(n(e,s),s.isMatchIgnored))return m(t)
-;return n&&n.endSameAsBegin&&(n.endRe=RegExp(t.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")),
-n.skip?M+=t:(n.excludeBegin&&(M+=t),
-g(),n.returnBegin||n.excludeBegin||(M=t)),h(n),n.returnBegin?0:t.length}(a)
-;if("illegal"===a.type&&!s){
-const e=Error('Illegal lexeme "'+l+'" for mode "'+(R.className||"<unnamed>")+'"')
-;throw e.mode=R,e}if("end"===a.type){const e=b(a);if(e!==W)return e}
-if("illegal"===a.type&&""===l)return 1
-;if(L>1e5&&L>3*a.index)throw Error("potential infinite loop, way more iterations than matches")
-;return M+=l,l.length}const v=N(e)
-;if(!v)throw U(o.replace("{}",e)),Error('Unknown language: "'+e+'"')
-;const w=T(v,{plugins:a});let y="",R=l||w;const _={},k=new u.__emitter(u);(()=>{
-const e=[];for(let t=R;t!==v;t=t.parent)t.className&&e.unshift(t.className)
-;e.forEach((e=>k.openNode(e)))})();let M="",O=0,A=0,L=0,I=!1;try{
-for(R.matcher.considerAll();;){
-L++,I?I=!1:R.matcher.considerAll(),R.matcher.lastIndex=A
-;const e=R.matcher.exec(t);if(!e)break;const n=x(t.substring(A,e.index),e)
-;A=e.index+n}return x(t.substr(A)),k.closeAllNodes(),k.finalize(),y=k.toHTML(),{
-relevance:Math.floor(O),value:y,language:e,illegal:!1,emitter:k,top:R}}catch(n){
-if(n.message&&n.message.includes("Illegal"))return{illegal:!0,illegalBy:{
-msg:n.message,context:t.slice(A-100,A+100),mode:n.mode},sofar:y,relevance:0,
-value:G(t),emitter:k};if(r)return{illegal:!1,relevance:0,value:G(t),emitter:k,
-language:e,top:R,errorRaised:n};throw n}}function p(e,t){
-t=t||u.languages||Object.keys(n);const i=(e=>{const t={relevance:0,
-emitter:new u.__emitter(u),value:G(e),illegal:!1,top:g}
-;return t.emitter.addText(e),t})(e),s=t.filter(N).filter(k).map((t=>f(t,e,!1)))
-;s.unshift(i);const a=s.sort(((e,t)=>{
-if(e.relevance!==t.relevance)return t.relevance-e.relevance
-;if(e.language&&t.language){if(N(e.language).supersetOf===t.language)return 1
-;if(N(t.language).supersetOf===e.language)return-1}return 0})),[r,l]=a,o=r
-;return o.second_best=l,o}const m={"before:highlightElement":({el:e})=>{
-u.useBR&&(e.innerHTML=e.innerHTML.replace(/\n/g,"").replace(/<br[ /]*>/g,"\n"))
-},"after:highlightElement":({result:e})=>{
-u.useBR&&(e.value=e.value.replace(/\n/g,"<br>"))}},b=/^(<[^>]+>|\t)+/gm,E={
-"after:highlightElement":({result:e})=>{
-u.tabReplace&&(e.value=e.value.replace(b,(e=>e.replace(/\t/g,u.tabReplace))))}}
-;function x(e){let t=null;const n=(e=>{let t=e.className+" "
-;t+=e.parentNode?e.parentNode.className:"";const n=u.languageDetectRe.exec(t)
-;if(n){const t=N(n[1])
-;return t||(z(o.replace("{}",n[1])),z("Falling back to no-highlight mode for this block.",e)),
-t?n[1]:"no-highlight"}return t.split(/\s+/).find((e=>h(e)||N(e)))})(e)
-;if(h(n))return;M("before:highlightElement",{el:e,language:n}),t=e
-;const i=t.textContent,a=n?d(i,{language:n,ignoreIllegals:!0}):p(i)
-;M("after:highlightElement",{el:e,result:a,text:i
-}),e.innerHTML=a.value,((e,t,n)=>{const i=t?s[t]:n
-;e.classList.add("hljs"),i&&e.classList.add(i)})(e,n,a.language),e.result={
-language:a.language,re:a.relevance,relavance:a.relevance
-},a.second_best&&(e.second_best={language:a.second_best.language,
-re:a.second_best.relevance,relavance:a.second_best.relevance})}const v=()=>{
-v.called||(v.called=!0,
-K("10.6.0","initHighlighting() is deprecated.  Use highlightAll() instead."),
-document.querySelectorAll("pre code").forEach(x))};let w=!1;function y(){
-"loading"!==document.readyState?document.querySelectorAll("pre code").forEach(x):w=!0
-}function N(e){return e=(e||"").toLowerCase(),n[e]||n[s[e]]}
-function R(e,{languageName:t}){"string"==typeof e&&(e=[e]),e.forEach((e=>{
-s[e.toLowerCase()]=t}))}function k(e){const t=N(e)
-;return t&&!t.disableAutodetect}function M(e,t){const n=e;a.forEach((e=>{
-e[n]&&e[n](t)}))}
-"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{
-w&&y()}),!1),Object.assign(e,{highlight:d,highlightAuto:p,highlightAll:y,
-fixMarkup:e=>{
-return K("10.2.0","fixMarkup will be removed entirely in v11.0"),K("10.2.0","Please see https://github.com/highlightjs/highlight.js/issues/2534"),
-t=e,
-u.tabReplace||u.useBR?t.replace(l,(e=>"\n"===e?u.useBR?"<br>":e:u.tabReplace?e.replace(/\t/g,u.tabReplace):e)):t
-;var t},highlightElement:x,
-highlightBlock:e=>(K("10.7.0","highlightBlock will be removed entirely in v12.0"),
-K("10.7.0","Please use highlightElement now."),x(e)),configure:e=>{
-e.useBR&&(K("10.3.0","'useBR' will be removed entirely in v11.0"),
-K("10.3.0","Please see https://github.com/highlightjs/highlight.js/issues/2559")),
-u=V(u,e)},initHighlighting:v,initHighlightingOnLoad:()=>{
-K("10.6.0","initHighlightingOnLoad() is deprecated.  Use highlightAll() instead."),
-w=!0},registerLanguage:(t,i)=>{let s=null;try{s=i(e)}catch(e){
-if(U("Language definition for '{}' could not be registered.".replace("{}",t)),
-!r)throw e;U(e),s=g}
-s.name||(s.name=t),n[t]=s,s.rawDefinition=i.bind(null,e),s.aliases&&R(s.aliases,{
-languageName:t})},unregisterLanguage:e=>{delete n[e]
-;for(const t of Object.keys(s))s[t]===e&&delete s[t]},
-listLanguages:()=>Object.keys(n),getLanguage:N,registerAliases:R,
-requireLanguage:e=>{
-K("10.4.0","requireLanguage will be removed entirely in v11."),
-K("10.4.0","Please see https://github.com/highlightjs/highlight.js/pull/2844")
-;const t=N(e);if(t)return t
-;throw Error("The '{}' language is required, but not loaded.".replace("{}",e))},
-autoDetection:k,inherit:V,addPlugin:e=>{(e=>{
-e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=t=>{
-e["before:highlightBlock"](Object.assign({block:t.el},t))
-}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=t=>{
-e["after:highlightBlock"](Object.assign({block:t.el},t))})})(e),a.push(e)},
-vuePlugin:P(e).VuePlugin}),e.debugMode=()=>{r=!1},e.safeMode=()=>{r=!0
-},e.versionString="10.7.2";for(const e in _)"object"==typeof _[e]&&t(_[e])
-;return Object.assign(e,_),e.addPlugin(m),e.addPlugin(D),e.addPlugin(E),e})({})
-}();"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs);
-hljs.registerLanguage("1c",(()=>{"use strict";return s=>{
-var x="[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]+",n="\u0434\u0430\u043b\u0435\u0435 \u0432\u043e\u0437\u0432\u0440\u0430\u0442 \u0432\u044b\u0437\u0432\u0430\u0442\u044c\u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0434\u043b\u044f \u0435\u0441\u043b\u0438 \u0438 \u0438\u0437 \u0438\u043b\u0438 \u0438\u043d\u0430\u0447\u0435 \u0438\u043d\u0430\u0447\u0435\u0435\u0441\u043b\u0438 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043a\u043e\u043d\u0435\u0446\u0435\u0441\u043b\u0438 \u043a\u043e\u043d\u0435\u0446\u043f\u043e\u043f\u044b\u0442\u043a\u0438 \u043a\u043e\u043d\u0435\u0446\u0446\u0438\u043a\u043b\u0430 \u043d\u0435 \u043d\u043e\u0432\u044b\u0439 \u043f\u0435\u0440\u0435\u0439\u0442\u0438 \u043f\u0435\u0440\u0435\u043c \u043f\u043e \u043f\u043e\u043a\u0430 \u043f\u043e\u043f\u044b\u0442\u043a\u0430 \u043f\u0440\u0435\u0440\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c \u0442\u043e\u0433\u0434\u0430 \u0446\u0438\u043a\u043b \u044d\u043a\u0441\u043f\u043e\u0440\u0442 ",e="null \u0438\u0441\u0442\u0438\u043d\u0430 \u043b\u043e\u0436\u044c \u043d\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043e",o=s.inherit(s.NUMBER_MODE),t={
-className:"string",begin:'"|\\|',end:'"|$',contains:[{begin:'""'}]},a={
-begin:"'",end:"'",excludeBegin:!0,excludeEnd:!0,contains:[{className:"number",
-begin:"\\d{4}([\\.\\\\/:-]?\\d{2}){0,5}"}]},m=s.inherit(s.C_LINE_COMMENT_MODE)
-;return{name:"1C:Enterprise",case_insensitive:!0,keywords:{$pattern:x,keyword:n,
-built_in:"\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u0441\u0442\u0440\u0430\u043d\u0438\u0446 \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u0441\u0442\u0440\u043e\u043a \u0441\u0438\u043c\u0432\u043e\u043b\u0442\u0430\u0431\u0443\u043b\u044f\u0446\u0438\u0438 ansitooem oemtoansi \u0432\u0432\u0435\u0441\u0442\u0438\u0432\u0438\u0434\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u0435 \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u0435\u0440\u0438\u043e\u0434 \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0439\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u0434\u0430\u0442\u0430\u0433\u043e\u0434 \u0434\u0430\u0442\u0430\u043c\u0435\u0441\u044f\u0446 \u0434\u0430\u0442\u0430\u0447\u0438\u0441\u043b\u043e \u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0441\u0442\u0440\u043e\u043a\u0443 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0441\u0442\u0440\u043e\u043a\u0438 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0438\u0431 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043a\u043e\u0434\u0441\u0438\u043c\u0432 \u043a\u043e\u043d\u0433\u043e\u0434\u0430 \u043a\u043e\u043d\u0435\u0446\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043a\u043e\u043d\u0435\u0446\u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u043d\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043a\u043e\u043d\u0435\u0446\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430 \u043a\u043e\u043d\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043a\u043e\u043d\u043c\u0435\u0441\u044f\u0446\u0430 \u043a\u043e\u043d\u043d\u0435\u0434\u0435\u043b\u0438 \u043b\u043e\u0433 \u043b\u043e\u043310 \u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043d\u0430\u0431\u043e\u0440\u0430\u043f\u0440\u0430\u0432 \u043d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c\u0432\u0438\u0434 \u043d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c\u0441\u0447\u0435\u0442 \u043d\u0430\u0439\u0442\u0438\u0441\u0441\u044b\u043b\u043a\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430 \u043d\u0430\u0447\u0433\u043e\u0434\u0430 \u043d\u0430\u0447\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043d\u0430\u0447\u043c\u0435\u0441\u044f\u0446\u0430 \u043d\u0430\u0447\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u043e\u043c\u0435\u0440\u0434\u043d\u044f\u0433\u043e\u0434\u0430 \u043d\u043e\u043c\u0435\u0440\u0434\u043d\u044f\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u043e\u043c\u0435\u0440\u043d\u0435\u0434\u0435\u043b\u0438\u0433\u043e\u0434\u0430 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0430\u0441\u0447\u0435\u0442\u043e\u0432 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u044f\u0437\u044b\u043a \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u043e\u043a\u043d\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 \u043f\u0435\u0440\u0438\u043e\u0434\u0441\u0442\u0440 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u0430\u0442\u0443\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0443\u0441\u0442\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0442\u0430 \u043f\u0440\u0435\u0444\u0438\u043a\u0441\u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u0438 \u043f\u0440\u043e\u043f\u0438\u0441\u044c \u043f\u0443\u0441\u0442\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0440\u0430\u0437\u043c \u0440\u0430\u0437\u043e\u0431\u0440\u0430\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043d\u0430 \u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043f\u043e \u0441\u0438\u043c\u0432 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442 \u0441\u0442\u0430\u0442\u0443\u0441\u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430 \u0441\u0442\u0440\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e\u0441\u0442\u0440\u043e\u043a \u0441\u0444\u043e\u0440\u043c\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0441\u0447\u0435\u0442\u043f\u043e\u043a\u043e\u0434\u0443 \u0442\u0435\u043a\u0443\u0449\u0435\u0435\u0432\u0440\u0435\u043c\u044f \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0441\u0442\u0440 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0442\u0430\u043d\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0442\u0430\u043f\u043e \u0444\u0438\u043a\u0441\u0448\u0430\u0431\u043b\u043e\u043d \u0448\u0430\u0431\u043b\u043e\u043d acos asin atan base64\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 base64\u0441\u0442\u0440\u043e\u043a\u0430 cos exp log log10 pow sin sqrt tan xml\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 xml\u0441\u0442\u0440\u043e\u043a\u0430 xml\u0442\u0438\u043f xml\u0442\u0438\u043f\u0437\u043d\u0447 \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0435\u043e\u043a\u043d\u043e \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0443\u043b\u0435\u0432\u043e \u0432\u0432\u0435\u0441\u0442\u0438\u0434\u0430\u0442\u0443 \u0432\u0432\u0435\u0441\u0442\u0438\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0432\u0432\u0435\u0441\u0442\u0438\u0441\u0442\u0440\u043e\u043a\u0443 \u0432\u0432\u0435\u0441\u0442\u0438\u0447\u0438\u0441\u043b\u043e \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c\u0447\u0442\u0435\u043d\u0438\u044fxml \u0432\u043e\u043f\u0440\u043e\u0441 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0432\u0440\u0435\u0433 \u0432\u044b\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0443\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u043f\u0440\u0430\u0432\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0432\u044b\u0447\u0438\u0441\u043b\u0438\u0442\u044c \u0433\u043e\u0434 \u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b\u0432\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0434\u0430\u0442\u0430 \u0434\u0435\u043d\u044c \u0434\u0435\u043d\u044c\u0433\u043e\u0434\u0430 \u0434\u0435\u043d\u044c\u043d\u0435\u0434\u0435\u043b\u0438 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c\u043c\u0435\u0441\u044f\u0446 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0434\u043b\u044f\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u0437\u0430\u043a\u0440\u044b\u0442\u044c\u0441\u043f\u0440\u0430\u0432\u043a\u0443 \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044cjson \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044cxml \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0434\u0430\u0442\u0443json \u0437\u0430\u043f\u0438\u0441\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0437\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0441\u0432\u043e\u0439\u0441\u0442\u0432 \u0437\u0430\u043f\u0440\u043e\u0441\u0438\u0442\u044c\u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c\u0441\u0438\u0441\u0442\u0435\u043c\u0443 \u0437\u0430\u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0441\u0442\u0440\u043e\u043a\u0443\u0432\u043d\u0443\u0442\u0440 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0444\u0430\u0439\u043b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u043e \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0441\u0442\u0440\u043e\u043a\u0438\u0432\u043d\u0443\u0442\u0440 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u0438\u0437xml\u0442\u0438\u043f\u0430 \u0438\u043c\u043f\u043e\u0440\u0442\u043c\u043e\u0434\u0435\u043b\u0438xdto \u0438\u043c\u044f\u043a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u0430 \u0438\u043c\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438\u043d\u0438\u0446\u0438\u0430\u043b\u0438\u0437\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0435\u0434\u0430\u043d\u043d\u044b\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u043e\u0431\u043e\u0448\u0438\u0431\u043a\u0435 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0438\u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0433\u043e\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0445\u0444\u0430\u0439\u043b\u043e\u0432 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u043a\u043e\u0434\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043a\u043e\u0434\u0441\u0438\u043c\u0432\u043e\u043b\u0430 \u043a\u043e\u043c\u0430\u043d\u0434\u0430\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043a\u043e\u043d\u0435\u0446\u0433\u043e\u0434\u0430 \u043a\u043e\u043d\u0435\u0446\u0434\u043d\u044f \u043a\u043e\u043d\u0435\u0446\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043a\u043e\u043d\u0435\u0446\u043c\u0435\u0441\u044f\u0446\u0430 \u043a\u043e\u043d\u0435\u0446\u043c\u0438\u043d\u0443\u0442\u044b \u043a\u043e\u043d\u0435\u0446\u043d\u0435\u0434\u0435\u043b\u0438 \u043a\u043e\u043d\u0435\u0446\u0447\u0430\u0441\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0430\u0434\u0438\u043d\u0430\u043c\u0438\u0447\u0435\u0441\u043a\u0438 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0430 \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0444\u0430\u0439\u043b \u043a\u0440\u0430\u0442\u043a\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043b\u0435\u0432 \u043c\u0430\u043a\u0441 \u043c\u0435\u0441\u0442\u043d\u043e\u0435\u0432\u0440\u0435\u043c\u044f \u043c\u0435\u0441\u044f\u0446 \u043c\u0438\u043d \u043c\u0438\u043d\u0443\u0442\u0430 \u043c\u043e\u043d\u043e\u043f\u043e\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u043d\u0430\u0439\u0442\u0438 \u043d\u0430\u0439\u0442\u0438\u043d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u044bxml \u043d\u0430\u0439\u0442\u0438\u043e\u043a\u043d\u043e\u043f\u043e\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0441\u0441\u044b\u043b\u043a\u0435 \u043d\u0430\u0439\u0442\u0438\u043f\u043e\u043c\u0435\u0447\u0435\u043d\u043d\u044b\u0435\u043d\u0430\u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435 \u043d\u0430\u0439\u0442\u0438\u043f\u043e\u0441\u0441\u044b\u043b\u043a\u0430\u043c \u043d\u0430\u0439\u0442\u0438\u0444\u0430\u0439\u043b\u044b \u043d\u0430\u0447\u0430\u043b\u043e\u0433\u043e\u0434\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u0434\u043d\u044f \u043d\u0430\u0447\u0430\u043b\u043e\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u043c\u0435\u0441\u044f\u0446\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u043c\u0438\u043d\u0443\u0442\u044b \u043d\u0430\u0447\u0430\u043b\u043e\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u0447\u0430\u0441\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0437\u0430\u043f\u0440\u043e\u0441\u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u0437\u0430\u043f\u0443\u0441\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0438\u0441\u043a\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0445\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0447\u0435\u0433\u043e\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043d\u0435\u0434\u0435\u043b\u044f\u0433\u043e\u0434\u0430 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e\u0441\u0442\u044c\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043d\u043e\u043c\u0435\u0440\u0441\u0435\u0430\u043d\u0441\u0430\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043d\u043e\u043c\u0435\u0440\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043d\u0440\u0435\u0433 \u043d\u0441\u0442\u0440 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u044e\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043f\u0440\u0435\u0440\u044b\u0432\u0430\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043e\u0431\u044a\u0435\u0434\u0438\u043d\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043e\u043a\u0440 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043e\u043f\u043e\u0432\u0435\u0441\u0442\u0438\u0442\u044c \u043e\u043f\u043e\u0432\u0435\u0441\u0442\u0438\u0442\u044c\u043e\u0431\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438 \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0437\u0430\u043f\u0440\u043e\u0441\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0438\u043d\u0434\u0435\u043a\u0441\u0441\u043f\u0440\u0430\u0432\u043a\u0438 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435\u0441\u043f\u0440\u0430\u0432\u043a\u0438 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0441\u043f\u0440\u0430\u0432\u043a\u0443 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0444\u043e\u0440\u043c\u0443 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0444\u043e\u0440\u043c\u0443\u043c\u043e\u0434\u0430\u043b\u044c\u043d\u043e \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0435\u0440\u0435\u0439\u0442\u0438\u043f\u043e\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0441\u0441\u044b\u043b\u043a\u0435 \u043f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0437\u0430\u043f\u0440\u043e\u0441\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0434\u0430\u0442\u044b \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0441\u0442\u0440\u043e\u043a\u0438 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0447\u0438\u0441\u043b\u0430 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u043e\u043f\u0440\u043e\u0441 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e\u043e\u0431\u043e\u0448\u0438\u0431\u043a\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043d\u0430\u043a\u0430\u0440\u0442\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u043d\u043e\u0435\u0438\u043c\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044ccom\u043e\u0431\u044a\u0435\u043a\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044cxml\u0442\u0438\u043f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0430\u0434\u0440\u0435\u0441\u043f\u043e\u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0443\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043f\u044f\u0449\u0435\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0441\u044b\u043f\u0430\u043d\u0438\u044f\u043f\u0430\u0441\u0441\u0438\u0432\u043d\u043e\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0432\u044b\u0431\u043e\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u043a\u043e\u0434\u044b\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0447\u0430\u0441\u043e\u0432\u044b\u0435\u043f\u043e\u044f\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0437\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043c\u044f\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0444\u0430\u0439\u043b\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043c\u044f\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e\u044d\u043a\u0440\u0430\u043d\u043e\u0432\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043a\u0440\u0430\u0442\u043a\u0438\u0439\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u043a\u0435\u0442\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b\u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0430\u0434\u0440\u0435\u0441\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0443\u044e\u0434\u043b\u0438\u043d\u0443\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u0443\u044e\u0441\u0441\u044b\u043b\u043a\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u0443\u044e\u0441\u0441\u044b\u043b\u043a\u0443\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u0449\u0438\u0439\u043c\u0430\u043a\u0435\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u0449\u0443\u044e\u0444\u043e\u0440\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u043a\u043d\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u043f\u0435\u0440\u0430\u0442\u0438\u0432\u043d\u0443\u044e\u043e\u0442\u043c\u0435\u0442\u043a\u0443\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0433\u043e\u0440\u0435\u0436\u0438\u043c\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0445\u043e\u043f\u0446\u0438\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u043e\u043b\u043d\u043e\u0435\u0438\u043c\u044f\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0445\u0441\u0441\u044b\u043b\u043e\u043a \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u0441\u043b\u043e\u0436\u043d\u043e\u0441\u0442\u0438\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438\u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0435\u0430\u043d\u0441\u044b\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430odata \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0443\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0441\u0435\u0430\u043d\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u043e\u0440\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e\u043e\u043f\u0446\u0438\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e\u043e\u043f\u0446\u0438\u044e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438\u043e\u0441 \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0432\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0435\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435 \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043f\u0440\u0430\u0432 \u043f\u0440\u0430\u0432\u043e\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043a\u043e\u0434\u0430\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0430\u0432\u0430 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0447\u0430\u0441\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u044f\u0441\u0430 \u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u043a\u0440\u0430\u0442\u0438\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043f\u0440\u0438\u0432\u0438\u043b\u0435\u0433\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c\u0432\u044b\u0437\u043e\u0432 \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044cjson \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044cxml \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044c\u0434\u0430\u0442\u0443json \u043f\u0443\u0441\u0442\u0430\u044f\u0441\u0442\u0440\u043e\u043a\u0430 \u0440\u0430\u0431\u043e\u0447\u0438\u0439\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0440\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0434\u043b\u044f\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u0440\u0430\u0437\u043e\u0440\u0432\u0430\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u0441\u0432\u043d\u0435\u0448\u043d\u0438\u043c\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u043c\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u0440\u043e\u043b\u044c\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0441\u0435\u043a\u0443\u043d\u0434\u0430 \u0441\u0438\u0433\u043d\u0430\u043b \u0441\u0438\u043c\u0432\u043e\u043b \u0441\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0441\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u043b\u0435\u0442\u043d\u0435\u0433\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0441\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u044c\u0431\u0443\u0444\u0435\u0440\u044b\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u043a\u0430\u0442\u0430\u043b\u043e\u0433 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u0444\u0430\u0431\u0440\u0438\u043a\u0443xdto \u0441\u043e\u043a\u0440\u043b \u0441\u043e\u043a\u0440\u043b\u043f \u0441\u043e\u043a\u0440\u043f \u0441\u043e\u043e\u0431\u0449\u0438\u0442\u044c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0441\u0440\u0435\u0434 \u0441\u0442\u0440\u0434\u043b\u0438\u043d\u0430 \u0441\u0442\u0440\u0437\u0430\u043a\u0430\u043d\u0447\u0438\u0432\u0430\u0435\u0442\u0441\u044f\u043d\u0430 \u0441\u0442\u0440\u0437\u0430\u043c\u0435\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u043d\u0430\u0439\u0442\u0438 \u0441\u0442\u0440\u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442\u0441\u044f\u0441 \u0441\u0442\u0440\u043e\u043a\u0430 \u0441\u0442\u0440\u043e\u043a\u0430\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0441\u0442\u0440\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u0441\u0442\u0440\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u0442\u0440\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u0441\u0440\u0430\u0432\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u0447\u0438\u0441\u043b\u043e\u0432\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0439 \u0441\u0442\u0440\u0447\u0438\u0441\u043b\u043e\u0441\u0442\u0440\u043e\u043a \u0441\u0442\u0440\u0448\u0430\u0431\u043b\u043e\u043d \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0434\u0430\u0442\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0434\u0430\u0442\u0430\u0441\u0435\u0430\u043d\u0441\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u0430\u044f\u0434\u0430\u0442\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u0430\u044f\u0434\u0430\u0442\u0430\u0432\u043c\u0438\u043b\u043b\u0438\u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u0448\u0440\u0438\u0444\u0442\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u043a\u043e\u0434\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0443\u0441\u043a\u0430 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u044f\u0437\u044b\u043a \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u044f\u0437\u044b\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0442\u0438\u043f \u0442\u0438\u043f\u0437\u043d\u0447 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044f\u0430\u043a\u0442\u0438\u0432\u043d\u0430 \u0442\u0440\u0435\u0433 \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0438\u0437\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442\u044b \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u043e\u0435\u0432\u0440\u0435\u043c\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0443\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043f\u044f\u0449\u0435\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0441\u044b\u043f\u0430\u043d\u0438\u044f\u043f\u0430\u0441\u0441\u0438\u0432\u043d\u043e\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043a\u0440\u0430\u0442\u043a\u0438\u0439\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0443\u044e\u0434\u043b\u0438\u043d\u0443\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043c\u043e\u043d\u043e\u043f\u043e\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0433\u043e\u0440\u0435\u0436\u0438\u043c\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0445\u043e\u043f\u0446\u0438\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0440\u0438\u0432\u0438\u043b\u0435\u0433\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u0441\u043b\u043e\u0436\u043d\u043e\u0441\u0442\u0438\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u0441\u0432\u043d\u0435\u0448\u043d\u0438\u043c\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u043c\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0438\u0444\u043e\u0440\u043c\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430odata \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0441\u0435\u0430\u043d\u0441\u0430 \u0444\u043e\u0440\u043c\u0430\u0442 \u0446\u0435\u043b \u0447\u0430\u0441 \u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441 \u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0441\u0435\u0430\u043d\u0441\u0430 \u0447\u0438\u0441\u043b\u043e \u0447\u0438\u0441\u043b\u043e\u043f\u0440\u043e\u043f\u0438\u0441\u044c\u044e \u044d\u0442\u043e\u0430\u0434\u0440\u0435\u0441\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 ws\u0441\u0441\u044b\u043b\u043a\u0438 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043c\u0430\u043a\u0435\u0442\u043e\u0432\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u0441\u0442\u0438\u043b\u0435\u0439 \u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u044b \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u043e\u0442\u0447\u0435\u0442\u044b \u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0435\u043f\u043e\u043a\u0443\u043f\u043a\u0438 \u0433\u043b\u0430\u0432\u043d\u044b\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441 \u0433\u043b\u0430\u0432\u043d\u044b\u0439\u0441\u0442\u0438\u043b\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0435\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f \u0436\u0443\u0440\u043d\u0430\u043b\u044b\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u0437\u0430\u0434\u0430\u0447\u0438 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u043e\u0431\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0447\u0435\u0439\u0434\u0430\u0442\u044b \u0438\u0441\u0442\u043e\u0440\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043a\u043e\u043d\u0441\u0442\u0430\u043d\u0442\u044b \u043a\u0440\u0438\u0442\u0435\u0440\u0438\u0438\u043e\u0442\u0431\u043e\u0440\u0430 \u043c\u0435\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0435 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u043a\u043b\u0430\u043c\u044b \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0430\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0445\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 \u043e\u0442\u0447\u0435\u0442\u044b \u043f\u0430\u043d\u0435\u043b\u044c\u0437\u0430\u0434\u0430\u0447\u043e\u0441 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0437\u0430\u043f\u0443\u0441\u043a\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u044f \u043f\u043b\u0430\u043d\u044b\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043f\u043b\u0430\u043d\u044b\u0432\u0438\u0434\u043e\u0432\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a \u043f\u043b\u0430\u043d\u044b\u043e\u0431\u043c\u0435\u043d\u0430 \u043f\u043b\u0430\u043d\u044b\u0441\u0447\u0435\u0442\u043e\u0432 \u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u043f\u043e\u0438\u0441\u043a \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0445\u043f\u043e\u043a\u0443\u043f\u043e\u043a \u0440\u0430\u0431\u043e\u0447\u0430\u044f\u0434\u0430\u0442\u0430 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0438\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0441\u0432\u0435\u0434\u0435\u043d\u0438\u0439 \u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u044b\u0435\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0442\u043e\u0440xdto \u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0438 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u0433\u0435\u043e\u043f\u043e\u0437\u0438\u0446\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043c\u0443\u043b\u044c\u0442\u0438\u043c\u0435\u0434\u0438\u0430 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0440\u0435\u043a\u043b\u0430\u043c\u044b \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043f\u043e\u0447\u0442\u044b \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0438\u0438 \u0444\u0430\u0431\u0440\u0438\u043a\u0430xdto \u0444\u0430\u0439\u043b\u043e\u0432\u044b\u0435\u043f\u043e\u0442\u043e\u043a\u0438 \u0444\u043e\u043d\u043e\u0432\u044b\u0435\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0432\u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043e\u0431\u0449\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u0434\u0438\u043d\u0430\u043c\u0438\u0447\u0435\u0441\u043a\u0438\u0445\u0441\u043f\u0438\u0441\u043a\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0441\u0438\u0441\u0442\u0435\u043c\u043d\u044b\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a ",
-class:"web\u0446\u0432\u0435\u0442\u0430 windows\u0446\u0432\u0435\u0442\u0430 windows\u0448\u0440\u0438\u0444\u0442\u044b \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u0440\u0430\u043c\u043a\u0438\u0441\u0442\u0438\u043b\u044f \u0441\u0438\u043c\u0432\u043e\u043b\u044b \u0446\u0432\u0435\u0442\u0430\u0441\u0442\u0438\u043b\u044f \u0448\u0440\u0438\u0444\u0442\u044b\u0441\u0442\u0438\u043b\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0435\u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c\u044b\u0432\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u044f\u0432\u0444\u043e\u0440\u043c\u0435 \u0430\u0432\u0442\u043e\u0440\u0430\u0437\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435\u0441\u0435\u0440\u0438\u0439 \u0430\u043d\u0438\u043c\u0430\u0446\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0432\u044b\u0440\u0430\u0432\u043d\u0438\u0432\u0430\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0438\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u043e\u0432 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0432\u044b\u0441\u043e\u0442\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u0430\u044f\u043f\u0440\u043e\u043a\u0440\u0443\u0442\u043a\u0430\u0444\u043e\u0440\u043c\u044b \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430 \u0432\u0438\u0434\u0433\u0440\u0443\u043f\u043f\u044b\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u0435\u043a\u043e\u0440\u0430\u0446\u0438\u0438\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0438\u0434\u043a\u043d\u043e\u043f\u043a\u0438\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u044f \u0432\u0438\u0434\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u0432\u0438\u0434\u043f\u043e\u043b\u044f\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0444\u043b\u0430\u0436\u043a\u0430 \u0432\u043b\u0438\u044f\u043d\u0438\u0435\u0440\u0430\u0437\u043c\u0435\u0440\u0430\u043d\u0430\u043f\u0443\u0437\u044b\u0440\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430 \u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0430\u043a\u043e\u043b\u043e\u043d\u043e\u043a \u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0430\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0444\u043e\u0440\u043c\u044b \u0433\u0440\u0443\u043f\u043f\u044b\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043f\u0435\u0440\u0435\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u043d\u0438\u044f \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f\u043f\u0435\u0440\u0435\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u043d\u0438\u044f \u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u043c\u0435\u0436\u0434\u0443\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043c\u0438\u0444\u043e\u0440\u043c\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0432\u044b\u0432\u043e\u0434\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u043b\u043e\u0441\u044b\u043f\u0440\u043e\u043a\u0440\u0443\u0442\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0442\u043e\u0447\u043a\u0438\u0431\u0438\u0440\u0436\u0435\u0432\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0438\u0441\u0442\u043e\u0440\u0438\u044f\u0432\u044b\u0431\u043e\u0440\u0430\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043e\u0441\u0438\u0442\u043e\u0447\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0440\u0430\u0437\u043c\u0435\u0440\u0430\u043f\u0443\u0437\u044b\u0440\u044c\u043a\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u044b\u043a\u043e\u043c\u0430\u043d\u0434 \u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c\u0441\u0435\u0440\u0438\u0439 \u043d\u0430\u0447\u0430\u043b\u044c\u043d\u043e\u0435\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0434\u0435\u0440\u0435\u0432\u0430 \u043d\u0430\u0447\u0430\u043b\u044c\u043d\u043e\u0435\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0441\u043f\u0438\u0441\u043a\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u043c\u0435\u0442\u043e\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u043c\u0435\u0442\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u043b\u0435\u0433\u0435\u043d\u0434\u0435\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u043a\u043d\u043e\u043f\u043e\u043a \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043d\u043e\u043f\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043d\u043e\u043f\u043a\u0438\u0432\u044b\u0431\u043e\u0440\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0431\u0441\u0443\u0436\u0434\u0435\u043d\u0438\u0439\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043f\u0443\u0437\u044b\u0440\u044c\u043a\u043e\u0432\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0430\u043d\u0435\u043b\u0438\u043f\u043e\u0438\u0441\u043a\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u044f\u043f\u0440\u0438\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0440\u0430\u0437\u043c\u0435\u0442\u043a\u0438\u043f\u043e\u043b\u043e\u0441\u044b\u0440\u0435\u0433\u0443\u043b\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0444\u0438\u0433\u0443\u0440\u044b\u043a\u043d\u043e\u043f\u043a\u0438 \u043f\u0430\u043b\u0438\u0442\u0440\u0430\u0446\u0432\u0435\u0442\u043e\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0438\u0441\u043a\u0432\u0442\u0430\u0431\u043b\u0438\u0446\u0435\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438\u043a\u043d\u043e\u043f\u043a\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u0430\u043d\u0434\u043d\u043e\u0439\u043f\u0430\u043d\u0435\u043b\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u0430\u043d\u0434\u043d\u043e\u0439\u043f\u0430\u043d\u0435\u043b\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043e\u043f\u043e\u0440\u043d\u043e\u0439\u0442\u043e\u0447\u043a\u0438\u043e\u0442\u0440\u0438\u0441\u043e\u0432\u043a\u0438 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u0448\u043a\u0430\u043b\u044b\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0430 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u0442\u0440\u043e\u043a\u0438\u043f\u043e\u0438\u0441\u043a\u0430 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u043b\u0438\u043d\u0438\u0438 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u043e\u0438\u0441\u043a\u043e\u043c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u043a\u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0439\u0433\u0438\u0441\u0442\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u0441\u0435\u0440\u0438\u0439\u0432\u043b\u0435\u0433\u0435\u043d\u0434\u0435\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0430\u0437\u043c\u0435\u0440\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0430\u0441\u0442\u044f\u0433\u0438\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0432\u0432\u043e\u0434\u0430\u0441\u0442\u0440\u043e\u043a\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0431\u043e\u0440\u0430\u043d\u0435\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u043e\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u0442\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0441\u0442\u0440\u043e\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0437\u043c\u0435\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0441\u0432\u044f\u0437\u0430\u043d\u043d\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u043f\u0435\u0447\u0430\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043a\u043e\u043c\u0430\u043d\u0434\u044b \u0440\u0435\u0436\u0438\u043c\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u043e\u043a\u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u043e\u043a\u043d\u0430\u0444\u043e\u0440\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0441\u0435\u0440\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u0440\u0438\u0441\u043e\u0432\u043a\u0438\u0441\u0435\u0442\u043a\u0438\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u0443\u043f\u0440\u043e\u0437\u0440\u0430\u0447\u043d\u043e\u0441\u0442\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0431\u0435\u043b\u043e\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0435 \u0440\u0435\u0436\u0438\u043c\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043a\u043e\u043b\u043e\u043d\u043a\u0438 \u0440\u0435\u0436\u0438\u043c\u0441\u0433\u043b\u0430\u0436\u0438\u0432\u0430\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u0441\u0433\u043b\u0430\u0436\u0438\u0432\u0430\u043d\u0438\u044f\u0438\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0441\u043f\u0438\u0441\u043a\u0430\u0437\u0430\u0434\u0430\u0447 \u0441\u043a\u0432\u043e\u0437\u043d\u043e\u0435\u0432\u044b\u0440\u0430\u0432\u043d\u0438\u0432\u0430\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c\u044b\u0432\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0441\u043f\u043e\u0441\u043e\u0431\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0438\u0432\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u0430\u044f\u0433\u0440\u0443\u043f\u043f\u0430\u043a\u043e\u043c\u0430\u043d\u0434 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0435\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u0435 \u0441\u0442\u0430\u0442\u0443\u0441\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0441\u0442\u0438\u043b\u044c\u0441\u0442\u0440\u0435\u043b\u043a\u0438 \u0442\u0438\u043f\u0430\u043f\u043f\u0440\u043e\u043a\u0441\u0438\u043c\u0430\u0446\u0438\u0438\u043b\u0438\u043d\u0438\u0438\u0442\u0440\u0435\u043d\u0434\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0435\u0434\u0438\u043d\u0438\u0446\u044b\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0442\u0438\u043f\u0438\u043c\u043f\u043e\u0440\u0442\u0430\u0441\u0435\u0440\u0438\u0439\u0441\u043b\u043e\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u043c\u0430\u0440\u043a\u0435\u0440\u0430\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043c\u0430\u0440\u043a\u0435\u0440\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0441\u0435\u0440\u0438\u0438\u0441\u043b\u043e\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u0447\u043d\u043e\u0433\u043e\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0448\u043a\u0430\u043b\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043b\u0435\u0433\u0435\u043d\u0434\u044b\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043f\u043e\u0438\u0441\u043a\u0430\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043f\u0440\u043e\u0435\u043a\u0446\u0438\u0438\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u043e\u0432\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0440\u0430\u043c\u043a\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u0432\u044f\u0437\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043f\u043e\u0441\u0435\u0440\u0438\u044f\u043c\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u043b\u0438\u043d\u0438\u0438 \u0442\u0438\u043f\u0441\u0442\u043e\u0440\u043e\u043d\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u0444\u043e\u0440\u043c\u044b\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0448\u043a\u0430\u043b\u044b\u0440\u0430\u0434\u0430\u0440\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0444\u0430\u043a\u0442\u043e\u0440\u043b\u0438\u043d\u0438\u0438\u0442\u0440\u0435\u043d\u0434\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0444\u0438\u0433\u0443\u0440\u0430\u043a\u043d\u043e\u043f\u043a\u0438 \u0444\u0438\u0433\u0443\u0440\u044b\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0444\u0438\u043a\u0441\u0430\u0446\u0438\u044f\u0432\u0442\u0430\u0431\u043b\u0438\u0446\u0435 \u0444\u043e\u0440\u043c\u0430\u0442\u0434\u043d\u044f\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0444\u043e\u0440\u043c\u0430\u0442\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0448\u0438\u0440\u0438\u043d\u0430\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0438\u0438 \u0432\u0438\u0434\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0432\u0438\u0434\u0441\u0447\u0435\u0442\u0430 \u0432\u0438\u0434\u0442\u043e\u0447\u043a\u0438\u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0430\u0433\u0440\u0435\u0433\u0430\u0442\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0435\u0436\u0438\u043c\u0430\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u0440\u0435\u0437\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0430\u0433\u0440\u0435\u0433\u0430\u0442\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u0432\u0440\u0435\u043c\u044f \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0438\u0441\u0438\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0430\u0432\u0442\u043e\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0439 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439\u043d\u043e\u043c\u0435\u0440\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0430\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u043a\u043e\u043b\u043e\u043d\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u0441\u0442\u0440\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u043e\u0442\u043d\u043e\u0441\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0441\u043f\u043e\u0441\u043e\u0431\u0447\u0442\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0434\u0432\u0443\u0441\u0442\u043e\u0440\u043e\u043d\u043d\u0435\u0439\u043f\u0435\u0447\u0430\u0442\u0438 \u0442\u0438\u043f\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043a\u0443\u0440\u0441\u043e\u0440\u043e\u0432\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0440\u0438\u0441\u0443\u043d\u043a\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u044f\u0447\u0435\u0439\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043b\u0438\u043d\u0438\u0439\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0440\u0438\u0441\u0443\u043d\u043a\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0441\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0443\u0437\u043e\u0440\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0444\u0430\u0439\u043b\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u043e\u0447\u043d\u043e\u0441\u0442\u044c\u043f\u0435\u0447\u0430\u0442\u0438 \u0447\u0435\u0440\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f\u0441\u0442\u0440\u0430\u043d\u0438\u0446 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u0440\u0435\u043c\u0435\u043d\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u043f\u043b\u0430\u043d\u0438\u0440\u043e\u0432\u0449\u0438\u043a\u0430 \u0442\u0438\u043f\u0444\u0430\u0439\u043b\u0430\u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043e\u0431\u0445\u043e\u0434\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0437\u0430\u043f\u0438\u0441\u0438\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0432\u0438\u0434\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0438\u0442\u043e\u0433\u043e\u0432 \u0434\u043e\u0441\u0442\u0443\u043f\u043a\u0444\u0430\u0439\u043b\u0443 \u0440\u0435\u0436\u0438\u043c\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u0432\u044b\u0431\u043e\u0440\u0430\u0444\u0430\u0439\u043b\u0430 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u0444\u0430\u0439\u043b\u0430 \u0442\u0438\u043f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0432\u0438\u0434\u0434\u0430\u043d\u043d\u044b\u0445\u0430\u043d\u0430\u043b\u0438\u0437\u0430 \u043c\u0435\u0442\u043e\u0434\u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0442\u0438\u043f\u0435\u0434\u0438\u043d\u0438\u0446\u044b\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430\u0432\u0440\u0435\u043c\u0435\u043d\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0447\u0438\u0441\u043b\u043e\u0432\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u0430\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u0434\u0435\u0440\u0435\u0432\u043e\u0440\u0435\u0448\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0438\u0437\u0430\u0446\u0438\u044f \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0430\u044f\u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0430 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u043c\u043e\u0434\u0435\u043b\u0438\u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430 \u0442\u0438\u043f\u043c\u0435\u0440\u044b\u0440\u0430\u0441\u0441\u0442\u043e\u044f\u043d\u0438\u044f\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043e\u0442\u0441\u0435\u0447\u0435\u043d\u0438\u044f\u043f\u0440\u0430\u0432\u0438\u043b\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0438 \u0442\u0438\u043f\u043f\u043e\u043b\u044f\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u0438\u0437\u0430\u0446\u0438\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u043e\u0440\u044f\u0434\u043e\u0447\u0438\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u0430\u0432\u0438\u043b\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u043e\u0440\u044f\u0434\u043e\u0447\u0438\u0432\u0430\u043d\u0438\u044f\u0448\u0430\u0431\u043b\u043e\u043d\u043e\u0432\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u0440\u043e\u0449\u0435\u043d\u0438\u044f\u0434\u0435\u0440\u0435\u0432\u0430\u0440\u0435\u0448\u0435\u043d\u0438\u0439 ws\u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430 \u0432\u0430\u0440\u0438\u0430\u043d\u0442xpathxs \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0437\u0430\u043f\u0438\u0441\u0438\u0434\u0430\u0442\u044bjson \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0432\u0438\u0434\u0433\u0440\u0443\u043f\u043f\u044b\u043c\u043e\u0434\u0435\u043b\u0438xs \u0432\u0438\u0434\u0444\u0430\u0441\u0435\u0442\u0430xdto \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044fdom \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u043d\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u0441\u0445\u0435\u043c\u044bxs \u0437\u0430\u043f\u0440\u0435\u0449\u0435\u043d\u043d\u044b\u0435\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u0438\u0434\u0435\u043d\u0442\u0438\u0447\u043d\u043e\u0441\u0442\u0438xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0438\u043c\u0435\u043dxs \u043c\u0435\u0442\u043e\u0434\u043d\u0430\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u044fxs \u043c\u043e\u0434\u0435\u043b\u044c\u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0433\u043exs \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0442\u0438\u043f\u0430xml \u043d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0445\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432xs \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0433\u043exs \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u043e\u0442\u0431\u043e\u0440\u0430\u0443\u0437\u043b\u043e\u0432dom \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0441\u0442\u0440\u043e\u043ajson \u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0432\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0435dom \u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u044bxml \u0442\u0438\u043f\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xml \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fjson \u0442\u0438\u043f\u043a\u0430\u043d\u043e\u043d\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043exml \u0442\u0438\u043f\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044bxs \u0442\u0438\u043f\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438xml \u0442\u0438\u043f\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430domxpath \u0442\u0438\u043f\u0443\u0437\u043b\u0430dom \u0442\u0438\u043f\u0443\u0437\u043b\u0430xml \u0444\u043e\u0440\u043c\u0430xml \u0444\u043e\u0440\u043c\u0430\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044fxs \u0444\u043e\u0440\u043c\u0430\u0442\u0434\u0430\u0442\u044bjson \u044d\u043a\u0440\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432json \u0432\u0438\u0434\u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0432\u043b\u043e\u0436\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u0435\u0439\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u043e\u0433\u043e\u043e\u0441\u0442\u0430\u0442\u043a\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0432\u044b\u0432\u043e\u0434\u0430\u0442\u0435\u043a\u0441\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0433\u0440\u0443\u043f\u043f\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u043e\u0442\u0431\u043e\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u043f\u043e\u043b\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043e\u0441\u0442\u0430\u0442\u043a\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0441\u0432\u044f\u0437\u0438\u043d\u0430\u0431\u043e\u0440\u043e\u0432\u0434\u0430\u043d\u043d\u044b\u0445\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043b\u0435\u0433\u0435\u043d\u0434\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043f\u0440\u0438\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0430\u0432\u0442\u043e\u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0444\u0438\u043a\u0441\u0430\u0446\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0443\u0441\u043b\u043e\u0432\u043d\u043e\u0433\u043e\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0430\u0436\u043d\u043e\u0441\u0442\u044c\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0442\u0435\u043a\u0441\u0442\u0430\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0432\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043d\u0435ascii\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0442\u0435\u043a\u0441\u0442\u0430\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u044b \u0441\u0442\u0430\u0442\u0443\u0441\u0440\u0430\u0437\u0431\u043e\u0440\u0430\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438\u0437\u0430\u043f\u0438\u0441\u0438\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0441\u0442\u0430\u0442\u0443\u0441\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438\u0437\u0430\u043f\u0438\u0441\u0438\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0442\u0438\u043f\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430\u0438\u043c\u0435\u043d\u0444\u0430\u0439\u043b\u043e\u0432\u0432zip\u0444\u0430\u0439\u043b\u0435 \u043c\u0435\u0442\u043e\u0434\u0441\u0436\u0430\u0442\u0438\u044fzip \u043c\u0435\u0442\u043e\u0434\u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044fzip \u0440\u0435\u0436\u0438\u043c\u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0443\u0442\u0435\u0439\u0444\u0430\u0439\u043b\u043e\u0432zip \u0440\u0435\u0436\u0438\u043c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u043f\u043e\u0434\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043e\u0432zip \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f\u043f\u0443\u0442\u0435\u0439zip \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0441\u0436\u0430\u0442\u0438\u044fzip \u0437\u0432\u0443\u043a\u043e\u0432\u043e\u0435\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430\u043a\u0441\u0442\u0440\u043e\u043a\u0435 \u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0432\u043f\u043e\u0442\u043e\u043a\u0435 \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u0431\u0430\u0439\u0442\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u043e\u0439\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0435\u0440\u0432\u0438\u0441\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0445\u043f\u043e\u043a\u0443\u043f\u043e\u043a \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u0444\u043e\u043d\u043e\u0432\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0442\u0438\u043f\u043f\u043e\u0434\u043f\u0438\u0441\u0447\u0438\u043a\u0430\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0445\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0437\u0430\u0449\u0438\u0449\u0435\u043d\u043d\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044fftp \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043e\u0440\u044f\u0434\u043a\u0430\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043c\u0438\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044c\u043d\u043e\u0439\u0442\u043e\u0447\u043a\u0438\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043e\u0431\u044a\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 http\u043c\u0435\u0442\u043e\u0434 \u0430\u0432\u0442\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0430\u0432\u0442\u043e\u043f\u0440\u0435\u0444\u0438\u043a\u0441\u043d\u043e\u043c\u0435\u0440\u0430\u0437\u0430\u0434\u0430\u0447\u0438 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u043e\u0433\u043e\u044f\u0437\u044b\u043a\u0430 \u0432\u0438\u0434\u0438\u0435\u0440\u0430\u0440\u0445\u0438\u0438 \u0432\u0438\u0434\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u044c\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0439\u043f\u0440\u0438\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439 \u0438\u043d\u0434\u0435\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0431\u0430\u0437\u044b\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0431\u044b\u0441\u0442\u0440\u043e\u0433\u043e\u0432\u044b\u0431\u043e\u0440\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0437\u0434\u0435\u043b\u044f\u0435\u043c\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u043f\u0435\u0440\u0430\u0442\u0438\u0432\u043d\u043e\u0435\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0432\u0438\u0434\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0432\u0438\u0434\u0430\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0437\u0430\u0434\u0430\u0447\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043b\u0430\u043d\u0430\u043e\u0431\u043c\u0435\u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u0447\u0435\u0442\u0430 \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0433\u0440\u0430\u043d\u0438\u0446\u044b\u043f\u0440\u0438\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u043d\u043e\u043c\u0435\u0440\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u043d\u043e\u043c\u0435\u0440\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0441\u0432\u0435\u0434\u0435\u043d\u0438\u0439 \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0432\u043e\u0437\u0432\u0440\u0430\u0449\u0430\u0435\u043c\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u043f\u043e\u0438\u0441\u043a\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u043f\u0440\u0438\u043d\u0430\u0434\u043b\u0435\u0436\u043d\u043e\u0441\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442\u0430 \u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u0438\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0438\u0441\u0438\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u043e\u0434\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0438\u043d\u0445\u0440\u043e\u043d\u043d\u044b\u0445\u0432\u044b\u0437\u043e\u0432\u043e\u0432\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u044b\u0438\u0432\u043d\u0435\u0448\u043d\u0438\u0445\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0433\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u044b\u0431\u043e\u0440\u0430\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0432\u043c\u0435\u0441\u0442\u0438\u043c\u043e\u0441\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0432\u043c\u0435\u0441\u0442\u0438\u043c\u043e\u0441\u0442\u0438\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u0440\u0435\u0436\u0438\u043c\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u043e\u0439\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u043f\u043b\u0430\u043d\u0430\u0441\u0447\u0435\u0442\u043e\u0432 \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u044b\u0431\u043e\u0440\u0430 \u0441\u043f\u043e\u0441\u043e\u0431\u043f\u043e\u0438\u0441\u043a\u0430\u0441\u0442\u0440\u043e\u043a\u0438\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0442\u0438\u043f\u0434\u0430\u043d\u043d\u044b\u0445\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043a\u043e\u0434\u0430\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u043a\u043e\u0434\u0430\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0437\u0430\u0434\u0430\u0447\u0438 \u0442\u0438\u043f\u0444\u043e\u0440\u043c\u044b \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0439 \u0432\u0430\u0436\u043d\u043e\u0441\u0442\u044c\u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b\u043f\u0440\u0438\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0444\u043e\u0440\u043c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u0448\u0440\u0438\u0444\u0442\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0439\u0434\u0430\u0442\u044b\u043d\u0430\u0447\u0430\u043b\u0430 \u0432\u0438\u0434\u0433\u0440\u0430\u043d\u0438\u0446\u044b \u0432\u0438\u0434\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0432\u0438\u0434\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0432\u0438\u0434\u0440\u0430\u043c\u043a\u0438 \u0432\u0438\u0434\u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0446\u0432\u0435\u0442\u0430 \u0432\u0438\u0434\u0447\u0438\u0441\u043b\u043e\u0432\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0448\u0440\u0438\u0444\u0442\u0430 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u0430\u044f\u0434\u043b\u0438\u043d\u0430 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439\u0437\u043d\u0430\u043a \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435byteordermark \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c\u0435\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043a\u043b\u0430\u0432\u0438\u0448\u0430 \u043a\u043e\u0434\u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430\u0434\u0438\u0430\u043b\u043e\u0433\u0430 \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430xbase \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430\u0442\u0435\u043a\u0441\u0442\u0430 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043e\u0438\u0441\u043a\u0430 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u043a\u0438 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0438\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0430\u043d\u0435\u043b\u0438\u0440\u0430\u0437\u0434\u0435\u043b\u043e\u0432 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u0432\u043e\u043f\u0440\u043e\u0441 \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0443\u0441\u043a\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u043a\u0440\u0443\u0433\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u0444\u043e\u0440\u043c\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u044b\u0431\u043e\u0440\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430windows \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0442\u0440\u043e\u043a\u0438 \u0441\u0442\u0430\u0442\u0443\u0441\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u0442\u0438\u043f\u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u044b \u0442\u0438\u043f\u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f\u043a\u043b\u0430\u0432\u0438\u0448\u0438enter \u0442\u0438\u043f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438\u043e\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0438\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0438\u0437\u043e\u043b\u044f\u0446\u0438\u0438\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0439 \u0445\u0435\u0448\u0444\u0443\u043d\u043a\u0446\u0438\u044f \u0447\u0430\u0441\u0442\u0438\u0434\u0430\u0442\u044b",
-type:"com\u043e\u0431\u044a\u0435\u043a\u0442 ftp\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 http\u0437\u0430\u043f\u0440\u043e\u0441 http\u0441\u0435\u0440\u0432\u0438\u0441\u043e\u0442\u0432\u0435\u0442 http\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 ws\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f ws\u043f\u0440\u043e\u043a\u0441\u0438 xbase \u0430\u043d\u0430\u043b\u0438\u0437\u0434\u0430\u043d\u043d\u044b\u0445 \u0430\u043d\u043d\u043e\u0442\u0430\u0446\u0438\u044fxs \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0443\u0444\u0435\u0440\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435xs \u0432\u044b\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0433\u0435\u043d\u0435\u0440\u0430\u0442\u043e\u0440\u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0445\u0447\u0438\u0441\u0435\u043b \u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0430\u044f\u0441\u0445\u0435\u043c\u0430 \u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0438\u0435\u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u044b \u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0430\u044f\u0441\u0445\u0435\u043c\u0430 \u0433\u0440\u0443\u043f\u043f\u0430\u043c\u043e\u0434\u0435\u043b\u0438xs \u0434\u0430\u043d\u043d\u044b\u0435\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0435\u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u0430 \u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430 \u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430\u0433\u0430\u043d\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0444\u0430\u0439\u043b\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0446\u0432\u0435\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0448\u0440\u0438\u0444\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u044f\u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0434\u0438\u0430\u043b\u043e\u0433\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442dom \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442html \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044fxs \u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u043e\u0435\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435 \u0437\u0430\u043f\u0438\u0441\u044cdom \u0437\u0430\u043f\u0438\u0441\u044cfastinfoset \u0437\u0430\u043f\u0438\u0441\u044chtml \u0437\u0430\u043f\u0438\u0441\u044cjson \u0437\u0430\u043f\u0438\u0441\u044cxml \u0437\u0430\u043f\u0438\u0441\u044czip\u0444\u0430\u0439\u043b\u0430 \u0437\u0430\u043f\u0438\u0441\u044c\u0434\u0430\u043d\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u044c\u0442\u0435\u043a\u0441\u0442\u0430 \u0437\u0430\u043f\u0438\u0441\u044c\u0443\u0437\u043b\u043e\u0432dom \u0437\u0430\u043f\u0440\u043e\u0441 \u0437\u0430\u0449\u0438\u0449\u0435\u043d\u043d\u043e\u0435\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435openssl \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u0435\u0439\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0437\u0432\u043b\u0435\u0447\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430 \u0438\u043c\u043f\u043e\u0440\u0442xs \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u0430 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0435\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u044b\u0439\u043f\u0440\u043e\u0444\u0438\u043b\u044c \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u0440\u043e\u043a\u0441\u0438 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u0434\u043b\u044f\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044fxs \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0442\u0435\u0440\u0430\u0442\u043e\u0440\u0443\u0437\u043b\u043e\u0432dom \u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0430 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0434\u0430\u0442\u044b \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0441\u0442\u0440\u043e\u043a\u0438 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0447\u0438\u0441\u043b\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u0449\u0438\u043a\u043c\u0430\u043a\u0435\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u0449\u0438\u043a\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u043c\u0430\u043a\u0435\u0442\u0430\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u0444\u043e\u0440\u043c\u0430\u0442\u043d\u043e\u0439\u0441\u0442\u0440\u043e\u043a\u0438 \u043b\u0438\u043d\u0438\u044f \u043c\u0430\u043a\u0435\u0442\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u043a\u0435\u0442\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u043a\u0435\u0442\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u0441\u043a\u0430xs \u043c\u0435\u043d\u0435\u0434\u0436\u0435\u0440\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u043d\u0430\u0431\u043e\u0440\u0441\u0445\u0435\u043cxml \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438json \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0431\u0445\u043e\u0434\u0434\u0435\u0440\u0435\u0432\u0430dom \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u043d\u043e\u0442\u0430\u0446\u0438\u0438xs \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430xs \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0434\u043e\u0441\u0442\u0443\u043f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u043e\u0442\u043a\u0430\u0437\u0432\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0434\u0430\u0432\u0430\u0435\u043c\u043e\u0433\u043e\u0444\u0430\u0439\u043b\u0430 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0442\u0438\u043f\u043e\u0432 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u043e\u0432xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u043c\u043e\u0434\u0435\u043b\u0438xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u0438\u0434\u0435\u043d\u0442\u0438\u0447\u043d\u043e\u0441\u0442\u0438xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0441\u0442\u0430\u0432\u043d\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0442\u0438\u043f\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430dom \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044fxpathxs \u043e\u0442\u0431\u043e\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u0430\u043a\u0435\u0442\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u043c\u044b\u0445\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0432\u044b\u0431\u043e\u0440\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0437\u0430\u043f\u0438\u0441\u0438json \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0437\u0430\u043f\u0438\u0441\u0438xml \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0447\u0442\u0435\u043d\u0438\u044fxml \u043f\u0435\u0440\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435xs \u043f\u043b\u0430\u043d\u0438\u0440\u043e\u0432\u0449\u0438\u043a \u043f\u043e\u043b\u0435\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044cdom \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u043e\u0442\u0447\u0435\u0442\u0430 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u043e\u0442\u0447\u0435\u0442\u0430\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u0441\u0445\u0435\u043cxml \u043f\u043e\u0442\u043e\u043a \u043f\u043e\u0442\u043e\u043a\u0432\u043f\u0430\u043c\u044f\u0442\u0438 \u043f\u043e\u0447\u0442\u0430 \u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0435\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435xsl \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043a\u043a\u0430\u043d\u043e\u043d\u0438\u0447\u0435\u0441\u043a\u043e\u043c\u0443xml \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u0432\u044b\u0432\u043e\u0434\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u043a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u044e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u0432\u044b\u0432\u043e\u0434\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0437\u044b\u043c\u0435\u043d\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0438\u043c\u0435\u043ddom \u0440\u0430\u043c\u043a\u0430 \u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u043e\u0435\u0438\u043c\u044fxml \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0447\u0442\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0432\u043e\u0434\u043d\u0430\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430 \u0441\u0432\u044f\u0437\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u0432\u044b\u0431\u043e\u0440\u0430 \u0441\u0432\u044f\u0437\u044c\u043f\u043e\u0442\u0438\u043f\u0443 \u0441\u0432\u044f\u0437\u044c\u043f\u043e\u0442\u0438\u043f\u0443\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0442\u043e\u0440xdto \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u043b\u0438\u0435\u043d\u0442\u0430windows \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u0444\u0430\u0439\u043b \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044b\u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u044e\u0449\u0438\u0445\u0446\u0435\u043d\u0442\u0440\u043e\u0432windows \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044b\u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u044e\u0449\u0438\u0445\u0446\u0435\u043d\u0442\u0440\u043e\u0432\u0444\u0430\u0439\u043b \u0441\u0436\u0430\u0442\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0438\u0441\u0442\u0435\u043c\u043d\u0430\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044e \u0441\u043e\u0447\u0435\u0442\u0430\u043d\u0438\u0435\u043a\u043b\u0430\u0432\u0438\u0448 \u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u0430\u044f\u0434\u0430\u0442\u0430\u043d\u0430\u0447\u0430\u043b\u0430 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439\u043f\u0435\u0440\u0438\u043e\u0434 \u0441\u0445\u0435\u043c\u0430xml \u0441\u0445\u0435\u043c\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0430\u0431\u043b\u0438\u0447\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0442\u0435\u0441\u0442\u0438\u0440\u0443\u0435\u043c\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u0438\u043f\u0434\u0430\u043d\u043d\u044b\u0445xml \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u0439\u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0444\u0430\u0431\u0440\u0438\u043a\u0430xdto \u0444\u0430\u0439\u043b \u0444\u0430\u0439\u043b\u043e\u0432\u044b\u0439\u043f\u043e\u0442\u043e\u043a \u0444\u0430\u0441\u0435\u0442\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u0430\u0440\u0430\u0437\u0440\u044f\u0434\u043e\u0432\u0434\u0440\u043e\u0431\u043d\u043e\u0439\u0447\u0430\u0441\u0442\u0438xs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0432\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0438\u0441\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0439\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0432\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0438\u0441\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0439\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043e\u0431\u0440\u0430\u0437\u0446\u0430xs \u0444\u0430\u0441\u0435\u0442\u043e\u0431\u0449\u0435\u0433\u043e\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u0430\u0440\u0430\u0437\u0440\u044f\u0434\u043e\u0432xs \u0444\u0430\u0441\u0435\u0442\u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0445\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432xs \u0444\u0438\u043b\u044c\u0442\u0440\u0443\u0437\u043b\u043e\u0432dom \u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f\u0441\u0442\u0440\u043e\u043a\u0430 \u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0444\u0440\u0430\u0433\u043c\u0435\u043d\u0442xs \u0445\u0435\u0448\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0446\u0432\u0435\u0442 \u0447\u0442\u0435\u043d\u0438\u0435fastinfoset \u0447\u0442\u0435\u043d\u0438\u0435html \u0447\u0442\u0435\u043d\u0438\u0435json \u0447\u0442\u0435\u043d\u0438\u0435xml \u0447\u0442\u0435\u043d\u0438\u0435zip\u0444\u0430\u0439\u043b\u0430 \u0447\u0442\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0447\u0442\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430 \u0447\u0442\u0435\u043d\u0438\u0435\u0443\u0437\u043b\u043e\u0432dom \u0448\u0440\u0438\u0444\u0442 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 comsafearray \u0434\u0435\u0440\u0435\u0432\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043c\u0430\u0441\u0441\u0438\u0432 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435 \u0441\u043f\u0438\u0441\u043e\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u0442\u0430\u0431\u043b\u0438\u0446\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0435\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u043c\u0430\u0441\u0441\u0438\u0432 ",
-literal:e},contains:[{className:"meta",begin:"#|&",end:"$",keywords:{$pattern:x,
-"meta-keyword":n+"\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u0432\u0435\u0431\u043a\u043b\u0438\u0435\u043d\u0442 \u0432\u043c\u0435\u0441\u0442\u043e \u0432\u043d\u0435\u0448\u043d\u0435\u0435\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u043a\u043b\u0438\u0435\u043d\u0442 \u043a\u043e\u043d\u0435\u0446\u043e\u0431\u043b\u0430\u0441\u0442\u0438 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043b\u0438\u0435\u043d\u0442 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u0435\u0440\u0432\u0435\u0440 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435\u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435\u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435\u0431\u0435\u0437\u043a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u0430 \u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435 \u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435\u0431\u0435\u0437\u043a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u0430 \u043e\u0431\u043b\u0430\u0441\u0442\u044c \u043f\u0435\u0440\u0435\u0434 \u043f\u043e\u0441\u043b\u0435 \u0441\u0435\u0440\u0432\u0435\u0440 \u0442\u043e\u043b\u0441\u0442\u044b\u0439\u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0431\u044b\u0447\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u043e\u043b\u0441\u0442\u044b\u0439\u043a\u043b\u0438\u0435\u043d\u0442\u0443\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u043c\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u043e\u043d\u043a\u0438\u0439\u043a\u043b\u0438\u0435\u043d\u0442 "
-},contains:[m]},{className:"function",variants:[{
-begin:"\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u0430|\u0444\u0443\u043d\u043a\u0446\u0438\u044f",
-end:"\\)",
-keywords:"\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u0430 \u0444\u0443\u043d\u043a\u0446\u0438\u044f"
-},{
-begin:"\u043a\u043e\u043d\u0435\u0446\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b|\u043a\u043e\u043d\u0435\u0446\u0444\u0443\u043d\u043a\u0446\u0438\u0438",
-keywords:"\u043a\u043e\u043d\u0435\u0446\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b \u043a\u043e\u043d\u0435\u0446\u0444\u0443\u043d\u043a\u0446\u0438\u0438"
-}],contains:[{begin:"\\(",end:"\\)",endsParent:!0,contains:[{className:"params",
-begin:x,end:",",excludeEnd:!0,endsWithParent:!0,keywords:{$pattern:x,
-keyword:"\u0437\u043d\u0430\u0447",literal:e},contains:[o,t,a]},m]
-},s.inherit(s.TITLE_MODE,{begin:x})]},m,{className:"symbol",begin:"~",end:";|:",
-excludeEnd:!0},o,t,a]}}})());
-hljs.registerLanguage("abnf",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(a=e)?"string"==typeof a?a:a.source:null;var a
-})).join("")}return a=>{const s={ruleDeclaration:/^[a-zA-Z][a-zA-Z0-9-]*/,
-unexpectedChars:/[!@#$^&',?+~`|:]/},n=a.COMMENT(/;/,/$/),r={
-className:"attribute",begin:e(s.ruleDeclaration,/(?=\s*=)/)};return{
-name:"Augmented Backus-Naur Form",illegal:s.unexpectedChars,
-keywords:["ALPHA","BIT","CHAR","CR","CRLF","CTL","DIGIT","DQUOTE","HEXDIG","HTAB","LF","LWSP","OCTET","SP","VCHAR","WSP"],
-contains:[r,n,{className:"symbol",begin:/%b[0-1]+(-[0-1]+|(\.[0-1]+)+){0,1}/},{
-className:"symbol",begin:/%d[0-9]+(-[0-9]+|(\.[0-9]+)+){0,1}/},{
-className:"symbol",begin:/%x[0-9A-F]+(-[0-9A-F]+|(\.[0-9A-F]+)+){0,1}/},{
-className:"symbol",begin:/%[si]/},a.QUOTE_STRING_MODE,a.NUMBER_MODE]}}})());
-hljs.registerLanguage("accesslog",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}function a(...n){
-return"("+n.map((n=>e(n))).join("|")+")"}return e=>{
-const l=["GET","POST","HEAD","PUT","DELETE","CONNECT","OPTIONS","PATCH","TRACE"]
-;return{name:"Apache Access Log",contains:[{className:"number",
-begin:/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?\b/,relevance:5},{
-className:"number",begin:/\b\d+\b/,relevance:0},{className:"string",
-begin:n(/"/,a(...l)),end:/"/,keywords:l,illegal:/\n/,relevance:5,contains:[{
-begin:/HTTP\/[12]\.\d'/,relevance:5}]},{className:"string",
-begin:/\[\d[^\]\n]{8,}\]/,illegal:/\n/,relevance:1},{className:"string",
-begin:/\[/,end:/\]/,illegal:/\n/,relevance:0},{className:"string",
-begin:/"Mozilla\/\d\.\d \(/,end:/"/,illegal:/\n/,relevance:3},{
-className:"string",begin:/"/,end:/"/,illegal:/\n/,relevance:0}]}}})());
-hljs.registerLanguage("actionscript",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>({name:"ActionScript",aliases:["as"],keywords:{
-keyword:"as break case catch class const continue default delete do dynamic each else extends final finally for function get if implements import in include instanceof interface internal is namespace native new override package private protected public return set static super switch this throw try typeof use var void while with",
-literal:"true false null undefined"},
-contains:[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.C_NUMBER_MODE,{
-className:"class",beginKeywords:"package",end:/\{/,contains:[n.TITLE_MODE]},{
-className:"class",beginKeywords:"class interface",end:/\{/,excludeEnd:!0,
-contains:[{beginKeywords:"extends implements"},n.TITLE_MODE]},{className:"meta",
-beginKeywords:"import include",end:/;/,keywords:{"meta-keyword":"import include"
-}},{className:"function",beginKeywords:"function",end:/[{;]/,excludeEnd:!0,
-illegal:/\S/,contains:[n.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,
-contains:[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,{
-className:"rest_arg",begin:/[.]{3}/,end:/[a-zA-Z_$][a-zA-Z0-9_$]*/,relevance:10
-}]},{begin:e(/:\s*/,/([*]|[a-zA-Z_$][a-zA-Z0-9_$]*)/)}]},n.METHOD_GUARD],
-illegal:/#/})})());
-hljs.registerLanguage("ada",(()=>{"use strict";return e=>{
-const n="[A-Za-z](_?[A-Za-z0-9.])*",s="[]\\{\\}%#'\"",a=e.COMMENT("--","$"),r={
-begin:"\\s+:\\s+",end:"\\s*(:=|;|\\)|=>|$)",illegal:s,contains:[{
-beginKeywords:"loop for declare others",endsParent:!0},{className:"keyword",
-beginKeywords:"not null constant access function procedure in out aliased exception"
-},{className:"type",begin:n,endsParent:!0,relevance:0}]};return{name:"Ada",
-case_insensitive:!0,keywords:{
-keyword:"abort else new return abs elsif not reverse abstract end accept entry select access exception of separate aliased exit or some all others subtype and for out synchronized array function overriding at tagged generic package task begin goto pragma terminate body private then if procedure type case in protected constant interface is raise use declare range delay limited record when delta loop rem while digits renames with do mod requeue xor",
-literal:"True False"},contains:[a,{className:"string",begin:/"/,end:/"/,
-contains:[{begin:/""/,relevance:0}]},{className:"string",begin:/'.'/},{
-className:"number",
-begin:"\\b(\\d(_|\\d)*#\\w+(\\.\\w+)?#([eE][-+]?\\d(_|\\d)*)?|\\d(_|\\d)*(\\.\\d(_|\\d)*)?([eE][-+]?\\d(_|\\d)*)?)",
-relevance:0},{className:"symbol",begin:"'"+n},{className:"title",
-begin:"(\\bwith\\s+)?(\\bprivate\\s+)?\\bpackage\\s+(\\bbody\\s+)?",
-end:"(is|$)",keywords:"package body",excludeBegin:!0,excludeEnd:!0,illegal:s},{
-begin:"(\\b(with|overriding)\\s+)?\\b(function|procedure)\\s+",
-end:"(\\bis|\\bwith|\\brenames|\\)\\s*;)",
-keywords:"overriding function procedure with is renames return",returnBegin:!0,
-contains:[a,{className:"title",
-begin:"(\\bwith\\s+)?\\b(function|procedure)\\s+",end:"(\\(|\\s+|$)",
-excludeBegin:!0,excludeEnd:!0,illegal:s},r,{className:"type",
-begin:"\\breturn\\s+",end:"(\\s+|;|$)",keywords:"return",excludeBegin:!0,
-excludeEnd:!0,endsParent:!0,illegal:s}]},{className:"type",
-begin:"\\b(sub)?type\\s+",end:"\\s+",keywords:"type",excludeBegin:!0,illegal:s
-},r]}}})());
-hljs.registerLanguage("angelscript",(()=>{"use strict";return e=>{var n={
-className:"built_in",
-begin:"\\b(void|bool|int|int8|int16|int32|int64|uint|uint8|uint16|uint32|uint64|string|ref|array|double|float|auto|dictionary)"
-},a={className:"symbol",begin:"[a-zA-Z0-9_]+@"},i={className:"keyword",
-begin:"<",end:">",contains:[n,a]};return n.contains=[i],a.contains=[i],{
-name:"AngelScript",aliases:["asc"],
-keywords:"for in|0 break continue while do|0 return if else case switch namespace is cast or and xor not get|0 in inout|10 out override set|0 private public const default|0 final shared external mixin|10 enum typedef funcdef this super import from interface abstract|0 try catch protected explicit property",
-illegal:"(^using\\s+[A-Za-z0-9_\\.]+;$|\\bfunction\\s*[^\\(])",contains:[{
-className:"string",begin:"'",end:"'",illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE],relevance:0},{className:"string",begin:'"""',
-end:'"""'},{className:"string",begin:'"',end:'"',illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE],relevance:0
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",
-begin:"^\\s*\\[",end:"\\]"},{beginKeywords:"interface namespace",end:/\{/,
-illegal:"[;.\\-]",contains:[{className:"symbol",begin:"[a-zA-Z0-9_]+"}]},{
-beginKeywords:"class",end:/\{/,illegal:"[;.\\-]",contains:[{className:"symbol",
-begin:"[a-zA-Z0-9_]+",contains:[{begin:"[:,]\\s*",contains:[{className:"symbol",
-begin:"[a-zA-Z0-9_]+"}]}]}]},n,a,{className:"literal",
-begin:"\\b(null|true|false)"},{className:"number",relevance:0,
-begin:"(-?)(\\b0[xXbBoOdD][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?f?|\\.\\d+f?)([eE][-+]?\\d+f?)?)"
-}]}}})());
-hljs.registerLanguage("apache",(()=>{"use strict";return e=>{const n={
-className:"number",begin:/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?/}
-;return{name:"Apache config",aliases:["apacheconf"],case_insensitive:!0,
-contains:[e.HASH_COMMENT_MODE,{className:"section",begin:/<\/?/,end:/>/,
-contains:[n,{className:"number",begin:/:\d{1,5}/
-},e.inherit(e.QUOTE_STRING_MODE,{relevance:0})]},{className:"attribute",
-begin:/\w+/,relevance:0,keywords:{
-nomarkup:"order deny allow setenv rewriterule rewriteengine rewritecond documentroot sethandler errordocument loadmodule options header listen serverroot servername"
-},starts:{end:/$/,relevance:0,keywords:{literal:"on off all deny allow"},
-contains:[{className:"meta",begin:/\s\[/,end:/\]$/},{className:"variable",
-begin:/[\$%]\{/,end:/\}/,contains:["self",{className:"number",begin:/[$%]\d+/}]
-},n,{className:"number",begin:/\d+/},e.QUOTE_STRING_MODE]}}],illegal:/\S/}}
-})());
-hljs.registerLanguage("applescript",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function t(...t){
-return t.map((t=>e(t))).join("")}function n(...t){
-return"("+t.map((t=>e(t))).join("|")+")"}return e=>{
-const r=e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),i={className:"params",
-begin:/\(/,end:/\)/,contains:["self",e.C_NUMBER_MODE,r]
-},o=e.COMMENT(/--/,/$/),a=[o,e.COMMENT(/\(\*/,/\*\)/,{contains:["self",o]
-}),e.HASH_COMMENT_MODE];return{name:"AppleScript",aliases:["osascript"],
-keywords:{
-keyword:"about above after against and around as at back before beginning behind below beneath beside between but by considering contain contains continue copy div does eighth else end equal equals error every exit fifth first for fourth from front get given global if ignoring in into is it its last local me middle mod my ninth not of on onto or over prop property put ref reference repeat returning script second set seventh since sixth some tell tenth that the|0 then third through thru timeout times to transaction try until where while whose with without",
-literal:"AppleScript false linefeed return pi quote result space tab true",
-built_in:"alias application boolean class constant date file integer list number real record string text activate beep count delay launch log offset read round run say summarize write character characters contents day frontmost id item length month name paragraph paragraphs rest reverse running time version weekday word words year"
-},contains:[r,e.C_NUMBER_MODE,{className:"built_in",
-begin:t(/\b/,n(/clipboard info/,/the clipboard/,/info for/,/list (disks|folder)/,/mount volume/,/path to/,/(close|open for) access/,/(get|set) eof/,/current date/,/do shell script/,/get volume settings/,/random number/,/set volume/,/system attribute/,/system info/,/time to GMT/,/(load|run|store) script/,/scripting components/,/ASCII (character|number)/,/localized string/,/choose (application|color|file|file name|folder|from list|remote application|URL)/,/display (alert|dialog)/),/\b/)
-},{className:"built_in",begin:/^\s*return\b/},{className:"literal",
-begin:/\b(text item delimiters|current application|missing value)\b/},{
-className:"keyword",
-begin:t(/\b/,n(/apart from/,/aside from/,/instead of/,/out of/,/greater than/,/isn't|(doesn't|does not) (equal|come before|come after|contain)/,/(greater|less) than( or equal)?/,/(starts?|ends|begins?) with/,/contained by/,/comes (before|after)/,/a (ref|reference)/,/POSIX (file|path)/,/(date|time) string/,/quoted form/),/\b/)
-},{beginKeywords:"on",illegal:/[${=;\n]/,contains:[e.UNDERSCORE_TITLE_MODE,i]
-},...a],illegal:/\/\/|->|=>|\[\[/}}})());
-hljs.registerLanguage("arcade",(()=>{"use strict";return e=>{
-const n="[A-Za-z_][0-9A-Za-z_]*",a={
-keyword:"if for while var new function do return void else break",
-literal:"BackSlash DoubleQuote false ForwardSlash Infinity NaN NewLine null PI SingleQuote Tab TextFormatting true undefined",
-built_in:"Abs Acos Angle Attachments Area AreaGeodetic Asin Atan Atan2 Average Bearing Boolean Buffer BufferGeodetic Ceil Centroid Clip Console Constrain Contains Cos Count Crosses Cut Date DateAdd DateDiff Day Decode DefaultValue Dictionary Difference Disjoint Distance DistanceGeodetic Distinct DomainCode DomainName Equals Exp Extent Feature FeatureSet FeatureSetByAssociation FeatureSetById FeatureSetByPortalItem FeatureSetByRelationshipName FeatureSetByTitle FeatureSetByUrl Filter First Floor Geometry GroupBy Guid HasKey Hour IIf IndexOf Intersection Intersects IsEmpty IsNan IsSelfIntersecting Length LengthGeodetic Log Max Mean Millisecond Min Minute Month MultiPartToSinglePart Multipoint NextSequenceValue Now Number OrderBy Overlaps Point Polygon Polyline Portal Pow Random Relate Reverse RingIsClockWise Round Second SetGeometry Sin Sort Sqrt Stdev Sum SymmetricDifference Tan Text Timestamp Today ToLocal Top Touches ToUTC TrackCurrentTime TrackGeometryWindow TrackIndex TrackStartTime TrackWindow TypeOf Union UrlEncode Variance Weekday When Within Year "
-},t={className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{
-begin:"\\b(0[oO][0-7]+)"},{begin:e.C_NUMBER_RE}],relevance:0},i={
-className:"subst",begin:"\\$\\{",end:"\\}",keywords:a,contains:[]},r={
-className:"string",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE,i]}
-;i.contains=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,t,e.REGEXP_MODE]
-;const o=i.contains.concat([e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE])
-;return{name:"ArcGIS Arcade",keywords:a,
-contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-className:"symbol",
-begin:"\\$[datastore|feature|layer|map|measure|sourcefeature|sourcelayer|targetfeature|targetlayer|value|view]+"
-},t,{begin:/[{,]\s*/,relevance:0,contains:[{begin:n+"\\s*:",returnBegin:!0,
-relevance:0,contains:[{className:"attr",begin:n,relevance:0}]}]},{
-begin:"("+e.RE_STARTERS_RE+"|\\b(return)\\b)\\s*",keywords:"return",
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.REGEXP_MODE,{
-className:"function",begin:"(\\(.*?\\)|"+n+")\\s*=>",returnBegin:!0,
-end:"\\s*=>",contains:[{className:"params",variants:[{begin:n},{begin:/\(\s*\)/
-},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:a,contains:o}]}]
-}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,
-excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:n}),{className:"params",
-begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:o}],illegal:/\[|%/},{
-begin:/\$[(.]/}],illegal:/#(?!!)/}}})());
-hljs.registerLanguage("arduino",(()=>{"use strict";function e(e){
-return t("(",e,")?")}function t(...e){return e.map((e=>{
-return(t=e)?"string"==typeof t?t:t.source:null;var t})).join("")}return r=>{
-const n=(r=>{const n=r.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),i="[a-zA-Z_]\\w*::",a="(decltype\\(auto\\)|"+e(i)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",s={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",
-variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[r.BACKSLASH_ESCAPE]},{
-begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},r.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={
-className:"number",variants:[{begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
-},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},r.inherit(o,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/},n,r.C_BLOCK_COMMENT_MODE]},d={
-className:"title",begin:e(i)+r.IDENT_RE,relevance:0
-},u=e(i)+r.IDENT_RE+"\\s*\\(",m={
-keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"_Bool _Complex _Imaginary",
-_relevance_hints:["asin","atan2","atan","calloc","ceil","cosh","cos","exit","exp","fabs","floor","fmod","fprintf","fputs","free","frexp","auto_ptr","deque","list","queue","stack","vector","map","set","pair","bitset","multiset","multimap","unordered_set","fscanf","future","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","tolower","toupper","labs","ldexp","log10","log","malloc","realloc","memchr","memcmp","memcpy","memset","modf","pow","printf","putchar","puts","scanf","sinh","sin","snprintf","sprintf","sqrt","sscanf","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","tanh","tan","unordered_map","unordered_multiset","unordered_multimap","priority_queue","make_pair","array","shared_ptr","abort","terminate","abs","acos","vfprintf","vprintf","vsprintf","endl","initializer_list","unique_ptr","complex","imaginary","std","string","wstring","cin","cout","cerr","clog","stdin","stdout","stderr","stringstream","istringstream","ostringstream"],
-literal:"true false nullptr NULL"},p={className:"function.dispatch",relevance:0,
-keywords:m,
-begin:t(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,r.IDENT_RE,(g=/\s*\(/,
-t("(?=",g,")")))};var g;const b=[p,c,s,n,r.C_BLOCK_COMMENT_MODE,l,o],_={
-variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:m,contains:b.concat([{
-begin:/\(/,end:/\)/,keywords:m,contains:b.concat(["self"]),relevance:0}]),
-relevance:0},y={className:"function",begin:"("+a+"[\\*&\\s]+)+"+u,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:m,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:m,relevance:0},{begin:u,
-returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
-endsWithParent:!0,contains:[o,l]},{className:"params",begin:/\(/,end:/\)/,
-keywords:m,relevance:0,contains:[n,r.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,
-end:/\)/,keywords:m,relevance:0,contains:["self",n,r.C_BLOCK_COMMENT_MODE,o,l,s]
-}]},s,n,r.C_BLOCK_COMMENT_MODE,c]};return{name:"C++",
-aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:m,illegal:"</",
-classNameAliases:{"function.dispatch":"built_in"},
-contains:[].concat(_,y,p,b,[c,{
-begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:m,contains:["self",s]},{begin:r.IDENT_RE+"::",keywords:m},{
-className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},r.TITLE_MODE]}]),exports:{
-preprocessor:c,strings:o,keywords:m}}})(r),i=n.keywords
-;return i.keyword+=" boolean byte word String",
-i.literal+=" DIGITAL_MESSAGE FIRMATA_STRING ANALOG_MESSAGE REPORT_DIGITAL REPORT_ANALOG INPUT_PULLUP SET_PIN_MODE INTERNAL2V56 SYSTEM_RESET LED_BUILTIN INTERNAL1V1 SYSEX_START INTERNAL EXTERNAL DEFAULT OUTPUT INPUT HIGH LOW",
-i.built_in+=" KeyboardController MouseController SoftwareSerial EthernetServer EthernetClient LiquidCrystal RobotControl GSMVoiceCall EthernetUDP EsploraTFT HttpClient RobotMotor WiFiClient GSMScanner FileSystem Scheduler GSMServer YunClient YunServer IPAddress GSMClient GSMModem Keyboard Ethernet Console GSMBand Esplora Stepper Process WiFiUDP GSM_SMS Mailbox USBHost Firmata PImage Client Server GSMPIN FileIO Bridge Serial EEPROM Stream Mouse Audio Servo File Task GPRS WiFi Wire TFT GSM SPI SD ",
-i._+=" setup loop runShellCommandAsynchronously analogWriteResolution retrieveCallingNumber printFirmwareVersion analogReadResolution sendDigitalPortPair noListenOnLocalhost readJoystickButton setFirmwareVersion readJoystickSwitch scrollDisplayRight getVoiceCallStatus scrollDisplayLeft writeMicroseconds delayMicroseconds beginTransmission getSignalStrength runAsynchronously getAsynchronously listenOnLocalhost getCurrentCarrier readAccelerometer messageAvailable sendDigitalPorts lineFollowConfig countryNameWrite runShellCommand readStringUntil rewindDirectory readTemperature setClockDivider readLightSensor endTransmission analogReference detachInterrupt countryNameRead attachInterrupt encryptionType readBytesUntil robotNameWrite readMicrophone robotNameRead cityNameWrite userNameWrite readJoystickY readJoystickX mouseReleased openNextFile scanNetworks noInterrupts digitalWrite beginSpeaker mousePressed isActionDone mouseDragged displayLogos noAutoscroll addParameter remoteNumber getModifiers keyboardRead userNameRead waitContinue processInput parseCommand printVersion readNetworks writeMessage blinkVersion cityNameRead readMessage setDataMode parsePacket isListening setBitOrder beginPacket isDirectory motorsWrite drawCompass digitalRead clearScreen serialEvent rightToLeft setTextSize leftToRight requestFrom keyReleased compassRead analogWrite interrupts WiFiServer disconnect playMelody parseFloat autoscroll getPINUsed setPINUsed setTimeout sendAnalog readSlider analogRead beginWrite createChar motorsStop keyPressed tempoWrite readButton subnetMask debugPrint macAddress writeGreen randomSeed attachGPRS readString sendString remotePort releaseAll mouseMoved background getXChange getYChange answerCall getResult voiceCall endPacket constrain getSocket writeJSON getButton available connected findUntil readBytes exitValue readGreen writeBlue startLoop IPAddress isPressed sendSysex pauseMode gatewayIP setCursor getOemKey tuneWrite noDisplay loadImage switchPIN onRequest onReceive changePIN playFile noBuffer parseInt overflow checkPIN knobRead beginTFT bitClear updateIR bitWrite position writeRGB highByte writeRed setSpeed readBlue noStroke remoteIP transfer shutdown hangCall beginSMS endWrite attached maintain noCursor checkReg checkPUK shiftOut isValid shiftIn pulseIn connect println localIP pinMode getIMEI display noBlink process getBand running beginSD drawBMP lowByte setBand release bitRead prepare pointTo readRed setMode noFill remove listen stroke detach attach noTone exists buffer height bitSet circle config cursor random IRread setDNS endSMS getKey micros millis begin print write ready flush width isPIN blink clear press mkdir rmdir close point yield image BSSID click delay read text move peek beep rect line open seek fill size turn stop home find step tone sqrt RSSI SSID end bit tan cos sin pow map abs max min get run put",
-n.name="Arduino",n.aliases=["ino"],n.supersetOf="cpp",n}})());
-hljs.registerLanguage("armasm",(()=>{"use strict";return s=>{const e={
-variants:[s.COMMENT("^[ \\t]*(?=#)","$",{relevance:0,excludeBegin:!0
-}),s.COMMENT("[;@]","$",{relevance:0
-}),s.C_LINE_COMMENT_MODE,s.C_BLOCK_COMMENT_MODE]};return{name:"ARM Assembly",
-case_insensitive:!0,aliases:["arm"],keywords:{$pattern:"\\.?"+s.IDENT_RE,
-meta:".2byte .4byte .align .ascii .asciz .balign .byte .code .data .else .end .endif .endm .endr .equ .err .exitm .extern .global .hword .if .ifdef .ifndef .include .irp .long .macro .rept .req .section .set .skip .space .text .word .arm .thumb .code16 .code32 .force_thumb .thumb_func .ltorg ALIAS ALIGN ARM AREA ASSERT ATTR CN CODE CODE16 CODE32 COMMON CP DATA DCB DCD DCDU DCDO DCFD DCFDU DCI DCQ DCQU DCW DCWU DN ELIF ELSE END ENDFUNC ENDIF ENDP ENTRY EQU EXPORT EXPORTAS EXTERN FIELD FILL FUNCTION GBLA GBLL GBLS GET GLOBAL IF IMPORT INCBIN INCLUDE INFO KEEP LCLA LCLL LCLS LTORG MACRO MAP MEND MEXIT NOFP OPT PRESERVE8 PROC QN READONLY RELOC REQUIRE REQUIRE8 RLIST FN ROUT SETA SETL SETS SN SPACE SUBT THUMB THUMBX TTL WHILE WEND ",
-built_in:"r0 r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 r12 r13 r14 r15 pc lr sp ip sl sb fp a1 a2 a3 a4 v1 v2 v3 v4 v5 v6 v7 v8 f0 f1 f2 f3 f4 f5 f6 f7 p0 p1 p2 p3 p4 p5 p6 p7 p8 p9 p10 p11 p12 p13 p14 p15 c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11 c12 c13 c14 c15 q0 q1 q2 q3 q4 q5 q6 q7 q8 q9 q10 q11 q12 q13 q14 q15 cpsr_c cpsr_x cpsr_s cpsr_f cpsr_cx cpsr_cxs cpsr_xs cpsr_xsf cpsr_sf cpsr_cxsf spsr_c spsr_x spsr_s spsr_f spsr_cx spsr_cxs spsr_xs spsr_xsf spsr_sf spsr_cxsf s0 s1 s2 s3 s4 s5 s6 s7 s8 s9 s10 s11 s12 s13 s14 s15 s16 s17 s18 s19 s20 s21 s22 s23 s24 s25 s26 s27 s28 s29 s30 s31 d0 d1 d2 d3 d4 d5 d6 d7 d8 d9 d10 d11 d12 d13 d14 d15 d16 d17 d18 d19 d20 d21 d22 d23 d24 d25 d26 d27 d28 d29 d30 d31 {PC} {VAR} {TRUE} {FALSE} {OPT} {CONFIG} {ENDIAN} {CODESIZE} {CPU} {FPU} {ARCHITECTURE} {PCSTOREOFFSET} {ARMASM_VERSION} {INTER} {ROPI} {RWPI} {SWST} {NOSWST} . @"
-},contains:[{className:"keyword",
-begin:"\\b(adc|(qd?|sh?|u[qh]?)?add(8|16)?|usada?8|(q|sh?|u[qh]?)?(as|sa)x|and|adrl?|sbc|rs[bc]|asr|b[lx]?|blx|bxj|cbn?z|tb[bh]|bic|bfc|bfi|[su]bfx|bkpt|cdp2?|clz|clrex|cmp|cmn|cpsi[ed]|cps|setend|dbg|dmb|dsb|eor|isb|it[te]{0,3}|lsl|lsr|ror|rrx|ldm(([id][ab])|f[ds])?|ldr((s|ex)?[bhd])?|movt?|mvn|mra|mar|mul|[us]mull|smul[bwt][bt]|smu[as]d|smmul|smmla|mla|umlaal|smlal?([wbt][bt]|d)|mls|smlsl?[ds]|smc|svc|sev|mia([bt]{2}|ph)?|mrr?c2?|mcrr2?|mrs|msr|orr|orn|pkh(tb|bt)|rbit|rev(16|sh)?|sel|[su]sat(16)?|nop|pop|push|rfe([id][ab])?|stm([id][ab])?|str(ex)?[bhd]?|(qd?)?sub|(sh?|q|u[qh]?)?sub(8|16)|[su]xt(a?h|a?b(16)?)|srs([id][ab])?|swpb?|swi|smi|tst|teq|wfe|wfi|yield)(eq|ne|cs|cc|mi|pl|vs|vc|hi|ls|ge|lt|gt|le|al|hs|lo)?[sptrx]?(?=\\s)"
-},e,s.QUOTE_STRING_MODE,{className:"string",begin:"'",end:"[^\\\\]'",relevance:0
-},{className:"title",begin:"\\|",end:"\\|",illegal:"\\n",relevance:0},{
-className:"number",variants:[{begin:"[#$=]?0x[0-9a-f]+"},{begin:"[#$=]?0b[01]+"
-},{begin:"[#$=]\\d+"},{begin:"\\b\\d+"}],relevance:0},{className:"symbol",
-variants:[{begin:"^[ \\t]*[a-z_\\.\\$][a-z0-9_\\.\\$]+:"},{
-begin:"^[a-z_\\.\\$][a-z0-9_\\.\\$]+"},{begin:"[=#]\\w+"}],relevance:0}]}}})());
-hljs.registerLanguage("xml",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(e){return a("(?=",e,")")}
-function a(...n){return n.map((n=>e(n))).join("")}function s(...n){
-return"("+n.map((n=>e(n))).join("|")+")"}return e=>{
-const t=a(/[A-Z_]/,a("(",/[A-Z0-9_.-]*:/,")?"),/[A-Z0-9_.-]*/),i={
-className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},r={begin:/\s/,
-contains:[{className:"meta-keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}]
-},c=e.inherit(r,{begin:/\(/,end:/\)/}),l=e.inherit(e.APOS_STRING_MODE,{
-className:"meta-string"}),g=e.inherit(e.QUOTE_STRING_MODE,{
-className:"meta-string"}),m={endsWithParent:!0,illegal:/</,relevance:0,
-contains:[{className:"attr",begin:/[A-Za-z0-9._:-]+/,relevance:0},{begin:/=\s*/,
-relevance:0,contains:[{className:"string",endsParent:!0,variants:[{begin:/"/,
-end:/"/,contains:[i]},{begin:/'/,end:/'/,contains:[i]},{begin:/[^\s"'=<>`]+/}]}]
-}]};return{name:"HTML, XML",
-aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],
-case_insensitive:!0,contains:[{className:"meta",begin:/<![a-z]/,end:/>/,
-relevance:10,contains:[r,g,l,c,{begin:/\[/,end:/\]/,contains:[{className:"meta",
-begin:/<![a-z]/,end:/>/,contains:[r,c,g,l]}]}]},e.COMMENT(/<!--/,/-->/,{
-relevance:10}),{begin:/<!\[CDATA\[/,end:/\]\]>/,relevance:10},i,{
-className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",
-begin:/<style(?=\s|>)/,end:/>/,keywords:{name:"style"},contains:[m],starts:{
-end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",
-begin:/<script(?=\s|>)/,end:/>/,keywords:{name:"script"},contains:[m],starts:{
-end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{
-className:"tag",begin:/<>|<\/>/},{className:"tag",
-begin:a(/</,n(a(t,s(/\/>/,/>/,/\s/)))),end:/\/?>/,contains:[{className:"name",
-begin:t,relevance:0,starts:m}]},{className:"tag",begin:a(/<\//,n(a(t,/>/))),
-contains:[{className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,
-endsParent:!0}]}]}}})());
-hljs.registerLanguage("asciidoc",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a=[{className:"strong",begin:/\*{2}([^\n]+?)\*{2}/
-},{className:"strong",
-begin:e(/\*\*/,/((\*(?!\*)|\\[^\n]|[^*\n\\])+\n)+/,/(\*(?!\*)|\\[^\n]|[^*\n\\])*/,/\*\*/),
-relevance:0},{className:"strong",begin:/\B\*(\S|\S[^\n]*?\S)\*(?!\w)/},{
-className:"strong",begin:/\*[^\s]([^\n]+\n)+([^\n]+)\*/}],s=[{
-className:"emphasis",begin:/_{2}([^\n]+?)_{2}/},{className:"emphasis",
-begin:e(/__/,/((_(?!_)|\\[^\n]|[^_\n\\])+\n)+/,/(_(?!_)|\\[^\n]|[^_\n\\])*/,/__/),
-relevance:0},{className:"emphasis",begin:/\b_(\S|\S[^\n]*?\S)_(?!\w)/},{
-className:"emphasis",begin:/_[^\s]([^\n]+\n)+([^\n]+)_/},{className:"emphasis",
-begin:"\\B'(?!['\\s])",end:"(\\n{2}|')",contains:[{begin:"\\\\'\\w",relevance:0
-}],relevance:0}];return{name:"AsciiDoc",aliases:["adoc"],
-contains:[n.COMMENT("^/{4,}\\n","\\n/{4,}$",{relevance:10
-}),n.COMMENT("^//","$",{relevance:0}),{className:"title",begin:"^\\.\\w.*$"},{
-begin:"^[=\\*]{4,}\\n",end:"\\n^[=\\*]{4,}$",relevance:10},{className:"section",
-relevance:10,variants:[{begin:"^(={1,6})[ \t].+?([ \t]\\1)?$"},{
-begin:"^[^\\[\\]\\n]+?\\n[=\\-~\\^\\+]{2,}$"}]},{className:"meta",
-begin:"^:.+?:",end:"\\s",excludeEnd:!0,relevance:10},{className:"meta",
-begin:"^\\[.+?\\]$",relevance:0},{className:"quote",begin:"^_{4,}\\n",
-end:"\\n_{4,}$",relevance:10},{className:"code",begin:"^[\\-\\.]{4,}\\n",
-end:"\\n[\\-\\.]{4,}$",relevance:10},{begin:"^\\+{4,}\\n",end:"\\n\\+{4,}$",
-contains:[{begin:"<",end:">",subLanguage:"xml",relevance:0}],relevance:10},{
-className:"bullet",begin:"^(\\*+|-+|\\.+|[^\\n]+?::)\\s+"},{className:"symbol",
-begin:"^(NOTE|TIP|IMPORTANT|WARNING|CAUTION):\\s+",relevance:10},{
-begin:/\\[*_`]/},{begin:/\\\\\*{2}[^\n]*?\*{2}/},{begin:/\\\\_{2}[^\n]*_{2}/},{
-begin:/\\\\`{2}[^\n]*`{2}/},{begin:/[:;}][*_`](?![*_`])/},...a,...s,{
-className:"string",variants:[{begin:"``.+?''"},{begin:"`.+?'"}]},{
-className:"code",begin:/`{2}/,end:/(\n{2}|`{2})/},{className:"code",
-begin:"(`.+?`|\\+.+?\\+)",relevance:0},{className:"code",begin:"^[ \\t]",
-end:"$",relevance:0},{begin:"^'{3,}[ \\t]*$",relevance:10},{
-begin:"(link:)?(http|https|ftp|file|irc|image:?):\\S+?\\[[^[]*?\\]",
-returnBegin:!0,contains:[{begin:"(link|image:?):",relevance:0},{
-className:"link",begin:"\\w",end:"[^\\[]+",relevance:0},{className:"string",
-begin:"\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0,relevance:0}],relevance:10}]
-}}})());
-hljs.registerLanguage("aspectj",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{
-const t="false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else extends implements break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws privileged aspectOf adviceexecution proceed cflowbelow cflow initialization preinitialization staticinitialization withincode target within execution getWithinTypeName handler thisJoinPoint thisJoinPointStaticPart thisEnclosingJoinPointStaticPart declare parents warning error soft precedence thisAspectInstance",i="get set args call"
-;return{name:"AspectJ",keywords:t,illegal:/<\/|#/,
-contains:[n.COMMENT(/\/\*\*/,/\*\//,{relevance:0,contains:[{begin:/\w+@/,
-relevance:0},{className:"doctag",begin:/@[A-Za-z]+/}]
-}),n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,{
-className:"class",beginKeywords:"aspect",end:/[{;=]/,excludeEnd:!0,
-illegal:/[:;"\[\]]/,contains:[{
-beginKeywords:"extends implements pertypewithin perthis pertarget percflowbelow percflow issingleton"
-},n.UNDERSCORE_TITLE_MODE,{begin:/\([^\)]*/,end:/[)]+/,keywords:t+" "+i,
-excludeEnd:!1}]},{className:"class",beginKeywords:"class interface",end:/[{;=]/,
-excludeEnd:!0,relevance:0,keywords:"class interface",illegal:/[:"\[\]]/,
-contains:[{beginKeywords:"extends implements"},n.UNDERSCORE_TITLE_MODE]},{
-beginKeywords:"pointcut after before around throwing returning",end:/[)]/,
-excludeEnd:!1,illegal:/["\[\]]/,contains:[{
-begin:e(n.UNDERSCORE_IDENT_RE,/\s*\(/),returnBegin:!0,
-contains:[n.UNDERSCORE_TITLE_MODE]}]},{begin:/[:]/,returnBegin:!0,end:/[{;]/,
-relevance:0,excludeEnd:!1,keywords:t,illegal:/["\[\]]/,contains:[{
-begin:e(n.UNDERSCORE_IDENT_RE,/\s*\(/),keywords:t+" "+i,relevance:0
-},n.QUOTE_STRING_MODE]},{beginKeywords:"new throw",relevance:0},{
-className:"function",
-begin:/\w+ +\w+(\.\w+)?\s*\([^\)]*\)\s*((throws)[\w\s,]+)?[\{;]/,returnBegin:!0,
-end:/[{;=]/,keywords:t,excludeEnd:!0,contains:[{
-begin:e(n.UNDERSCORE_IDENT_RE,/\s*\(/),returnBegin:!0,relevance:0,
-contains:[n.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,
-relevance:0,keywords:t,
-contains:[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,n.C_NUMBER_MODE,n.C_BLOCK_COMMENT_MODE]
-},n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE]},n.C_NUMBER_MODE,{
-className:"meta",begin:/@[A-Za-z]+/}]}}})());
-hljs.registerLanguage("autohotkey",(()=>{"use strict";return e=>{const a={
-begin:"`[\\s\\S]"};return{name:"AutoHotkey",case_insensitive:!0,aliases:["ahk"],
-keywords:{
-keyword:"Break Continue Critical Exit ExitApp Gosub Goto New OnExit Pause return SetBatchLines SetTimer Suspend Thread Throw Until ahk_id ahk_class ahk_pid ahk_exe ahk_group",
-literal:"true false NOT AND OR",
-built_in:"ComSpec Clipboard ClipboardAll ErrorLevel"},
-contains:[a,e.inherit(e.QUOTE_STRING_MODE,{contains:[a]}),e.COMMENT(";","$",{
-relevance:0}),e.C_BLOCK_COMMENT_MODE,{className:"number",begin:e.NUMBER_RE,
-relevance:0},{className:"variable",begin:"%[a-zA-Z0-9#_$@]+%"},{
-className:"built_in",begin:"^\\s*\\w+\\s*(,|%)"},{className:"title",variants:[{
-begin:'^[^\\n";]+::(?!=)'},{begin:'^[^\\n";]+:(?!=)',relevance:0}]},{
-className:"meta",begin:"^\\s*#\\w+",end:"$",relevance:0},{className:"built_in",
-begin:"A_[a-zA-Z0-9]+"},{begin:",\\s*,"}]}}})());
-hljs.registerLanguage("autoit",(()=>{"use strict";return e=>{const t={
-variants:[e.COMMENT(";","$",{relevance:0
-}),e.COMMENT("#cs","#ce"),e.COMMENT("#comments-start","#comments-end")]},r={
-begin:"\\$[A-z0-9_]+"},i={className:"string",variants:[{begin:/"/,end:/"/,
-contains:[{begin:/""/,relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,
-relevance:0}]}]},n={variants:[e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE]};return{
-name:"AutoIt",case_insensitive:!0,illegal:/\/\*/,keywords:{
-keyword:"ByRef Case Const ContinueCase ContinueLoop Dim Do Else ElseIf EndFunc EndIf EndSelect EndSwitch EndWith Enum Exit ExitLoop For Func Global If In Local Next ReDim Return Select Static Step Switch Then To Until Volatile WEnd While With",
-built_in:"Abs ACos AdlibRegister AdlibUnRegister Asc AscW ASin Assign ATan AutoItSetOption AutoItWinGetTitle AutoItWinSetTitle Beep Binary BinaryLen BinaryMid BinaryToString BitAND BitNOT BitOR BitRotate BitShift BitXOR BlockInput Break Call CDTray Ceiling Chr ChrW ClipGet ClipPut ConsoleRead ConsoleWrite ConsoleWriteError ControlClick ControlCommand ControlDisable ControlEnable ControlFocus ControlGetFocus ControlGetHandle ControlGetPos ControlGetText ControlHide ControlListView ControlMove ControlSend ControlSetText ControlShow ControlTreeView Cos Dec DirCopy DirCreate DirGetSize DirMove DirRemove DllCall DllCallAddress DllCallbackFree DllCallbackGetPtr DllCallbackRegister DllClose DllOpen DllStructCreate DllStructGetData DllStructGetPtr DllStructGetSize DllStructSetData DriveGetDrive DriveGetFileSystem DriveGetLabel DriveGetSerial DriveGetType DriveMapAdd DriveMapDel DriveMapGet DriveSetLabel DriveSpaceFree DriveSpaceTotal DriveStatus EnvGet EnvSet EnvUpdate Eval Execute Exp FileChangeDir FileClose FileCopy FileCreateNTFSLink FileCreateShortcut FileDelete FileExists FileFindFirstFile FileFindNextFile FileFlush FileGetAttrib FileGetEncoding FileGetLongName FileGetPos FileGetShortcut FileGetShortName FileGetSize FileGetTime FileGetVersion FileInstall FileMove FileOpen FileOpenDialog FileRead FileReadLine FileReadToArray FileRecycle FileRecycleEmpty FileSaveDialog FileSelectFolder FileSetAttrib FileSetEnd FileSetPos FileSetTime FileWrite FileWriteLine Floor FtpSetProxy FuncName GUICreate GUICtrlCreateAvi GUICtrlCreateButton GUICtrlCreateCheckbox GUICtrlCreateCombo GUICtrlCreateContextMenu GUICtrlCreateDate GUICtrlCreateDummy GUICtrlCreateEdit GUICtrlCreateGraphic GUICtrlCreateGroup GUICtrlCreateIcon GUICtrlCreateInput GUICtrlCreateLabel GUICtrlCreateList GUICtrlCreateListView GUICtrlCreateListViewItem GUICtrlCreateMenu GUICtrlCreateMenuItem GUICtrlCreateMonthCal GUICtrlCreateObj GUICtrlCreatePic GUICtrlCreateProgress GUICtrlCreateRadio GUICtrlCreateSlider GUICtrlCreateTab GUICtrlCreateTabItem GUICtrlCreateTreeView GUICtrlCreateTreeViewItem GUICtrlCreateUpdown GUICtrlDelete GUICtrlGetHandle GUICtrlGetState GUICtrlRead GUICtrlRecvMsg GUICtrlRegisterListViewSort GUICtrlSendMsg GUICtrlSendToDummy GUICtrlSetBkColor GUICtrlSetColor GUICtrlSetCursor GUICtrlSetData GUICtrlSetDefBkColor GUICtrlSetDefColor GUICtrlSetFont GUICtrlSetGraphic GUICtrlSetImage GUICtrlSetLimit GUICtrlSetOnEvent GUICtrlSetPos GUICtrlSetResizing GUICtrlSetState GUICtrlSetStyle GUICtrlSetTip GUIDelete GUIGetCursorInfo GUIGetMsg GUIGetStyle GUIRegisterMsg GUISetAccelerators GUISetBkColor GUISetCoord GUISetCursor GUISetFont GUISetHelp GUISetIcon GUISetOnEvent GUISetState GUISetStyle GUIStartGroup GUISwitch Hex HotKeySet HttpSetProxy HttpSetUserAgent HWnd InetClose InetGet InetGetInfo InetGetSize InetRead IniDelete IniRead IniReadSection IniReadSectionNames IniRenameSection IniWrite IniWriteSection InputBox Int IsAdmin IsArray IsBinary IsBool IsDeclared IsDllStruct IsFloat IsFunc IsHWnd IsInt IsKeyword IsNumber IsObj IsPtr IsString Log MemGetStats Mod MouseClick MouseClickDrag MouseDown MouseGetCursor MouseGetPos MouseMove MouseUp MouseWheel MsgBox Number ObjCreate ObjCreateInterface ObjEvent ObjGet ObjName OnAutoItExitRegister OnAutoItExitUnRegister Ping PixelChecksum PixelGetColor PixelSearch ProcessClose ProcessExists ProcessGetStats ProcessList ProcessSetPriority ProcessWait ProcessWaitClose ProgressOff ProgressOn ProgressSet Ptr Random RegDelete RegEnumKey RegEnumVal RegRead RegWrite Round Run RunAs RunAsWait RunWait Send SendKeepActive SetError SetExtended ShellExecute ShellExecuteWait Shutdown Sin Sleep SoundPlay SoundSetWaveVolume SplashImageOn SplashOff SplashTextOn Sqrt SRandom StatusbarGetText StderrRead StdinWrite StdioClose StdoutRead String StringAddCR StringCompare StringFormat StringFromASCIIArray StringInStr StringIsAlNum StringIsAlpha StringIsASCII StringIsDigit StringIsFloat StringIsInt StringIsLower StringIsSpace StringIsUpper StringIsXDigit StringLeft StringLen StringLower StringMid StringRegExp StringRegExpReplace StringReplace StringReverse StringRight StringSplit StringStripCR StringStripWS StringToASCIIArray StringToBinary StringTrimLeft StringTrimRight StringUpper Tan TCPAccept TCPCloseSocket TCPConnect TCPListen TCPNameToIP TCPRecv TCPSend TCPShutdown, UDPShutdown TCPStartup, UDPStartup TimerDiff TimerInit ToolTip TrayCreateItem TrayCreateMenu TrayGetMsg TrayItemDelete TrayItemGetHandle TrayItemGetState TrayItemGetText TrayItemSetOnEvent TrayItemSetState TrayItemSetText TraySetClick TraySetIcon TraySetOnEvent TraySetPauseIcon TraySetState TraySetToolTip TrayTip UBound UDPBind UDPCloseSocket UDPOpen UDPRecv UDPSend VarGetType WinActivate WinActive WinClose WinExists WinFlash WinGetCaretPos WinGetClassList WinGetClientSize WinGetHandle WinGetPos WinGetProcess WinGetState WinGetText WinGetTitle WinKill WinList WinMenuSelectItem WinMinimizeAll WinMinimizeAllUndo WinMove WinSetOnTop WinSetState WinSetTitle WinSetTrans WinWait WinWaitActive WinWaitClose WinWaitNotActive",
-literal:"True False And Null Not Or Default"},contains:[t,r,i,n,{
-className:"meta",begin:"#",end:"$",keywords:{
-"meta-keyword":["EndRegion","forcedef","forceref","ignorefunc","include","include-once","NoTrayIcon","OnAutoItStartRegister","pragma","Region","RequireAdmin","Tidy_Off","Tidy_On","Tidy_Parameters"]
-},contains:[{begin:/\\\n/,relevance:0},{beginKeywords:"include",keywords:{
-"meta-keyword":"include"},end:"$",contains:[i,{className:"meta-string",
-variants:[{begin:"<",end:">"},{begin:/"/,end:/"/,contains:[{begin:/""/,
-relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]}]}]},i,t]
-},{className:"symbol",begin:"@[A-z0-9_]+"},{className:"function",
-beginKeywords:"Func",end:"$",illegal:"\\$|\\[|%",
-contains:[e.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",
-contains:[r,i,n]}]}]}}})());
-hljs.registerLanguage("avrasm",(()=>{"use strict";return r=>({
-name:"AVR Assembly",case_insensitive:!0,keywords:{$pattern:"\\.?"+r.IDENT_RE,
-keyword:"adc add adiw and andi asr bclr bld brbc brbs brcc brcs break breq brge brhc brhs brid brie brlo brlt brmi brne brpl brsh brtc brts brvc brvs bset bst call cbi cbr clc clh cli cln clr cls clt clv clz com cp cpc cpi cpse dec eicall eijmp elpm eor fmul fmuls fmulsu icall ijmp in inc jmp ld ldd ldi lds lpm lsl lsr mov movw mul muls mulsu neg nop or ori out pop push rcall ret reti rjmp rol ror sbc sbr sbrc sbrs sec seh sbi sbci sbic sbis sbiw sei sen ser ses set sev sez sleep spm st std sts sub subi swap tst wdr",
-built_in:"r0 r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 r12 r13 r14 r15 r16 r17 r18 r19 r20 r21 r22 r23 r24 r25 r26 r27 r28 r29 r30 r31 x|0 xh xl y|0 yh yl z|0 zh zl ucsr1c udr1 ucsr1a ucsr1b ubrr1l ubrr1h ucsr0c ubrr0h tccr3c tccr3a tccr3b tcnt3h tcnt3l ocr3ah ocr3al ocr3bh ocr3bl ocr3ch ocr3cl icr3h icr3l etimsk etifr tccr1c ocr1ch ocr1cl twcr twdr twar twsr twbr osccal xmcra xmcrb eicra spmcsr spmcr portg ddrg ping portf ddrf sreg sph spl xdiv rampz eicrb eimsk gimsk gicr eifr gifr timsk tifr mcucr mcucsr tccr0 tcnt0 ocr0 assr tccr1a tccr1b tcnt1h tcnt1l ocr1ah ocr1al ocr1bh ocr1bl icr1h icr1l tccr2 tcnt2 ocr2 ocdr wdtcr sfior eearh eearl eedr eecr porta ddra pina portb ddrb pinb portc ddrc pinc portd ddrd pind spdr spsr spcr udr0 ucsr0a ucsr0b ubrr0l acsr admux adcsr adch adcl porte ddre pine pinf",
-meta:".byte .cseg .db .def .device .dseg .dw .endmacro .equ .eseg .exit .include .list .listmac .macro .nolist .org .set"
-},contains:[r.C_BLOCK_COMMENT_MODE,r.COMMENT(";","$",{relevance:0
-}),r.C_NUMBER_MODE,r.BINARY_NUMBER_MODE,{className:"number",
-begin:"\\b(\\$[a-zA-Z0-9]+|0o[0-7]+)"},r.QUOTE_STRING_MODE,{className:"string",
-begin:"'",end:"[^\\\\]'",illegal:"[^\\\\][^']"},{className:"symbol",
-begin:"^[A-Za-z0-9_.$]+:"},{className:"meta",begin:"#",end:"$"},{
-className:"subst",begin:"@[0-9]+"}]})})());
-hljs.registerLanguage("awk",(()=>{"use strict";return e=>({name:"Awk",keywords:{
-keyword:"BEGIN END if else while do for in break continue delete next nextfile function func exit|10"
-},contains:[{className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{
-begin:/\$\{(.*?)\}/}]},{className:"string",contains:[e.BACKSLASH_ESCAPE],
-variants:[{begin:/(u|b)?r?'''/,end:/'''/,relevance:10},{begin:/(u|b)?r?"""/,
-end:/"""/,relevance:10},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{
-begin:/(u|r|ur)"/,end:/"/,relevance:10},{begin:/(b|br)'/,end:/'/},{
-begin:/(b|br)"/,end:/"/},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-},e.REGEXP_MODE,e.HASH_COMMENT_MODE,e.NUMBER_MODE]})})());
-hljs.registerLanguage("axapta",(()=>{"use strict";return e=>({name:"X++",
-aliases:["x++"],keywords:{
-keyword:["abstract","as","asc","avg","break","breakpoint","by","byref","case","catch","changecompany","class","client","client","common","const","continue","count","crosscompany","delegate","delete_from","desc","display","div","do","edit","else","eventhandler","exists","extends","final","finally","firstfast","firstonly","firstonly1","firstonly10","firstonly100","firstonly1000","flush","for","forceliterals","forcenestedloop","forceplaceholders","forceselectorder","forupdate","from","generateonly","group","hint","if","implements","in","index","insert_recordset","interface","internal","is","join","like","maxof","minof","mod","namespace","new","next","nofetch","notexists","optimisticlock","order","outer","pessimisticlock","print","private","protected","public","readonly","repeatableread","retry","return","reverse","select","server","setting","static","sum","super","switch","this","throw","try","ttsabort","ttsbegin","ttscommit","unchecked","update_recordset","using","validtimestate","void","where","while"],
-built_in:["anytype","boolean","byte","char","container","date","double","enum","guid","int","int64","long","real","short","str","utcdatetime","var"],
-literal:["default","false","null","true"]},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"#",end:"$"},{className:"class",
-beginKeywords:"class interface",end:/\{/,excludeEnd:!0,illegal:":",contains:[{
-beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]}]})})());
-hljs.registerLanguage("bash",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(s=e)?"string"==typeof s?s:s.source:null;var s
-})).join("")}return s=>{const n={},t={begin:/\$\{/,end:/\}/,contains:["self",{
-begin:/:-/,contains:[n]}]};Object.assign(n,{className:"variable",variants:[{
-begin:e(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},t]});const a={
-className:"subst",begin:/\$\(/,end:/\)/,contains:[s.BACKSLASH_ESCAPE]},i={
-begin:/<<-?\s*(?=\w+)/,starts:{contains:[s.END_SAME_AS_BEGIN({begin:/(\w+)/,
-end:/(\w+)/,className:"string"})]}},c={className:"string",begin:/"/,end:/"/,
-contains:[s.BACKSLASH_ESCAPE,n,a]};a.contains.push(c);const o={begin:/\$\(\(/,
-end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},s.NUMBER_MODE,n]
-},r=s.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10
-}),l={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,
-contains:[s.inherit(s.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{
-name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z._-]+\b/,
-keyword:"if then else elif fi for while in do done case esac function",
-literal:"true false",
-built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp"
-},contains:[r,s.SHEBANG(),l,o,s.HASH_COMMENT_MODE,i,c,{className:"",begin:/\\"/
-},{className:"string",begin:/'/,end:/'/},n]}}})());
-hljs.registerLanguage("basic",(()=>{"use strict";return E=>({name:"BASIC",
-case_insensitive:!0,illegal:"^.",keywords:{$pattern:"[a-zA-Z][a-zA-Z0-9_$%!#]*",
-keyword:"ABS ASC AND ATN AUTO|0 BEEP BLOAD|10 BSAVE|10 CALL CALLS CDBL CHAIN CHDIR CHR$|10 CINT CIRCLE CLEAR CLOSE CLS COLOR COM COMMON CONT COS CSNG CSRLIN CVD CVI CVS DATA DATE$ DEFDBL DEFINT DEFSNG DEFSTR DEF|0 SEG USR DELETE DIM DRAW EDIT END ENVIRON ENVIRON$ EOF EQV ERASE ERDEV ERDEV$ ERL ERR ERROR EXP FIELD FILES FIX FOR|0 FRE GET GOSUB|10 GOTO HEX$ IF THEN ELSE|0 INKEY$ INP INPUT INPUT# INPUT$ INSTR IMP INT IOCTL IOCTL$ KEY ON OFF LIST KILL LEFT$ LEN LET LINE LLIST LOAD LOC LOCATE LOF LOG LPRINT USING LSET MERGE MID$ MKDIR MKD$ MKI$ MKS$ MOD NAME NEW NEXT NOISE NOT OCT$ ON OR PEN PLAY STRIG OPEN OPTION BASE OUT PAINT PALETTE PCOPY PEEK PMAP POINT POKE POS PRINT PRINT] PSET PRESET PUT RANDOMIZE READ REM RENUM RESET|0 RESTORE RESUME RETURN|0 RIGHT$ RMDIR RND RSET RUN SAVE SCREEN SGN SHELL SIN SOUND SPACE$ SPC SQR STEP STICK STOP STR$ STRING$ SWAP SYSTEM TAB TAN TIME$ TIMER TROFF TRON TO USR VAL VARPTR VARPTR$ VIEW WAIT WHILE WEND WIDTH WINDOW WRITE XOR"
-},contains:[E.QUOTE_STRING_MODE,E.COMMENT("REM","$",{relevance:10
-}),E.COMMENT("'","$",{relevance:0}),{className:"symbol",begin:"^[0-9]+ ",
-relevance:10},{className:"number",begin:"\\b\\d+(\\.\\d+)?([edED]\\d+)?[#!]?",
-relevance:0},{className:"number",begin:"(&[hH][0-9a-fA-F]{1,4})"},{
-className:"number",begin:"(&[oO][0-7]{1,6})"}]})})());
-hljs.registerLanguage("bnf",(()=>{"use strict";return e=>({
-name:"Backus\u2013Naur Form",contains:[{className:"attribute",begin:/</,end:/>/
-},{begin:/::=/,end:/$/,contains:[{begin:/</,end:/>/
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-}]})})());
-hljs.registerLanguage("brainfuck",(()=>{"use strict";return e=>{const n={
-className:"literal",begin:/[+-]/,relevance:0};return{name:"Brainfuck",
-aliases:["bf"],
-contains:[e.COMMENT("[^\\[\\]\\.,\\+\\-<> \r\n]","[\\[\\]\\.,\\+\\-<> \r\n]",{
-returnEnd:!0,relevance:0}),{className:"title",begin:"[\\[\\]]",relevance:0},{
-className:"string",begin:"[\\.,]",relevance:0},{begin:/(?:\+\+|--)/,contains:[n]
-},n]}}})());
-hljs.registerLanguage("c-like",(()=>{"use strict";function e(e){
-return t("(",e,")?")}function t(...e){return e.map((e=>{
-return(t=e)?"string"==typeof t?t:t.source:null;var t})).join("")}return n=>{
-const a=(n=>{const a=n.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),r="[a-zA-Z_]\\w*::",s="(decltype\\(auto\\)|"+e(r)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",i={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},c={className:"string",
-variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[n.BACKSLASH_ESCAPE]},{
-begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},n.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
-className:"number",variants:[{begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
-},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},l={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},n.inherit(c,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/},a,n.C_BLOCK_COMMENT_MODE]},d={
-className:"title",begin:e(r)+n.IDENT_RE,relevance:0
-},u=e(r)+n.IDENT_RE+"\\s*\\(",p={
-keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"_Bool _Complex _Imaginary",
-_relevance_hints:["asin","atan2","atan","calloc","ceil","cosh","cos","exit","exp","fabs","floor","fmod","fprintf","fputs","free","frexp","auto_ptr","deque","list","queue","stack","vector","map","set","pair","bitset","multiset","multimap","unordered_set","fscanf","future","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","tolower","toupper","labs","ldexp","log10","log","malloc","realloc","memchr","memcmp","memcpy","memset","modf","pow","printf","putchar","puts","scanf","sinh","sin","snprintf","sprintf","sqrt","sscanf","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","tanh","tan","unordered_map","unordered_multiset","unordered_multimap","priority_queue","make_pair","array","shared_ptr","abort","terminate","abs","acos","vfprintf","vprintf","vsprintf","endl","initializer_list","unique_ptr","complex","imaginary","std","string","wstring","cin","cout","cerr","clog","stdin","stdout","stderr","stringstream","istringstream","ostringstream"],
-literal:"true false nullptr NULL"},m={className:"function.dispatch",relevance:0,
-keywords:p,
-begin:t(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,n.IDENT_RE,(_=/\s*\(/,
-t("(?=",_,")")))};var _;const g=[m,l,i,a,n.C_BLOCK_COMMENT_MODE,o,c],b={
-variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:p,contains:g.concat([{
-begin:/\(/,end:/\)/,keywords:p,contains:g.concat(["self"]),relevance:0}]),
-relevance:0},f={className:"function",begin:"("+s+"[\\*&\\s]+)+"+u,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:p,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:p,relevance:0},{begin:u,
-returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
-endsWithParent:!0,contains:[c,o]},{className:"params",begin:/\(/,end:/\)/,
-keywords:p,relevance:0,contains:[a,n.C_BLOCK_COMMENT_MODE,c,o,i,{begin:/\(/,
-end:/\)/,keywords:p,relevance:0,contains:["self",a,n.C_BLOCK_COMMENT_MODE,c,o,i]
-}]},i,a,n.C_BLOCK_COMMENT_MODE,l]};return{name:"C++",
-aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:p,illegal:"</",
-classNameAliases:{"function.dispatch":"built_in"},
-contains:[].concat(b,f,m,g,[l,{
-begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:p,contains:["self",i]},{begin:n.IDENT_RE+"::",keywords:p},{
-className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},n.TITLE_MODE]}]),exports:{
-preprocessor:l,strings:c,keywords:p}}})(n)
-;return a.disableAutodetect=!0,a.aliases=[],
-n.getLanguage("c")||a.aliases.push("c","h"),
-n.getLanguage("cpp")||a.aliases.push("cc","c++","h++","hpp","hh","hxx","cxx"),a}
-})());
-hljs.registerLanguage("c",(()=>{"use strict";function e(e){
-return((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(",e,")?")
-}return t=>{const n=t.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),r="[a-zA-Z_]\\w*::",a="(decltype\\(auto\\)|"+e(r)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",i={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},s={className:"string",
-variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[t.BACKSLASH_ESCAPE]},{
-begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},t.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
-className:"number",variants:[{begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
-},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},t.inherit(s,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/},n,t.C_BLOCK_COMMENT_MODE]},l={
-className:"title",begin:e(r)+t.IDENT_RE,relevance:0
-},d=e(r)+t.IDENT_RE+"\\s*\\(",u={
-keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr _Bool complex _Complex imaginary _Imaginary",
-literal:"true false nullptr NULL"},m=[c,i,n,t.C_BLOCK_COMMENT_MODE,o,s],p={
-variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:u,contains:m.concat([{
-begin:/\(/,end:/\)/,keywords:u,contains:m.concat(["self"]),relevance:0}]),
-relevance:0},_={className:"function",begin:"("+a+"[\\*&\\s]+)+"+d,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:u,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:u,relevance:0},{begin:d,
-returnBegin:!0,contains:[l],relevance:0},{className:"params",begin:/\(/,
-end:/\)/,keywords:u,relevance:0,contains:[n,t.C_BLOCK_COMMENT_MODE,s,o,i,{
-begin:/\(/,end:/\)/,keywords:u,relevance:0,
-contains:["self",n,t.C_BLOCK_COMMENT_MODE,s,o,i]}]
-},i,n,t.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u,
-disableAutodetect:!0,illegal:"</",contains:[].concat(p,_,m,[c,{
-begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:u,contains:["self",i]},{begin:t.IDENT_RE+"::",keywords:u},{
-className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},t.TITLE_MODE]}]),exports:{
-preprocessor:c,strings:s,keywords:u}}}})());
-hljs.registerLanguage("cal",(()=>{"use strict";return e=>{
-const n="div mod in and or not xor asserterror begin case do downto else end exit for if of repeat then to until while with var",a=[e.C_LINE_COMMENT_MODE,e.COMMENT(/\{/,/\}/,{
-relevance:0}),e.COMMENT(/\(\*/,/\*\)/,{relevance:10})],r={className:"string",
-begin:/'/,end:/'/,contains:[{begin:/''/}]},s={className:"string",begin:/(#\d+)+/
-},i={className:"function",beginKeywords:"procedure",end:/[:;]/,
-keywords:"procedure|10",contains:[e.TITLE_MODE,{className:"params",begin:/\(/,
-end:/\)/,keywords:n,contains:[r,s]}].concat(a)},t={className:"class",
-begin:"OBJECT (Table|Form|Report|Dataport|Codeunit|XMLport|MenuSuite|Page|Query) (\\d+) ([^\\r\\n]+)",
-returnBegin:!0,contains:[e.TITLE_MODE,i]};return{name:"C/AL",
-case_insensitive:!0,keywords:{keyword:n,literal:"false true"},illegal:/\/\*/,
-contains:[r,s,{className:"number",begin:"\\b\\d+(\\.\\d+)?(DT|D|T)",relevance:0
-},{className:"string",begin:'"',end:'"'},e.NUMBER_MODE,t,i]}}})());
-hljs.registerLanguage("capnproto",(()=>{"use strict";return n=>({
-name:"Cap\u2019n Proto",aliases:["capnp"],keywords:{
-keyword:"struct enum interface union group import using const annotation extends in of on as with from fixed",
-built_in:"Void Bool Int8 Int16 Int32 Int64 UInt8 UInt16 UInt32 UInt64 Float32 Float64 Text Data AnyPointer AnyStruct Capability List",
-literal:"true false"},
-contains:[n.QUOTE_STRING_MODE,n.NUMBER_MODE,n.HASH_COMMENT_MODE,{
-className:"meta",begin:/@0x[\w\d]{16};/,illegal:/\n/},{className:"symbol",
-begin:/@\d+\b/},{className:"class",beginKeywords:"struct enum",end:/\{/,
-illegal:/\n/,contains:[n.inherit(n.TITLE_MODE,{starts:{endsWithParent:!0,
-excludeEnd:!0}})]},{className:"class",beginKeywords:"interface",end:/\{/,
-illegal:/\n/,contains:[n.inherit(n.TITLE_MODE,{starts:{endsWithParent:!0,
-excludeEnd:!0}})]}]})})());
-hljs.registerLanguage("ceylon",(()=>{"use strict";return e=>{
-const a="assembly module package import alias class interface object given value assign void function new of extends satisfies abstracts in out return break continue throw assert dynamic if else switch case for while try catch finally then let this outer super is exists nonempty",s={
-className:"subst",excludeBegin:!0,excludeEnd:!0,begin:/``/,end:/``/,keywords:a,
-relevance:10},n=[{className:"string",begin:'"""',end:'"""',relevance:10},{
-className:"string",begin:'"',end:'"',contains:[s]},{className:"string",
-begin:"'",end:"'"},{className:"number",
-begin:"#[0-9a-fA-F_]+|\\$[01_]+|[0-9_]+(?:\\.[0-9_](?:[eE][+-]?\\d+)?)?[kMGTPmunpf]?",
-relevance:0}];return s.contains=n,{name:"Ceylon",keywords:{
-keyword:a+" shared abstract formal default actual variable late native deprecated final sealed annotation suppressWarnings small",
-meta:"doc by license see throws tagged"},illegal:"\\$[^01]|#[^0-9a-fA-F]",
-contains:[e.C_LINE_COMMENT_MODE,e.COMMENT("/\\*","\\*/",{contains:["self"]}),{
-className:"meta",begin:'@[a-z]\\w*(?::"[^"]*")?'}].concat(n)}}})());
-hljs.registerLanguage("clean",(()=>{"use strict";return e=>({name:"Clean",
-aliases:["icl","dcl"],keywords:{
-keyword:"if let in with where case of class instance otherwise implementation definition system module from import qualified as special code inline foreign export ccall stdcall generic derive infix infixl infixr",
-built_in:"Int Real Char Bool",literal:"True False"},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{
-begin:"->|<-[|:]?|#!?|>>=|\\{\\||\\|\\}|:==|=:|<>"}]})})());
-hljs.registerLanguage("clojure",(()=>{"use strict";return e=>{
-const t="a-zA-Z_\\-!.?+*=<>&#'",n="["+t+"]["+t+"0-9/;:]*",r="def defonce defprotocol defstruct defmulti defmethod defn- defn defmacro deftype defrecord",a={
-$pattern:n,
-"builtin-name":r+" cond apply if-not if-let if not not= =|0 <|0 >|0 <=|0 >=|0 ==|0 +|0 /|0 *|0 -|0 rem quot neg? pos? delay? symbol? keyword? true? false? integer? empty? coll? list? set? ifn? fn? associative? sequential? sorted? counted? reversible? number? decimal? class? distinct? isa? float? rational? reduced? ratio? odd? even? char? seq? vector? string? map? nil? contains? zero? instance? not-every? not-any? libspec? -> ->> .. . inc compare do dotimes mapcat take remove take-while drop letfn drop-last take-last drop-while while intern condp case reduced cycle split-at split-with repeat replicate iterate range merge zipmap declare line-seq sort comparator sort-by dorun doall nthnext nthrest partition eval doseq await await-for let agent atom send send-off release-pending-sends add-watch mapv filterv remove-watch agent-error restart-agent set-error-handler error-handler set-error-mode! error-mode shutdown-agents quote var fn loop recur throw try monitor-enter monitor-exit macroexpand macroexpand-1 for dosync and or when when-not when-let comp juxt partial sequence memoize constantly complement identity assert peek pop doto proxy first rest cons cast coll last butlast sigs reify second ffirst fnext nfirst nnext meta with-meta ns in-ns create-ns import refer keys select-keys vals key val rseq name namespace promise into transient persistent! conj! assoc! dissoc! pop! disj! use class type num float double short byte boolean bigint biginteger bigdec print-method print-dup throw-if printf format load compile get-in update-in pr pr-on newline flush read slurp read-line subvec with-open memfn time re-find re-groups rand-int rand mod locking assert-valid-fdecl alias resolve ref deref refset swap! reset! set-validator! compare-and-set! alter-meta! reset-meta! commute get-validator alter ref-set ref-history-count ref-min-history ref-max-history ensure sync io! new next conj set! to-array future future-call into-array aset gen-class reduce map filter find empty hash-map hash-set sorted-map sorted-map-by sorted-set sorted-set-by vec vector seq flatten reverse assoc dissoc list disj get union difference intersection extend extend-type extend-protocol int nth delay count concat chunk chunk-buffer chunk-append chunk-first chunk-rest max min dec unchecked-inc-int unchecked-inc unchecked-dec-inc unchecked-dec unchecked-negate unchecked-add-int unchecked-add unchecked-subtract-int unchecked-subtract chunk-next chunk-cons chunked-seq? prn vary-meta lazy-seq spread list* str find-keyword keyword symbol gensym force rationalize"
-},s={begin:n,relevance:0},o={className:"number",begin:"[-+]?\\d+(\\.\\d+)?",
-relevance:0},i=e.inherit(e.QUOTE_STRING_MODE,{illegal:null
-}),c=e.COMMENT(";","$",{relevance:0}),d={className:"literal",
-begin:/\b(true|false|nil)\b/},l={begin:"[\\[\\{]",end:"[\\]\\}]"},m={
-className:"comment",begin:"\\^"+n},p=e.COMMENT("\\^\\{","\\}"),u={
-className:"symbol",begin:"[:]{1,2}"+n},f={begin:"\\(",end:"\\)"},h={
-endsWithParent:!0,relevance:0},y={keywords:a,className:"name",begin:n,
-relevance:0,starts:h},g=[f,i,m,p,c,u,l,o,d,s],b={beginKeywords:r,lexemes:n,
-end:'(\\[|#|\\d|"|:|\\{|\\)|\\(|$)',contains:[{className:"title",begin:n,
-relevance:0,excludeEnd:!0,endsParent:!0}].concat(g)}
-;return f.contains=[e.COMMENT("comment",""),b,y,h],
-h.contains=g,l.contains=g,p.contains=[l],{name:"Clojure",aliases:["clj"],
-illegal:/\S/,contains:[f,i,m,p,c,u,l,o,d]}}})());
-hljs.registerLanguage("clojure-repl",(()=>{"use strict";return e=>({
-name:"Clojure REPL",contains:[{className:"meta",begin:/^([\w.-]+|\s*#_)?=>/,
-starts:{end:/$/,subLanguage:"clojure"}}]})})());
-hljs.registerLanguage("cmake",(()=>{"use strict";return e=>({name:"CMake",
-aliases:["cmake.in"],case_insensitive:!0,keywords:{
-keyword:"break cmake_host_system_information cmake_minimum_required cmake_parse_arguments cmake_policy configure_file continue elseif else endforeach endfunction endif endmacro endwhile execute_process file find_file find_library find_package find_path find_program foreach function get_cmake_property get_directory_property get_filename_component get_property if include include_guard list macro mark_as_advanced math message option return separate_arguments set_directory_properties set_property set site_name string unset variable_watch while add_compile_definitions add_compile_options add_custom_command add_custom_target add_definitions add_dependencies add_executable add_library add_link_options add_subdirectory add_test aux_source_directory build_command create_test_sourcelist define_property enable_language enable_testing export fltk_wrap_ui get_source_file_property get_target_property get_test_property include_directories include_external_msproject include_regular_expression install link_directories link_libraries load_cache project qt_wrap_cpp qt_wrap_ui remove_definitions set_source_files_properties set_target_properties set_tests_properties source_group target_compile_definitions target_compile_features target_compile_options target_include_directories target_link_directories target_link_libraries target_link_options target_sources try_compile try_run ctest_build ctest_configure ctest_coverage ctest_empty_binary_directory ctest_memcheck ctest_read_custom_files ctest_run_script ctest_sleep ctest_start ctest_submit ctest_test ctest_update ctest_upload build_name exec_program export_library_dependencies install_files install_programs install_targets load_command make_directory output_required_files remove subdir_depends subdirs use_mangled_mesa utility_source variable_requires write_file qt5_use_modules qt5_use_package qt5_wrap_cpp on off true false and or not command policy target test exists is_newer_than is_directory is_symlink is_absolute matches less greater equal less_equal greater_equal strless strgreater strequal strless_equal strgreater_equal version_less version_greater version_equal version_less_equal version_greater_equal in_list defined"
-},contains:[{className:"variable",begin:/\$\{/,end:/\}/
-},e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE]})})());
-hljs.registerLanguage("coffeescript",(()=>{"use strict"
-;const e=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],n=["true","false","null","undefined","NaN","Infinity"],a=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"])
-;return r=>{const t={
-keyword:e.concat(["then","unless","until","loop","by","when","and","or","is","isnt","not"]).filter((i=["var","const","let","function","static"],
-e=>!i.includes(e))),literal:n.concat(["yes","no","on","off"]),
-built_in:a.concat(["npm","print"])};var i;const s="[A-Za-z$_][0-9A-Za-z$_]*",o={
-className:"subst",begin:/#\{/,end:/\}/,keywords:t
-},c=[r.BINARY_NUMBER_MODE,r.inherit(r.C_NUMBER_MODE,{starts:{end:"(\\s*/)?",
-relevance:0}}),{className:"string",variants:[{begin:/'''/,end:/'''/,
-contains:[r.BACKSLASH_ESCAPE]},{begin:/'/,end:/'/,contains:[r.BACKSLASH_ESCAPE]
-},{begin:/"""/,end:/"""/,contains:[r.BACKSLASH_ESCAPE,o]},{begin:/"/,end:/"/,
-contains:[r.BACKSLASH_ESCAPE,o]}]},{className:"regexp",variants:[{begin:"///",
-end:"///",contains:[o,r.HASH_COMMENT_MODE]},{begin:"//[gim]{0,3}(?=\\W)",
-relevance:0},{begin:/\/(?![ *]).*?(?![\\]).\/[gim]{0,3}(?=\W)/}]},{begin:"@"+s
-},{subLanguage:"javascript",excludeBegin:!0,excludeEnd:!0,variants:[{
-begin:"```",end:"```"},{begin:"`",end:"`"}]}];o.contains=c
-;const l=r.inherit(r.TITLE_MODE,{begin:s}),d="(\\(.*\\)\\s*)?\\B[-=]>",g={
-className:"params",begin:"\\([^\\(]",returnBegin:!0,contains:[{begin:/\(/,
-end:/\)/,keywords:t,contains:["self"].concat(c)}]};return{name:"CoffeeScript",
-aliases:["coffee","cson","iced"],keywords:t,illegal:/\/\*/,
-contains:c.concat([r.COMMENT("###","###"),r.HASH_COMMENT_MODE,{
-className:"function",begin:"^\\s*"+s+"\\s*=\\s*"+d,end:"[-=]>",returnBegin:!0,
-contains:[l,g]},{begin:/[:\(,=]\s*/,relevance:0,contains:[{className:"function",
-begin:d,end:"[-=]>",returnBegin:!0,contains:[g]}]},{className:"class",
-beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{
-beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[l]},l]
-},{begin:s+":",end:":",returnBegin:!0,returnEnd:!0,relevance:0}])}}})());
-hljs.registerLanguage("coq",(()=>{"use strict";return e=>({name:"Coq",keywords:{
-keyword:"_|0 as at cofix else end exists exists2 fix for forall fun if IF in let match mod Prop return Set then Type using where with Abort About Add Admit Admitted All Arguments Assumptions Axiom Back BackTo Backtrack Bind Blacklist Canonical Cd Check Class Classes Close Coercion Coercions CoFixpoint CoInductive Collection Combined Compute Conjecture Conjectures Constant constr Constraint Constructors Context Corollary CreateHintDb Cut Declare Defined Definition Delimit Dependencies Dependent Derive Drop eauto End Equality Eval Example Existential Existentials Existing Export exporting Extern Extract Extraction Fact Field Fields File Fixpoint Focus for From Function Functional Generalizable Global Goal Grab Grammar Graph Guarded Heap Hint HintDb Hints Hypotheses Hypothesis ident Identity If Immediate Implicit Import Include Inductive Infix Info Initial Inline Inspect Instance Instances Intro Intros Inversion Inversion_clear Language Left Lemma Let Libraries Library Load LoadPath Local Locate Ltac ML Mode Module Modules Monomorphic Morphism Next NoInline Notation Obligation Obligations Opaque Open Optimize Options Parameter Parameters Parametric Path Paths pattern Polymorphic Preterm Print Printing Program Projections Proof Proposition Pwd Qed Quit Rec Record Recursive Redirect Relation Remark Remove Require Reserved Reset Resolve Restart Rewrite Right Ring Rings Save Scheme Scope Scopes Script Search SearchAbout SearchHead SearchPattern SearchRewrite Section Separate Set Setoid Show Solve Sorted Step Strategies Strategy Structure SubClass Table Tables Tactic Term Test Theorem Time Timeout Transparent Type Typeclasses Types Undelimit Undo Unfocus Unfocused Unfold Universe Universes Unset Unshelve using Variable Variables Variant Verbose Visibility where with",
-built_in:"abstract absurd admit after apply as assert assumption at auto autorewrite autounfold before bottom btauto by case case_eq cbn cbv change classical_left classical_right clear clearbody cofix compare compute congruence constr_eq constructor contradict contradiction cut cutrewrite cycle decide decompose dependent destruct destruction dintuition discriminate discrR do double dtauto eapply eassumption eauto ecase econstructor edestruct ediscriminate eelim eexact eexists einduction einjection eleft elim elimtype enough equality erewrite eright esimplify_eq esplit evar exact exactly_once exfalso exists f_equal fail field field_simplify field_simplify_eq first firstorder fix fold fourier functional generalize generalizing gfail give_up has_evar hnf idtac in induction injection instantiate intro intro_pattern intros intuition inversion inversion_clear is_evar is_var lapply lazy left lia lra move native_compute nia nsatz omega once pattern pose progress proof psatz quote record red refine reflexivity remember rename repeat replace revert revgoals rewrite rewrite_strat right ring ring_simplify rtauto set setoid_reflexivity setoid_replace setoid_rewrite setoid_symmetry setoid_transitivity shelve shelve_unifiable simpl simple simplify_eq solve specialize split split_Rabs split_Rmult stepl stepr subst sum swap symmetry tactic tauto time timeout top transitivity trivial try tryif unfold unify until using vm_compute with"
-},contains:[e.QUOTE_STRING_MODE,e.COMMENT("\\(\\*","\\*\\)"),e.C_NUMBER_MODE,{
-className:"type",excludeBegin:!0,begin:"\\|\\s*",end:"\\w+"},{begin:/[-=]>/}]})
-})());
-hljs.registerLanguage("cos",(()=>{"use strict";return e=>({
-name:"Cach\xe9 Object Script",case_insensitive:!0,aliases:["cls"],
-keywords:"property parameter class classmethod clientmethod extends as break catch close continue do d|0 else elseif for goto halt hang h|0 if job j|0 kill k|0 lock l|0 merge new open quit q|0 read r|0 return set s|0 tcommit throw trollback try tstart use view while write w|0 xecute x|0 zkill znspace zn ztrap zwrite zw zzdump zzwrite print zbreak zinsert zload zprint zremove zsave zzprint mv mvcall mvcrt mvdim mvprint zquit zsync ascii",
-contains:[{className:"number",begin:"\\b(\\d+(\\.\\d*)?|\\.\\d+)",relevance:0},{
-className:"string",variants:[{begin:'"',end:'"',contains:[{begin:'""',
-relevance:0}]}]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-className:"comment",begin:/;/,end:"$",relevance:0},{className:"built_in",
-begin:/(?:\$\$?|\.\.)\^?[a-zA-Z]+/},{className:"built_in",
-begin:/\$\$\$[a-zA-Z]+/},{className:"built_in",begin:/%[a-z]+(?:\.[a-z]+)*/},{
-className:"symbol",begin:/\^%?[a-zA-Z][\w]*/},{className:"keyword",
-begin:/##class|##super|#define|#dim/},{begin:/&sql\(/,end:/\)/,excludeBegin:!0,
-excludeEnd:!0,subLanguage:"sql"},{begin:/&(js|jscript|javascript)</,end:/>/,
-excludeBegin:!0,excludeEnd:!0,subLanguage:"javascript"},{begin:/&html<\s*</,
-end:/>\s*>/,subLanguage:"xml"}]})})());
-hljs.registerLanguage("cpp",(()=>{"use strict";function e(e){
-return t("(",e,")?")}function t(...e){return e.map((e=>{
-return(t=e)?"string"==typeof t?t:t.source:null;var t})).join("")}return n=>{
-const r=n.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),a="[a-zA-Z_]\\w*::",i="(decltype\\(auto\\)|"+e(a)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",s={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},c={className:"string",
-variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[n.BACKSLASH_ESCAPE]},{
-begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},n.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
-className:"number",variants:[{begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
-},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},l={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},n.inherit(c,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/},r,n.C_BLOCK_COMMENT_MODE]},d={
-className:"title",begin:e(a)+n.IDENT_RE,relevance:0
-},u=e(a)+n.IDENT_RE+"\\s*\\(",m={
-keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"_Bool _Complex _Imaginary",
-_relevance_hints:["asin","atan2","atan","calloc","ceil","cosh","cos","exit","exp","fabs","floor","fmod","fprintf","fputs","free","frexp","auto_ptr","deque","list","queue","stack","vector","map","set","pair","bitset","multiset","multimap","unordered_set","fscanf","future","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","tolower","toupper","labs","ldexp","log10","log","malloc","realloc","memchr","memcmp","memcpy","memset","modf","pow","printf","putchar","puts","scanf","sinh","sin","snprintf","sprintf","sqrt","sscanf","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","tanh","tan","unordered_map","unordered_multiset","unordered_multimap","priority_queue","make_pair","array","shared_ptr","abort","terminate","abs","acos","vfprintf","vprintf","vsprintf","endl","initializer_list","unique_ptr","complex","imaginary","std","string","wstring","cin","cout","cerr","clog","stdin","stdout","stderr","stringstream","istringstream","ostringstream"],
-literal:"true false nullptr NULL"},p={className:"function.dispatch",relevance:0,
-keywords:m,
-begin:t(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,n.IDENT_RE,(_=/\s*\(/,
-t("(?=",_,")")))};var _;const g=[p,l,s,r,n.C_BLOCK_COMMENT_MODE,o,c],b={
-variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:m,contains:g.concat([{
-begin:/\(/,end:/\)/,keywords:m,contains:g.concat(["self"]),relevance:0}]),
-relevance:0},f={className:"function",begin:"("+i+"[\\*&\\s]+)+"+u,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:m,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:m,relevance:0},{begin:u,
-returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
-endsWithParent:!0,contains:[c,o]},{className:"params",begin:/\(/,end:/\)/,
-keywords:m,relevance:0,contains:[r,n.C_BLOCK_COMMENT_MODE,c,o,s,{begin:/\(/,
-end:/\)/,keywords:m,relevance:0,contains:["self",r,n.C_BLOCK_COMMENT_MODE,c,o,s]
-}]},s,r,n.C_BLOCK_COMMENT_MODE,l]};return{name:"C++",
-aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:m,illegal:"</",
-classNameAliases:{"function.dispatch":"built_in"},
-contains:[].concat(b,f,p,g,[l,{
-begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:m,contains:["self",s]},{begin:n.IDENT_RE+"::",keywords:m},{
-className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},n.TITLE_MODE]}]),exports:{
-preprocessor:l,strings:c,keywords:m}}}})());
-hljs.registerLanguage("crmsh",(()=>{"use strict";return e=>{
-const t="group clone ms master location colocation order fencing_topology rsc_ticket acl_target acl_group user role tag xml"
-;return{name:"crmsh",aliases:["crm","pcmk"],case_insensitive:!0,keywords:{
-keyword:"params meta operations op rule attributes utilization read write deny defined not_defined in_range date spec in ref reference attribute type xpath version and or lt gt tag lte gte eq ne \\ number string",
-literal:"Master Started Slave Stopped start promote demote stop monitor true false"
-},contains:[e.HASH_COMMENT_MODE,{beginKeywords:"node",starts:{
-end:"\\s*([\\w_-]+:)?",starts:{className:"title",end:"\\s*[\\$\\w_][\\w_-]*"}}
-},{beginKeywords:"primitive rsc_template",starts:{className:"title",
-end:"\\s*[\\$\\w_][\\w_-]*",starts:{end:"\\s*@?[\\w_][\\w_\\.:-]*"}}},{
-begin:"\\b("+t.split(" ").join("|")+")\\s+",keywords:t,starts:{
-className:"title",end:"[\\$\\w_][\\w_-]*"}},{
-beginKeywords:"property rsc_defaults op_defaults",starts:{className:"title",
-end:"\\s*([\\w_-]+:)?"}},e.QUOTE_STRING_MODE,{className:"meta",
-begin:"(ocf|systemd|service|lsb):[\\w_:-]+",relevance:0},{className:"number",
-begin:"\\b\\d+(\\.\\d+)?(ms|s|h|m)?",relevance:0},{className:"literal",
-begin:"[-]?(infinity|inf)",relevance:0},{className:"attr",
-begin:/([A-Za-z$_#][\w_-]+)=/,relevance:0},{className:"tag",begin:"</?",
-end:"/?>",relevance:0}]}}})());
-hljs.registerLanguage("crystal",(()=>{"use strict";return e=>{
-const n="(_?[ui](8|16|32|64|128))?",i="[a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|[=!]~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~|]|//|//=|&[-+*]=?|&\\*\\*|\\[\\][=?]?",s="[A-Za-z_]\\w*(::\\w+)*(\\?|!)?",a={
-$pattern:"[a-zA-Z_]\\w*[!?=]?",
-keyword:"abstract alias annotation as as? asm begin break case class def do else elsif end ensure enum extend for fun if include instance_sizeof is_a? lib macro module next nil? of out pointerof private protected rescue responds_to? return require select self sizeof struct super then type typeof union uninitialized unless until verbatim when while with yield __DIR__ __END_LINE__ __FILE__ __LINE__",
-literal:"false nil true"},t={className:"subst",begin:/#\{/,end:/\}/,keywords:a
-},c={className:"template-variable",variants:[{begin:"\\{\\{",end:"\\}\\}"},{
-begin:"\\{%",end:"%\\}"}],keywords:a};function r(e,n){const i=[{begin:e,end:n}]
-;return i[0].contains=i,i}const l={className:"string",
-contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/
-},{begin:/`/,end:/`/},{begin:"%[Qwi]?\\(",end:"\\)",contains:r("\\(","\\)")},{
-begin:"%[Qwi]?\\[",end:"\\]",contains:r("\\[","\\]")},{begin:"%[Qwi]?\\{",
-end:/\}/,contains:r(/\{/,/\}/)},{begin:"%[Qwi]?<",end:">",contains:r("<",">")},{
-begin:"%[Qwi]?\\|",end:"\\|"},{begin:/<<-\w+$/,end:/^\s*\w+$/}],relevance:0},b={
-className:"string",variants:[{begin:"%q\\(",end:"\\)",contains:r("\\(","\\)")},{
-begin:"%q\\[",end:"\\]",contains:r("\\[","\\]")},{begin:"%q\\{",end:/\}/,
-contains:r(/\{/,/\}/)},{begin:"%q<",end:">",contains:r("<",">")},{begin:"%q\\|",
-end:"\\|"},{begin:/<<-'\w+'$/,end:/^\s*\w+$/}],relevance:0},o={
-begin:"(?!%\\})("+e.RE_STARTERS_RE+"|\\n|\\b(case|if|select|unless|until|when|while)\\b)\\s*",
-keywords:"case if select unless until when while",contains:[{className:"regexp",
-contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:"//[a-z]*",relevance:0},{
-begin:"/(?!\\/)",end:"/[a-z]*"}]}],relevance:0},g=[c,l,b,{className:"regexp",
-contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:"%r\\(",end:"\\)",
-contains:r("\\(","\\)")},{begin:"%r\\[",end:"\\]",contains:r("\\[","\\]")},{
-begin:"%r\\{",end:/\}/,contains:r(/\{/,/\}/)},{begin:"%r<",end:">",
-contains:r("<",">")},{begin:"%r\\|",end:"\\|"}],relevance:0},o,{
-className:"meta",begin:"@\\[",end:"\\]",
-contains:[e.inherit(e.QUOTE_STRING_MODE,{className:"meta-string"})]
-},e.HASH_COMMENT_MODE,{className:"class",beginKeywords:"class module struct",
-end:"$|;",illegal:/=/,contains:[e.HASH_COMMENT_MODE,e.inherit(e.TITLE_MODE,{
-begin:s}),{begin:"<"}]},{className:"class",beginKeywords:"lib enum union",
-end:"$|;",illegal:/=/,contains:[e.HASH_COMMENT_MODE,e.inherit(e.TITLE_MODE,{
-begin:s})]},{beginKeywords:"annotation",end:"$|;",illegal:/=/,
-contains:[e.HASH_COMMENT_MODE,e.inherit(e.TITLE_MODE,{begin:s})],relevance:2},{
-className:"function",beginKeywords:"def",end:/\B\b/,
-contains:[e.inherit(e.TITLE_MODE,{begin:i,endsParent:!0})]},{
-className:"function",beginKeywords:"fun macro",end:/\B\b/,
-contains:[e.inherit(e.TITLE_MODE,{begin:i,endsParent:!0})],relevance:2},{
-className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{
-className:"symbol",begin:":",contains:[l,{begin:i}],relevance:0},{
-className:"number",variants:[{begin:"\\b0b([01_]+)"+n},{begin:"\\b0o([0-7_]+)"+n
-},{begin:"\\b0x([A-Fa-f0-9_]+)"+n},{
-begin:"\\b([1-9][0-9_]*[0-9]|[0-9])(\\.[0-9][0-9_]*)?([eE]_?[-+]?[0-9_]*)?(_?f(32|64))?(?!_)"
-},{begin:"\\b([1-9][0-9_]*|0)"+n}],relevance:0}]
-;return t.contains=g,c.contains=g.slice(1),{name:"Crystal",aliases:["cr"],
-keywords:a,contains:g}}})());
-hljs.registerLanguage("csharp",(()=>{"use strict";return e=>{const n={
-keyword:["abstract","as","base","break","case","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]),
-built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"],
-literal:["default","false","null","true"]},a=e.inherit(e.TITLE_MODE,{
-begin:"[a-zA-Z](\\.?\\w)*"}),i={className:"number",variants:[{
-begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},s={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]
-},t=e.inherit(s,{illegal:/\n/}),r={className:"subst",begin:/\{/,end:/\}/,
-keywords:n},l=e.inherit(r,{illegal:/\n/}),c={className:"string",begin:/\$"/,
-end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/
-},e.BACKSLASH_ESCAPE,l]},o={className:"string",begin:/\$@"/,end:'"',contains:[{
-begin:/\{\{/},{begin:/\}\}/},{begin:'""'},r]},d=e.inherit(o,{illegal:/\n/,
-contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},l]})
-;r.contains=[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.C_BLOCK_COMMENT_MODE],
-l.contains=[d,c,t,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.inherit(e.C_BLOCK_COMMENT_MODE,{
-illegal:/\n/})];const g={variants:[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-},E={begin:"<",end:">",contains:[{beginKeywords:"in out"},a]
-},_=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",b={
-begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"],
-keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0,
-contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{
-begin:"\x3c!--|--\x3e"},{begin:"</?",end:">"}]}]
-}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#",
-end:"$",keywords:{
-"meta-keyword":"if else elif endif define undef warning error line region endregion pragma checksum"
-}},g,i,{beginKeywords:"class interface",relevance:0,end:/[{;=]/,
-illegal:/[^\s:,]/,contains:[{beginKeywords:"where class"
-},a,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace",
-relevance:0,end:/[{;=]/,illegal:/[^\s:]/,
-contains:[a,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{
-beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/,
-contains:[a,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta",
-begin:"^\\s*\\[",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{
-className:"meta-string",begin:/"/,end:/"/}]},{
-beginKeywords:"new return throw await else",relevance:0},{className:"function",
-begin:"("+_+"\\s+)+"+e.IDENT_RE+"\\s*(<.+>\\s*)?\\(",returnBegin:!0,
-end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{
-beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial",
-relevance:0},{begin:e.IDENT_RE+"\\s*(<.+>\\s*)?\\(",returnBegin:!0,
-contains:[e.TITLE_MODE,E],relevance:0},{className:"params",begin:/\(/,end:/\)/,
-excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0,
-contains:[g,i,e.C_BLOCK_COMMENT_MODE]
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},b]}}})());
-hljs.registerLanguage("csp",(()=>{"use strict";return e=>({name:"CSP",
-case_insensitive:!1,keywords:{$pattern:"[a-zA-Z][a-zA-Z0-9_-]*",
-keyword:"base-uri child-src connect-src default-src font-src form-action frame-ancestors frame-src img-src media-src object-src plugin-types report-uri sandbox script-src style-src"
-},contains:[{className:"string",begin:"'",end:"'"},{className:"attribute",
-begin:"^Content",end:":",excludeEnd:!0}]})})());
-hljs.registerLanguage("css",(()=>{"use strict"
-;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],o=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],r=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse()
-;return n=>{const a=(e=>({IMPORTANT:{className:"meta",begin:"!important"},
-HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"},
-ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/,
-illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}
-}))(n),l=[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE];return{name:"CSS",
-case_insensitive:!0,illegal:/[=|'\$]/,keywords:{keyframePosition:"from to"},
-classNameAliases:{keyframePosition:"selector-tag"},
-contains:[n.C_BLOCK_COMMENT_MODE,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/
-},n.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0
-},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0
-},a.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{
-begin:":("+i.join("|")+")"},{begin:"::("+o.join("|")+")"}]},{
-className:"attribute",begin:"\\b("+r.join("|")+")\\b"},{begin:":",end:"[;}]",
-contains:[a.HEXCOLOR,a.IMPORTANT,n.CSS_NUMBER_MODE,...l,{
-begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri"
-},contains:[{className:"string",begin:/[^)]/,endsWithParent:!0,excludeEnd:!0}]
-},{className:"built_in",begin:/[\w-]+(?=\()/}]},{
-begin:(s=/@/,((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(?=",s,")")),
-end:"[{;]",relevance:0,illegal:/:/,contains:[{className:"keyword",
-begin:/@-?\w[\w]*(-\w+)*/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,
-relevance:0,keywords:{$pattern:/[a-z-]+/,keyword:"and or not only",
-attribute:t.join(" ")},contains:[{begin:/[a-z-]+(?=:)/,className:"attribute"
-},...l,n.CSS_NUMBER_MODE]}]},{className:"selector-tag",
-begin:"\\b("+e.join("|")+")\\b"}]};var s}})());
-hljs.registerLanguage("d",(()=>{"use strict";return e=>{const a={
-$pattern:e.UNDERSCORE_IDENT_RE,
-keyword:"abstract alias align asm assert auto body break byte case cast catch class const continue debug default delete deprecated do else enum export extern final finally for foreach foreach_reverse|10 goto if immutable import in inout int interface invariant is lazy macro mixin module new nothrow out override package pragma private protected public pure ref return scope shared static struct super switch synchronized template this throw try typedef typeid typeof union unittest version void volatile while with __FILE__ __LINE__ __gshared|10 __thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ __VERSION__",
-built_in:"bool cdouble cent cfloat char creal dchar delegate double dstring float function idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar wstring",
-literal:"false null true"
-},d="((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))",n="\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};",t={
-className:"number",begin:"\\b"+d+"(L|u|U|Lu|LU|uL|UL)?",relevance:0},_={
-className:"number",
-begin:"\\b(((0[xX](([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)\\.([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)|\\.?([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))[pP][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))|((0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(\\.\\d*|([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)))|\\d+\\.(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)|\\.(0|[1-9][\\d_]*)([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))?))([fF]|L|i|[fF]i|Li)?|"+d+"(i|[fF]i|Li))",
-relevance:0},r={className:"string",begin:"'("+n+"|.)",end:"'",illegal:"."},i={
-className:"string",begin:'"',contains:[{begin:n,relevance:0}],end:'"[cwd]?'
-},s=e.COMMENT("\\/\\+","\\+\\/",{contains:["self"],relevance:10});return{
-name:"D",keywords:a,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s,{
-className:"string",begin:'x"[\\da-fA-F\\s\\n\\r]*"[cwd]?',relevance:10},i,{
-className:"string",begin:'[rq]"',end:'"[cwd]?',relevance:5},{className:"string",
-begin:"`",end:"`[cwd]?"},{className:"string",begin:'q"\\{',end:'\\}"'},_,t,r,{
-className:"meta",begin:"^#!",end:"$",relevance:5},{className:"meta",
-begin:"#(line)",end:"$",relevance:5},{className:"keyword",
-begin:"@[a-zA-Z_][a-zA-Z_\\d]*"}]}}})());
-hljs.registerLanguage("markdown",(()=>{"use strict";function n(...n){
-return n.map((n=>{return(e=n)?"string"==typeof e?e:e.source:null;var e
-})).join("")}return e=>{const a={begin:/<\/?[A-Za-z_]/,end:">",
-subLanguage:"xml",relevance:0},i={variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0
-},{begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/,
-relevance:2},{begin:n(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/),
-relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{
-begin:/\[.+?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{
-className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0,
-returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)",
-excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[",
-end:"\\]",excludeBegin:!0,excludeEnd:!0}]},s={className:"strong",contains:[],
-variants:[{begin:/_{2}/,end:/_{2}/},{begin:/\*{2}/,end:/\*{2}/}]},c={
-className:"emphasis",contains:[],variants:[{begin:/\*(?!\*)/,end:/\*/},{
-begin:/_(?!_)/,end:/_/,relevance:0}]};s.contains.push(c),c.contains.push(s)
-;let t=[a,i]
-;return s.contains=s.contains.concat(t),c.contains=c.contains.concat(t),
-t=t.concat(s,c),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{
-className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:t},{
-begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n",
-contains:t}]}]},a,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)",
-end:"\\s+",excludeEnd:!0},s,c,{className:"quote",begin:"^>\\s+",contains:t,
-end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{
-begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{
-begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))",
-contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{
-begin:"^[-\\*]{3,}",end:"$"},i,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{
-className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{
-className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}}})());
-hljs.registerLanguage("dart",(()=>{"use strict";return e=>{const n={
-className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"}]},a={className:"subst",
-variants:[{begin:/\$\{/,end:/\}/}],keywords:"true false null this is new super"
-},t={className:"string",variants:[{begin:"r'''",end:"'''"},{begin:'r"""',
-end:'"""'},{begin:"r'",end:"'",illegal:"\\n"},{begin:'r"',end:'"',illegal:"\\n"
-},{begin:"'''",end:"'''",contains:[e.BACKSLASH_ESCAPE,n,a]},{begin:'"""',
-end:'"""',contains:[e.BACKSLASH_ESCAPE,n,a]},{begin:"'",end:"'",illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE,n,a]},{begin:'"',end:'"',illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE,n,a]}]};a.contains=[e.C_NUMBER_MODE,t]
-;const i=["Comparable","DateTime","Duration","Function","Iterable","Iterator","List","Map","Match","Object","Pattern","RegExp","Set","Stopwatch","String","StringBuffer","StringSink","Symbol","Type","Uri","bool","double","int","num","Element","ElementList"],r=i.map((e=>e+"?"))
-;return{name:"Dart",keywords:{
-keyword:"abstract as assert async await break case catch class const continue covariant default deferred do dynamic else enum export extends extension external factory false final finally for Function get hide if implements import in inferface is late library mixin new null on operator part required rethrow return set show static super switch sync this throw true try typedef var void while with yield",
-built_in:i.concat(r).concat(["Never","Null","dynamic","print","document","querySelector","querySelectorAll","window"]),
-$pattern:/[A-Za-z][A-Za-z0-9_]*\??/},
-contains:[t,e.COMMENT(/\/\*\*(?!\/)/,/\*\//,{subLanguage:"markdown",relevance:0
-}),e.COMMENT(/\/{3,} ?/,/$/,{contains:[{subLanguage:"markdown",begin:".",
-end:"$",relevance:0}]}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-className:"class",beginKeywords:"class interface",end:/\{/,excludeEnd:!0,
-contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]
-},e.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"},{begin:"=>"}]}}})());
-hljs.registerLanguage("delphi",(()=>{"use strict";return e=>{
-const r="exports register file shl array record property for mod while set ally label uses raise not stored class safecall var interface or private static exit index inherited to else stdcall override shr asm far resourcestring finalization packed virtual out and protected library do xorwrite goto near function end div overload object unit begin string on inline repeat until destructor write message program with read initialization except default nil if case cdecl in downto threadvar of try pascal const external constructor type public then implementation finally published procedure absolute reintroduce operator as is abstract alias assembler bitpacked break continue cppdecl cvar enumerator experimental platform deprecated unimplemented dynamic export far16 forward generic helper implements interrupt iochecks local name nodefault noreturn nostackframe oldfpccall otherwise saveregisters softfloat specialize strict unaligned varargs ",a=[e.C_LINE_COMMENT_MODE,e.COMMENT(/\{/,/\}/,{
-relevance:0}),e.COMMENT(/\(\*/,/\*\)/,{relevance:10})],t={className:"meta",
-variants:[{begin:/\{\$/,end:/\}/},{begin:/\(\*\$/,end:/\*\)/}]},n={
-className:"string",begin:/'/,end:/'/,contains:[{begin:/''/}]},s={
-className:"string",begin:/(#\d+)+/},i={begin:e.IDENT_RE+"\\s*=\\s*class\\s*\\(",
-returnBegin:!0,contains:[e.TITLE_MODE]},c={className:"function",
-beginKeywords:"function constructor destructor procedure",end:/[:;]/,
-keywords:"function constructor|10 destructor|10 procedure|10",
-contains:[e.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:r,
-contains:[n,s,t].concat(a)},t].concat(a)};return{name:"Delphi",
-aliases:["dpr","dfm","pas","pascal","freepascal","lazarus","lpr","lfm"],
-case_insensitive:!0,keywords:r,illegal:/"|\$[G-Zg-z]|\/\*|<\/|\|/,
-contains:[n,s,e.NUMBER_MODE,{className:"number",relevance:0,variants:[{
-begin:"\\$[0-9A-Fa-f]+"},{begin:"&[0-7]+"},{begin:"%[01]+"}]},i,c,t].concat(a)}}
-})());
-hljs.registerLanguage("diff",(()=>{"use strict";return e=>({name:"Diff",
-aliases:["patch"],contains:[{className:"meta",relevance:10,variants:[{
-begin:/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/},{begin:/^\*\*\* +\d+,\d+ +\*\*\*\*$/},{
-begin:/^--- +\d+,\d+ +----$/}]},{className:"comment",variants:[{begin:/Index: /,
-end:/$/},{begin:/^index/,end:/$/},{begin:/={3,}/,end:/$/},{begin:/^-{3}/,end:/$/
-},{begin:/^\*{3} /,end:/$/},{begin:/^\+{3}/,end:/$/},{begin:/^\*{15}$/},{
-begin:/^diff --git/,end:/$/}]},{className:"addition",begin:/^\+/,end:/$/},{
-className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/,
-end:/$/}]})})());
-hljs.registerLanguage("django",(()=>{"use strict";return e=>{const t={
-begin:/\|[A-Za-z]+:?/,keywords:{
-name:"truncatewords removetags linebreaksbr yesno get_digit timesince random striptags filesizeformat escape linebreaks length_is ljust rjust cut urlize fix_ampersands title floatformat capfirst pprint divisibleby add make_list unordered_list urlencode timeuntil urlizetrunc wordcount stringformat linenumbers slice date dictsort dictsortreversed default_if_none pluralize lower join center default truncatewords_html upper length phone2numeric wordwrap time addslashes slugify first escapejs force_escape iriencode last safe safeseq truncatechars localize unlocalize localtime utc timezone"
-},contains:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE]};return{name:"Django",
-aliases:["jinja"],case_insensitive:!0,subLanguage:"xml",
-contains:[e.COMMENT(/\{%\s*comment\s*%\}/,/\{%\s*endcomment\s*%\}/),e.COMMENT(/\{#/,/#\}/),{
-className:"template-tag",begin:/\{%/,end:/%\}/,contains:[{className:"name",
-begin:/\w+/,keywords:{
-name:"comment endcomment load templatetag ifchanged endifchanged if endif firstof for endfor ifnotequal endifnotequal widthratio extends include spaceless endspaceless regroup ifequal endifequal ssi now with cycle url filter endfilter debug block endblock else autoescape endautoescape csrf_token empty elif endwith static trans blocktrans endblocktrans get_static_prefix get_media_prefix plural get_current_language language get_available_languages get_current_language_bidi get_language_info get_language_info_list localize endlocalize localtime endlocaltime timezone endtimezone get_current_timezone verbatim"
-},starts:{endsWithParent:!0,keywords:"in by as",contains:[t],relevance:0}}]},{
-className:"template-variable",begin:/\{\{/,end:/\}\}/,contains:[t]}]}}})());
-hljs.registerLanguage("dns",(()=>{"use strict";return d=>({name:"DNS Zone",
-aliases:["bind","zone"],keywords:{
-keyword:"IN A AAAA AFSDB APL CAA CDNSKEY CDS CERT CNAME DHCID DLV DNAME DNSKEY DS HIP IPSECKEY KEY KX LOC MX NAPTR NS NSEC NSEC3 NSEC3PARAM PTR RRSIG RP SIG SOA SRV SSHFP TA TKEY TLSA TSIG TXT"
-},contains:[d.COMMENT(";","$",{relevance:0}),{className:"meta",
-begin:/^\$(TTL|GENERATE|INCLUDE|ORIGIN)\b/},{className:"number",
-begin:"((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))\\b"
-},{className:"number",
-begin:"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\b"
-},d.inherit(d.NUMBER_MODE,{begin:/\b\d+[dhwm]?/})]})})());
-hljs.registerLanguage("dockerfile",(()=>{"use strict";return e=>({
-name:"Dockerfile",aliases:["docker"],case_insensitive:!0,
-keywords:"from maintainer expose env arg user onbuild stopsignal",
-contains:[e.HASH_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{
-beginKeywords:"run cmd entrypoint volume add copy workdir label healthcheck shell",
-starts:{end:/[^\\]$/,subLanguage:"bash"}}],illegal:"</"})})());
-hljs.registerLanguage("dos",(()=>{"use strict";return e=>{
-const t=e.COMMENT(/^\s*@?rem\b/,/$/,{relevance:10});return{
-name:"Batch file (DOS)",aliases:["bat","cmd"],case_insensitive:!0,
-illegal:/\/\*/,keywords:{
-keyword:"if else goto for in do call exit not exist errorlevel defined equ neq lss leq gtr geq",
-built_in:"prn nul lpt3 lpt2 lpt1 con com4 com3 com2 com1 aux shift cd dir echo setlocal endlocal set pause copy append assoc at attrib break cacls cd chcp chdir chkdsk chkntfs cls cmd color comp compact convert date dir diskcomp diskcopy doskey erase fs find findstr format ftype graftabl help keyb label md mkdir mode more move path pause print popd pushd promt rd recover rem rename replace restore rmdir shift sort start subst time title tree type ver verify vol ping net ipconfig taskkill xcopy ren del"
-},contains:[{className:"variable",begin:/%%[^ ]|%[^ ]+?%|![^ ]+?!/},{
-className:"function",begin:"^\\s*[A-Za-z._?][A-Za-z0-9_$#@~.?]*(:|\\s+label)",
-end:"goto:eof",contains:[e.inherit(e.TITLE_MODE,{
-begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),t]},{
-className:"number",begin:"\\b\\d+",relevance:0},t]}}})());
-hljs.registerLanguage("dsconfig",(()=>{"use strict";return e=>({
-keywords:"dsconfig",contains:[{className:"keyword",begin:"^dsconfig",end:/\s/,
-excludeEnd:!0,relevance:10},{className:"built_in",
-begin:/(list|create|get|set|delete)-(\w+)/,end:/\s/,excludeEnd:!0,
-illegal:"!@#$%^&*()",relevance:10},{className:"built_in",begin:/--(\w+)/,
-end:/\s/,excludeEnd:!0},{className:"string",begin:/"/,end:/"/},{
-className:"string",begin:/'/,end:/'/},{className:"string",begin:/[\w\-?]+:\w+/,
-end:/\W/,relevance:0},{className:"string",begin:/\w+(\-\w+)*/,end:/(?=\W)/,
-relevance:0},e.HASH_COMMENT_MODE]})})());
-hljs.registerLanguage("dts",(()=>{"use strict";return e=>{const n={
-className:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{
-begin:'((u8?|U)|L)?"'}),{begin:'(u8?|U)?R"',end:'"',
-contains:[e.BACKSLASH_ESCAPE]},{begin:"'\\\\?.",end:"'",illegal:"."}]},a={
-className:"number",variants:[{
-begin:"\\b(\\d+(\\.\\d*)?|\\.\\d+)(u|U|l|L|ul|UL|f|F)"},{begin:e.C_NUMBER_RE}],
-relevance:0},s={className:"meta",begin:"#",end:"$",keywords:{
-"meta-keyword":"if else elif endif define undef ifdef ifndef"},contains:[{
-begin:/\\\n/,relevance:0},{beginKeywords:"include",end:"$",keywords:{
-"meta-keyword":"include"},contains:[e.inherit(n,{className:"meta-string"}),{
-className:"meta-string",begin:"<",end:">",illegal:"\\n"}]
-},n,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},i={className:"variable",
-begin:/&[a-z\d_]*\b/},d={className:"meta-keyword",begin:"/[a-z][a-z\\d-]*/"},l={
-className:"symbol",begin:"^\\s*[a-zA-Z_][a-zA-Z\\d_]*:"},r={className:"params",
-begin:"<",end:">",contains:[a,i]},_={className:"class",
-begin:/[a-zA-Z_][a-zA-Z\d_@]*\s\{/,end:/[{;=]/,returnBegin:!0,excludeEnd:!0}
-;return{name:"Device Tree",keywords:"",contains:[{className:"class",
-begin:"/\\s*\\{",end:/\};/,relevance:10,
-contains:[i,d,l,_,r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,n]
-},i,d,l,_,r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,n,s,{
-begin:e.IDENT_RE+"::",keywords:""}]}}})());
-hljs.registerLanguage("dust",(()=>{"use strict";return e=>({name:"Dust",
-aliases:["dst"],case_insensitive:!0,subLanguage:"xml",contains:[{
-className:"template-tag",begin:/\{[#\/]/,end:/\}/,illegal:/;/,contains:[{
-className:"name",begin:/[a-zA-Z\.-]+/,starts:{endsWithParent:!0,relevance:0,
-contains:[e.QUOTE_STRING_MODE]}}]},{className:"template-variable",begin:/\{/,
-end:/\}/,illegal:/;/,keywords:"if eq ne lt lte gt gte select default math sep"}]
-})})());
-hljs.registerLanguage("ebnf",(()=>{"use strict";return e=>{
-const a=e.COMMENT(/\(\*/,/\*\)/);return{name:"Extended Backus-Naur Form",
-illegal:/\S/,contains:[a,{className:"attribute",
-begin:/^[ ]*[a-zA-Z]+([\s_-]+[a-zA-Z]+)*/},{begin:/=/,end:/[.;]/,contains:[a,{
-className:"meta",begin:/\?.*\?/},{className:"string",
-variants:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"`",end:"`"}]}]}]}}
-})());
-hljs.registerLanguage("elixir",(()=>{"use strict";return e=>{
-const n="[a-zA-Z_][a-zA-Z0-9_.]*(!|\\?)?",i={$pattern:n,
-keyword:"and false then defined module in return redo retry end for true self when next until do begin unless nil break not case cond alias while ensure or include use alias fn quote require import with|0"
-},a={className:"subst",begin:/#\{/,end:/\}/,keywords:i},s={className:"number",
-begin:"(\\b0o[0-7_]+)|(\\b0b[01_]+)|(\\b0x[0-9a-fA-F_]+)|(-?\\b[1-9][0-9_]*(\\.[0-9_]+([eE][-+]?[0-9]+)?)?)",
-relevance:0},b={className:"string",begin:"~[a-z](?=[/|([{<\"'])",contains:[{
-endsParent:!0,contains:[{contains:[e.BACKSLASH_ESCAPE,a],variants:[{begin:/"/,
-end:/"/},{begin:/'/,end:/'/},{begin:/\//,end:/\//},{begin:/\|/,end:/\|/},{
-begin:/\(/,end:/\)/},{begin:/\[/,end:/\]/},{begin:/\{/,end:/\}/},{begin:/</,
-end:/>/}]}]}]},d={className:"string",contains:[e.BACKSLASH_ESCAPE,a],variants:[{
-begin:/"""/,end:/"""/},{begin:/'''/,end:/'''/},{begin:/~S"""/,end:/"""/,
-contains:[]},{begin:/~S"/,end:/"/,contains:[]},{begin:/~S'''/,end:/'''/,
-contains:[]},{begin:/~S'/,end:/'/,contains:[]},{begin:/'/,end:/'/},{begin:/"/,
-end:/"/}]},r={className:"function",beginKeywords:"def defp defmacro",end:/\B\b/,
-contains:[e.inherit(e.TITLE_MODE,{begin:n,endsParent:!0})]},g=e.inherit(r,{
-className:"class",beginKeywords:"defimpl defmodule defprotocol defrecord",
-end:/\bdo\b|$|;/}),t=[d,{className:"string",begin:"~[A-Z](?=[/|([{<\"'])",
-contains:[{begin:/"/,end:/"/},{begin:/'/,end:/'/},{begin:/\//,end:/\//},{
-begin:/\|/,end:/\|/},{begin:/\(/,end:/\)/},{begin:/\[/,end:/\]/},{begin:/\{/,
-end:/\}/},{begin:/</,end:/>/}]},b,e.HASH_COMMENT_MODE,g,r,{begin:"::"},{
-className:"symbol",begin:":(?![\\s:])",contains:[d,{
-begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"
-}],relevance:0},{className:"symbol",begin:n+":(?!:)",relevance:0},s,{
-className:"variable",begin:"(\\$\\W)|((\\$|@@?)(\\w+))"},{begin:"->"},{
-begin:"("+e.RE_STARTERS_RE+")\\s*",contains:[e.HASH_COMMENT_MODE,{
-begin:/\/: (?=\d+\s*[,\]])/,relevance:0,contains:[s]},{className:"regexp",
-illegal:"\\n",contains:[e.BACKSLASH_ESCAPE,a],variants:[{begin:"/",end:"/[a-z]*"
-},{begin:"%r\\[",end:"\\][a-z]*"}]}],relevance:0}];return a.contains=t,{
-name:"Elixir",keywords:i,contains:t}}})());
-hljs.registerLanguage("elm",(()=>{"use strict";return e=>{const n={
-variants:[e.COMMENT("--","$"),e.COMMENT(/\{-/,/-\}/,{contains:["self"]})]},i={
-className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},s={begin:"\\(",end:"\\)",
-illegal:'"',contains:[{className:"type",
-begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},n]};return{name:"Elm",
-keywords:"let in if then else case of where module import exposing type alias as infix infixl infixr port effect command subscription",
-contains:[{beginKeywords:"port effect module",end:"exposing",
-keywords:"port effect module where command subscription exposing",
-contains:[s,n],illegal:"\\W\\.|;"},{begin:"import",end:"$",
-keywords:"import as exposing",contains:[s,n],illegal:"\\W\\.|;"},{begin:"type",
-end:"$",keywords:"type alias",contains:[i,s,{begin:/\{/,end:/\}/,
-contains:s.contains},n]},{beginKeywords:"infix infixl infixr",end:"$",
-contains:[e.C_NUMBER_MODE,n]},{begin:"port",end:"$",keywords:"port",contains:[n]
-},{className:"string",begin:"'\\\\?.",end:"'",illegal:"."
-},e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,i,e.inherit(e.TITLE_MODE,{
-begin:"^[_a-z][\\w']*"}),n,{begin:"->|<-"}],illegal:/;/}}})());
-hljs.registerLanguage("ruby",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{
-const a="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",i={
-keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor __FILE__",
-built_in:"proc lambda",literal:"true false nil"},s={className:"doctag",
-begin:"@[A-Za-z]+"},r={begin:"#<",end:">"},b=[n.COMMENT("#","$",{contains:[s]
-}),n.COMMENT("^=begin","^=end",{contains:[s],relevance:10
-}),n.COMMENT("^__END__","\\n$")],c={className:"subst",begin:/#\{/,end:/\}/,
-keywords:i},t={className:"string",contains:[n.BACKSLASH_ESCAPE,c],variants:[{
-begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:/%[qQwWx]?\(/,
-end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{begin:/%[qQwWx]?\{/,end:/\}/},{
-begin:/%[qQwWx]?</,end:/>/},{begin:/%[qQwWx]?\//,end:/\//},{begin:/%[qQwWx]?%/,
-end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{begin:/%[qQwWx]?\|/,end:/\|/},{
-begin:/\B\?(\\\d{1,3})/},{begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{
-begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{
-begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{
-begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{
-begin:/<<[-~]?'?(\w+)\n(?:[^\n]*\n)*?\s*\1\b/,returnBegin:!0,contains:[{
-begin:/<<[-~]?'?/},n.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,
-contains:[n.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",d={className:"number",
-relevance:0,variants:[{
-begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{
-begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b"
-},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{
-begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{
-begin:"\\b0(_?[0-7])+r?i?\\b"}]},l={className:"params",begin:"\\(",end:"\\)",
-endsParent:!0,keywords:i},o=[t,{className:"class",beginKeywords:"class module",
-end:"$|;",illegal:/=/,contains:[n.inherit(n.TITLE_MODE,{
-begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|!)?"}),{begin:"<\\s*",contains:[{
-begin:"("+n.IDENT_RE+"::)?"+n.IDENT_RE,relevance:0}]}].concat(b)},{
-className:"function",begin:e(/def\s+/,(_=a+"\\s*(\\(|;|$)",e("(?=",_,")"))),
-relevance:0,keywords:"def",end:"$|;",contains:[n.inherit(n.TITLE_MODE,{begin:a
-}),l].concat(b)},{begin:n.IDENT_RE+"::"},{className:"symbol",
-begin:n.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol",
-begin:":(?!\\s)",contains:[t,{begin:a}],relevance:0},d,{className:"variable",
-begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{
-className:"params",begin:/\|/,end:/\|/,relevance:0,keywords:i},{
-begin:"("+n.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[{
-className:"regexp",contains:[n.BACKSLASH_ESCAPE,c],illegal:/\n/,variants:[{
-begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{begin:"%r\\(",
-end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]
-}].concat(r,b),relevance:0}].concat(r,b);var _;c.contains=o,l.contains=o
-;const E=[{begin:/^\s*=>/,starts:{end:"$",contains:o}},{className:"meta",
-begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])",
-starts:{end:"$",contains:o}}];return b.unshift(r),{name:"Ruby",
-aliases:["rb","gemspec","podspec","thor","irb"],keywords:i,illegal:/\/\*/,
-contains:[n.SHEBANG({binary:"ruby"})].concat(E).concat(b).concat(o)}}})());
-hljs.registerLanguage("erb",(()=>{"use strict";return e=>({name:"ERB",
-subLanguage:"xml",contains:[e.COMMENT("<%#","%>"),{begin:"<%[%=-]?",
-end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0}]})})());
-hljs.registerLanguage("erlang-repl",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>({name:"Erlang REPL",keywords:{
-built_in:"spawn spawn_link self",
-keyword:"after and andalso|10 band begin bnot bor bsl bsr bxor case catch cond div end fun if let not of or orelse|10 query receive rem try when xor"
-},contains:[{className:"meta",begin:"^[0-9]+> ",relevance:10
-},n.COMMENT("%","$"),{className:"number",
-begin:"\\b(\\d+(_\\d+)*#[a-fA-F0-9]+(_[a-fA-F0-9]+)*|\\d+(_\\d+)*(\\.\\d+(_\\d+)*)?([eE][-+]?\\d+)?)",
-relevance:0},n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,{
-begin:e(/\?(::)?/,/([A-Z]\w*)/,/((::)[A-Z]\w*)*/)},{begin:"->"},{begin:"ok"},{
-begin:"!"},{
-begin:"(\\b[a-z'][a-zA-Z0-9_']*:[a-z'][a-zA-Z0-9_']*)|(\\b[a-z'][a-zA-Z0-9_']*)",
-relevance:0},{begin:"[A-Z][a-zA-Z0-9_']*",relevance:0}]})})());
-hljs.registerLanguage("erlang",(()=>{"use strict";return e=>{
-const n="[a-z'][a-zA-Z0-9_']*",r="("+n+":"+n+"|"+n+")",a={
-keyword:"after and andalso|10 band begin bnot bor bsl bzr bxor case catch cond div end fun if let not of orelse|10 query receive rem try when xor",
-literal:"false true"},i=e.COMMENT("%","$"),s={className:"number",
-begin:"\\b(\\d+(_\\d+)*#[a-fA-F0-9]+(_[a-fA-F0-9]+)*|\\d+(_\\d+)*(\\.\\d+(_\\d+)*)?([eE][-+]?\\d+)?)",
-relevance:0},c={begin:"fun\\s+"+n+"/\\d+"},t={begin:r+"\\(",end:"\\)",
-returnBegin:!0,relevance:0,contains:[{begin:r,relevance:0},{begin:"\\(",
-end:"\\)",endsWithParent:!0,returnEnd:!0,relevance:0}]},d={begin:/\{/,end:/\}/,
-relevance:0},o={begin:"\\b_([A-Z][A-Za-z0-9_]*)?",relevance:0},l={
-begin:"[A-Z][a-zA-Z0-9_]*",relevance:0},b={begin:"#"+e.UNDERSCORE_IDENT_RE,
-relevance:0,returnBegin:!0,contains:[{begin:"#"+e.UNDERSCORE_IDENT_RE,
-relevance:0},{begin:/\{/,end:/\}/,relevance:0}]},g={
-beginKeywords:"fun receive if try case",end:"end",keywords:a}
-;g.contains=[i,c,e.inherit(e.APOS_STRING_MODE,{className:""
-}),g,t,e.QUOTE_STRING_MODE,s,d,o,l,b]
-;const E=[i,c,g,t,e.QUOTE_STRING_MODE,s,d,o,l,b]
-;t.contains[1].contains=E,d.contains=E,b.contains[1].contains=E;const u={
-className:"params",begin:"\\(",end:"\\)",contains:E};return{name:"Erlang",
-aliases:["erl"],keywords:a,illegal:"(</|\\*=|\\+=|-=|/\\*|\\*/|\\(\\*|\\*\\))",
-contains:[{className:"function",begin:"^"+n+"\\s*\\(",end:"->",returnBegin:!0,
-illegal:"\\(|#|//|/\\*|\\\\|:|;",contains:[u,e.inherit(e.TITLE_MODE,{begin:n})],
-starts:{end:";|\\.",keywords:a,contains:E}},i,{begin:"^-",end:"\\.",relevance:0,
-excludeEnd:!0,returnBegin:!0,keywords:{$pattern:"-"+e.IDENT_RE,
-keyword:["-module","-record","-undef","-export","-ifdef","-ifndef","-author","-copyright","-doc","-vsn","-import","-include","-include_lib","-compile","-define","-else","-endif","-file","-behaviour","-behavior","-spec"].map((e=>e+"|1.5")).join(" ")
-},contains:[u]},s,e.QUOTE_STRING_MODE,b,o,l,d,{begin:/\.$/}]}}})());
-hljs.registerLanguage("excel",(()=>{"use strict";return E=>({
-name:"Excel formulae",aliases:["xlsx","xls"],case_insensitive:!0,keywords:{
-$pattern:/[a-zA-Z][\w\.]*/,
-built_in:"ABS ACCRINT ACCRINTM ACOS ACOSH ACOT ACOTH AGGREGATE ADDRESS AMORDEGRC AMORLINC AND ARABIC AREAS ASC ASIN ASINH ATAN ATAN2 ATANH AVEDEV AVERAGE AVERAGEA AVERAGEIF AVERAGEIFS BAHTTEXT BASE BESSELI BESSELJ BESSELK BESSELY BETADIST BETA.DIST BETAINV BETA.INV BIN2DEC BIN2HEX BIN2OCT BINOMDIST BINOM.DIST BINOM.DIST.RANGE BINOM.INV BITAND BITLSHIFT BITOR BITRSHIFT BITXOR CALL CEILING CEILING.MATH CEILING.PRECISE CELL CHAR CHIDIST CHIINV CHITEST CHISQ.DIST CHISQ.DIST.RT CHISQ.INV CHISQ.INV.RT CHISQ.TEST CHOOSE CLEAN CODE COLUMN COLUMNS COMBIN COMBINA COMPLEX CONCAT CONCATENATE CONFIDENCE CONFIDENCE.NORM CONFIDENCE.T CONVERT CORREL COS COSH COT COTH COUNT COUNTA COUNTBLANK COUNTIF COUNTIFS COUPDAYBS COUPDAYS COUPDAYSNC COUPNCD COUPNUM COUPPCD COVAR COVARIANCE.P COVARIANCE.S CRITBINOM CSC CSCH CUBEKPIMEMBER CUBEMEMBER CUBEMEMBERPROPERTY CUBERANKEDMEMBER CUBESET CUBESETCOUNT CUBEVALUE CUMIPMT CUMPRINC DATE DATEDIF DATEVALUE DAVERAGE DAY DAYS DAYS360 DB DBCS DCOUNT DCOUNTA DDB DEC2BIN DEC2HEX DEC2OCT DECIMAL DEGREES DELTA DEVSQ DGET DISC DMAX DMIN DOLLAR DOLLARDE DOLLARFR DPRODUCT DSTDEV DSTDEVP DSUM DURATION DVAR DVARP EDATE EFFECT ENCODEURL EOMONTH ERF ERF.PRECISE ERFC ERFC.PRECISE ERROR.TYPE EUROCONVERT EVEN EXACT EXP EXPON.DIST EXPONDIST FACT FACTDOUBLE FALSE|0 F.DIST FDIST F.DIST.RT FILTERXML FIND FINDB F.INV F.INV.RT FINV FISHER FISHERINV FIXED FLOOR FLOOR.MATH FLOOR.PRECISE FORECAST FORECAST.ETS FORECAST.ETS.CONFINT FORECAST.ETS.SEASONALITY FORECAST.ETS.STAT FORECAST.LINEAR FORMULATEXT FREQUENCY F.TEST FTEST FV FVSCHEDULE GAMMA GAMMA.DIST GAMMADIST GAMMA.INV GAMMAINV GAMMALN GAMMALN.PRECISE GAUSS GCD GEOMEAN GESTEP GETPIVOTDATA GROWTH HARMEAN HEX2BIN HEX2DEC HEX2OCT HLOOKUP HOUR HYPERLINK HYPGEOM.DIST HYPGEOMDIST IF IFERROR IFNA IFS IMABS IMAGINARY IMARGUMENT IMCONJUGATE IMCOS IMCOSH IMCOT IMCSC IMCSCH IMDIV IMEXP IMLN IMLOG10 IMLOG2 IMPOWER IMPRODUCT IMREAL IMSEC IMSECH IMSIN IMSINH IMSQRT IMSUB IMSUM IMTAN INDEX INDIRECT INFO INT INTERCEPT INTRATE IPMT IRR ISBLANK ISERR ISERROR ISEVEN ISFORMULA ISLOGICAL ISNA ISNONTEXT ISNUMBER ISODD ISREF ISTEXT ISO.CEILING ISOWEEKNUM ISPMT JIS KURT LARGE LCM LEFT LEFTB LEN LENB LINEST LN LOG LOG10 LOGEST LOGINV LOGNORM.DIST LOGNORMDIST LOGNORM.INV LOOKUP LOWER MATCH MAX MAXA MAXIFS MDETERM MDURATION MEDIAN MID MIDBs MIN MINIFS MINA MINUTE MINVERSE MIRR MMULT MOD MODE MODE.MULT MODE.SNGL MONTH MROUND MULTINOMIAL MUNIT N NA NEGBINOM.DIST NEGBINOMDIST NETWORKDAYS NETWORKDAYS.INTL NOMINAL NORM.DIST NORMDIST NORMINV NORM.INV NORM.S.DIST NORMSDIST NORM.S.INV NORMSINV NOT NOW NPER NPV NUMBERVALUE OCT2BIN OCT2DEC OCT2HEX ODD ODDFPRICE ODDFYIELD ODDLPRICE ODDLYIELD OFFSET OR PDURATION PEARSON PERCENTILE.EXC PERCENTILE.INC PERCENTILE PERCENTRANK.EXC PERCENTRANK.INC PERCENTRANK PERMUT PERMUTATIONA PHI PHONETIC PI PMT POISSON.DIST POISSON POWER PPMT PRICE PRICEDISC PRICEMAT PROB PRODUCT PROPER PV QUARTILE QUARTILE.EXC QUARTILE.INC QUOTIENT RADIANS RAND RANDBETWEEN RANK.AVG RANK.EQ RANK RATE RECEIVED REGISTER.ID REPLACE REPLACEB REPT RIGHT RIGHTB ROMAN ROUND ROUNDDOWN ROUNDUP ROW ROWS RRI RSQ RTD SEARCH SEARCHB SEC SECH SECOND SERIESSUM SHEET SHEETS SIGN SIN SINH SKEW SKEW.P SLN SLOPE SMALL SQL.REQUEST SQRT SQRTPI STANDARDIZE STDEV STDEV.P STDEV.S STDEVA STDEVP STDEVPA STEYX SUBSTITUTE SUBTOTAL SUM SUMIF SUMIFS SUMPRODUCT SUMSQ SUMX2MY2 SUMX2PY2 SUMXMY2 SWITCH SYD T TAN TANH TBILLEQ TBILLPRICE TBILLYIELD T.DIST T.DIST.2T T.DIST.RT TDIST TEXT TEXTJOIN TIME TIMEVALUE T.INV T.INV.2T TINV TODAY TRANSPOSE TREND TRIM TRIMMEAN TRUE|0 TRUNC T.TEST TTEST TYPE UNICHAR UNICODE UPPER VALUE VAR VAR.P VAR.S VARA VARP VARPA VDB VLOOKUP WEBSERVICE WEEKDAY WEEKNUM WEIBULL WEIBULL.DIST WORKDAY WORKDAY.INTL XIRR XNPV XOR YEAR YEARFRAC YIELD YIELDDISC YIELDMAT Z.TEST ZTEST"
-},contains:[{begin:/^=/,end:/[^=]/,returnEnd:!0,illegal:/=/,relevance:10},{
-className:"symbol",begin:/\b[A-Z]{1,2}\d+\b/,end:/[^\d]/,excludeEnd:!0,
-relevance:0},{className:"symbol",begin:/[A-Z]{0,2}\d*:[A-Z]{0,2}\d*/,relevance:0
-},E.BACKSLASH_ESCAPE,E.QUOTE_STRING_MODE,{className:"number",
-begin:E.NUMBER_RE+"(%)?",relevance:0},E.COMMENT(/\bN\(/,/\)/,{excludeBegin:!0,
-excludeEnd:!0,illegal:/\n/})]})})());
-hljs.registerLanguage("fix",(()=>{"use strict";return e=>({name:"FIX",
-contains:[{begin:/[^\u2401\u0001]+/,end:/[\u2401\u0001]/,excludeEnd:!0,
-returnBegin:!0,returnEnd:!1,contains:[{begin:/([^\u2401\u0001=]+)/,
-end:/=([^\u2401\u0001=]+)/,returnEnd:!0,returnBegin:!1,className:"attr"},{
-begin:/=/,end:/([\u2401\u0001])/,excludeEnd:!0,excludeBegin:!0,
-className:"string"}]}],case_insensitive:!0})})());
-hljs.registerLanguage("flix",(()=>{"use strict";return e=>({name:"Flix",
-keywords:{literal:"true false",
-keyword:"case class def else enum if impl import in lat rel index let match namespace switch type yield with"
-},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",
-begin:/'(.|\\[xXuU][a-zA-Z0-9]+)'/},{className:"string",variants:[{begin:'"',
-end:'"'}]},{className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,
-excludeEnd:!0,contains:[{className:"title",relevance:0,
-begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/
-}]},e.C_NUMBER_MODE]})})());
-hljs.registerLanguage("fortran",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a={variants:[n.COMMENT("!","$",{relevance:0
-}),n.COMMENT("^C[ ]","$",{relevance:0}),n.COMMENT("^C$","$",{relevance:0})]
-},t=/(_[a-z_\d]+)?/,i=/([de][+-]?\d+)?/,c={className:"number",variants:[{
-begin:e(/\b\d+/,/\.(\d*)/,i,t)},{begin:e(/\b\d+/,i,t)},{begin:e(/\.\d+/,i,t)}],
-relevance:0},o={className:"function",
-beginKeywords:"subroutine function program",illegal:"[${=\\n]",
-contains:[n.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)"}]}
-;return{name:"Fortran",case_insensitive:!0,aliases:["f90","f95"],keywords:{
-literal:".False. .True.",
-keyword:"kind do concurrent local shared while private call intrinsic where elsewhere type endtype endmodule endselect endinterface end enddo endif if forall endforall only contains default return stop then block endblock endassociate public subroutine|10 function program .and. .or. .not. .le. .eq. .ge. .gt. .lt. goto save else use module select case access blank direct exist file fmt form formatted iostat name named nextrec number opened rec recl sequential status unformatted unit continue format pause cycle exit c_null_char c_alert c_backspace c_form_feed flush wait decimal round iomsg synchronous nopass non_overridable pass protected volatile abstract extends import non_intrinsic value deferred generic final enumerator class associate bind enum c_int c_short c_long c_long_long c_signed_char c_size_t c_int8_t c_int16_t c_int32_t c_int64_t c_int_least8_t c_int_least16_t c_int_least32_t c_int_least64_t c_int_fast8_t c_int_fast16_t c_int_fast32_t c_int_fast64_t c_intmax_t C_intptr_t c_float c_double c_long_double c_float_complex c_double_complex c_long_double_complex c_bool c_char c_null_ptr c_null_funptr c_new_line c_carriage_return c_horizontal_tab c_vertical_tab iso_c_binding c_loc c_funloc c_associated  c_f_pointer c_ptr c_funptr iso_fortran_env character_storage_size error_unit file_storage_size input_unit iostat_end iostat_eor numeric_storage_size output_unit c_f_procpointer ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode newunit contiguous recursive pad position action delim readwrite eor advance nml interface procedure namelist include sequence elemental pure impure integer real character complex logical codimension dimension allocatable|10 parameter external implicit|10 none double precision assign intent optional pointer target in out common equivalence data",
-built_in:"alog alog10 amax0 amax1 amin0 amin1 amod cabs ccos cexp clog csin csqrt dabs dacos dasin datan datan2 dcos dcosh ddim dexp dint dlog dlog10 dmax1 dmin1 dmod dnint dsign dsin dsinh dsqrt dtan dtanh float iabs idim idint idnint ifix isign max0 max1 min0 min1 sngl algama cdabs cdcos cdexp cdlog cdsin cdsqrt cqabs cqcos cqexp cqlog cqsin cqsqrt dcmplx dconjg derf derfc dfloat dgamma dimag dlgama iqint qabs qacos qasin qatan qatan2 qcmplx qconjg qcos qcosh qdim qerf qerfc qexp qgamma qimag qlgama qlog qlog10 qmax1 qmin1 qmod qnint qsign qsin qsinh qsqrt qtan qtanh abs acos aimag aint anint asin atan atan2 char cmplx conjg cos cosh exp ichar index int log log10 max min nint sign sin sinh sqrt tan tanh print write dim lge lgt lle llt mod nullify allocate deallocate adjustl adjustr all allocated any associated bit_size btest ceiling count cshift date_and_time digits dot_product eoshift epsilon exponent floor fraction huge iand ibclr ibits ibset ieor ior ishft ishftc lbound len_trim matmul maxexponent maxloc maxval merge minexponent minloc minval modulo mvbits nearest pack present product radix random_number random_seed range repeat reshape rrspacing scale scan selected_int_kind selected_real_kind set_exponent shape size spacing spread sum system_clock tiny transpose trim ubound unpack verify achar iachar transfer dble entry dprod cpu_time command_argument_count get_command get_command_argument get_environment_variable is_iostat_end ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode is_iostat_eor move_alloc new_line selected_char_kind same_type_as extends_type_of acosh asinh atanh bessel_j0 bessel_j1 bessel_jn bessel_y0 bessel_y1 bessel_yn erf erfc erfc_scaled gamma log_gamma hypot norm2 atomic_define atomic_ref execute_command_line leadz trailz storage_size merge_bits bge bgt ble blt dshiftl dshiftr findloc iall iany iparity image_index lcobound ucobound maskl maskr num_images parity popcnt poppar shifta shiftl shiftr this_image sync change team co_broadcast co_max co_min co_sum co_reduce"
-},illegal:/\/\*/,contains:[{className:"string",relevance:0,
-variants:[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE]},o,{begin:/^C\s*=(?!=)/,
-relevance:0},a,c]}}})());
-hljs.registerLanguage("fsharp",(()=>{"use strict";return e=>{const n={begin:"<",
-end:">",contains:[e.inherit(e.TITLE_MODE,{begin:/'[a-zA-Z0-9_]+/})]};return{
-name:"F#",aliases:["fs"],
-keywords:"abstract and as assert base begin class default delegate do done downcast downto elif else end exception extern false finally for fun function global if in inherit inline interface internal lazy let match member module mutable namespace new null of open or override private public rec return sig static struct then to true try type upcast use val void when while with yield",
-illegal:/\/\*/,contains:[{className:"keyword",begin:/\b(yield|return|let|do)!/
-},{className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},{
-className:"string",begin:'"""',end:'"""'},e.COMMENT("\\(\\*(\\s)","\\*\\)",{
-contains:["self"]}),{className:"class",beginKeywords:"type",end:"\\(|=|$",
-excludeEnd:!0,contains:[e.UNDERSCORE_TITLE_MODE,n]},{className:"meta",
-begin:"\\[<",end:">\\]",relevance:10},{className:"symbol",
-begin:"\\B('[A-Za-z])\\b",contains:[e.BACKSLASH_ESCAPE]
-},e.C_LINE_COMMENT_MODE,e.inherit(e.QUOTE_STRING_MODE,{illegal:null
-}),e.C_NUMBER_MODE]}}})());
-hljs.registerLanguage("gams",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a={
-keyword:"abort acronym acronyms alias all and assign binary card diag display else eq file files for free ge gt if integer le loop lt maximizing minimizing model models ne negative no not option options or ord positive prod put putpage puttl repeat sameas semicont semiint smax smin solve sos1 sos2 sum system table then until using while xor yes",
-literal:"eps inf na",
-built_in:"abs arccos arcsin arctan arctan2 Beta betaReg binomial ceil centropy cos cosh cvPower div div0 eDist entropy errorf execSeed exp fact floor frac gamma gammaReg log logBeta logGamma log10 log2 mapVal max min mod ncpCM ncpF ncpVUpow ncpVUsin normal pi poly power randBinomial randLinear randTriangle round rPower sigmoid sign signPower sin sinh slexp sllog10 slrec sqexp sqlog10 sqr sqrec sqrt tan tanh trunc uniform uniformInt vcPower bool_and bool_eqv bool_imp bool_not bool_or bool_xor ifThen rel_eq rel_ge rel_gt rel_le rel_lt rel_ne gday gdow ghour gleap gmillisec gminute gmonth gsecond gyear jdate jnow jstart jtime errorLevel execError gamsRelease gamsVersion handleCollect handleDelete handleStatus handleSubmit heapFree heapLimit heapSize jobHandle jobKill jobStatus jobTerminate licenseLevel licenseStatus maxExecError sleep timeClose timeComp timeElapsed timeExec timeStart"
-},i={className:"symbol",variants:[{begin:/=[lgenxc]=/},{begin:/\$/}]},s={
-className:"comment",variants:[{begin:"'",end:"'"},{begin:'"',end:'"'}],
-illegal:"\\n",contains:[n.BACKSLASH_ESCAPE]},o={begin:"/",end:"/",keywords:a,
-contains:[s,n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,n.C_NUMBER_MODE]
-},t=/[a-z0-9&#*=?@\\><:,()$[\]_.{}!+%^-]+/,r={
-begin:/[a-z][a-z0-9_]*(\([a-z0-9_, ]*\))?[ \t]+/,excludeBegin:!0,end:"$",
-endsWithParent:!0,contains:[s,o,{className:"comment",
-begin:e(t,(l=e(/[ ]+/,t),e("(",l,")*"))),relevance:0}]};var l;return{
-name:"GAMS",aliases:["gms"],case_insensitive:!0,keywords:a,
-contains:[n.COMMENT(/^\$ontext/,/^\$offtext/),{className:"meta",
-begin:"^\\$[a-z0-9]+",end:"$",returnBegin:!0,contains:[{
-className:"meta-keyword",begin:"^\\$[a-z0-9]+"}]
-},n.COMMENT("^\\*","$"),n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,{
-beginKeywords:"set sets parameter parameters variable variables scalar scalars equation equations",
-end:";",
-contains:[n.COMMENT("^\\*","$"),n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,o,r]
-},{beginKeywords:"table",end:";",returnBegin:!0,contains:[{
-beginKeywords:"table",end:"$",contains:[r]
-},n.COMMENT("^\\*","$"),n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,n.C_NUMBER_MODE]
-},{className:"function",begin:/^[a-z][a-z0-9_,\-+' ()$]+\.{2}/,returnBegin:!0,
-contains:[{className:"title",begin:/^[a-z0-9_]+/},{className:"params",
-begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0},i]},n.C_NUMBER_MODE,i]}}
-})());
-hljs.registerLanguage("gauss",(()=>{"use strict";return e=>{const t={
-keyword:"bool break call callexe checkinterrupt clear clearg closeall cls comlog compile continue create debug declare delete disable dlibrary dllcall do dos ed edit else elseif enable end endfor endif endp endo errorlog errorlogat expr external fn for format goto gosub graph if keyword let lib library line load loadarray loadexe loadf loadk loadm loadp loads loadx local locate loopnextindex lprint lpwidth lshow matrix msym ndpclex new open output outwidth plot plotsym pop prcsn print printdos proc push retp return rndcon rndmod rndmult rndseed run save saveall screen scroll setarray show sparse stop string struct system trace trap threadfor threadendfor threadbegin threadjoin threadstat threadend until use while winprint ne ge le gt lt and xor or not eq eqv",
-built_in:"abs acf aconcat aeye amax amean AmericanBinomCall AmericanBinomCall_Greeks AmericanBinomCall_ImpVol AmericanBinomPut AmericanBinomPut_Greeks AmericanBinomPut_ImpVol AmericanBSCall AmericanBSCall_Greeks AmericanBSCall_ImpVol AmericanBSPut AmericanBSPut_Greeks AmericanBSPut_ImpVol amin amult annotationGetDefaults annotationSetBkd annotationSetFont annotationSetLineColor annotationSetLineStyle annotationSetLineThickness annualTradingDays arccos arcsin areshape arrayalloc arrayindex arrayinit arraytomat asciiload asclabel astd astds asum atan atan2 atranspose axmargin balance band bandchol bandcholsol bandltsol bandrv bandsolpd bar base10 begwind besselj bessely beta box boxcox cdfBeta cdfBetaInv cdfBinomial cdfBinomialInv cdfBvn cdfBvn2 cdfBvn2e cdfCauchy cdfCauchyInv cdfChic cdfChii cdfChinc cdfChincInv cdfExp cdfExpInv cdfFc cdfFnc cdfFncInv cdfGam cdfGenPareto cdfHyperGeo cdfLaplace cdfLaplaceInv cdfLogistic cdfLogisticInv cdfmControlCreate cdfMvn cdfMvn2e cdfMvnce cdfMvne cdfMvt2e cdfMvtce cdfMvte cdfN cdfN2 cdfNc cdfNegBinomial cdfNegBinomialInv cdfNi cdfPoisson cdfPoissonInv cdfRayleigh cdfRayleighInv cdfTc cdfTci cdfTnc cdfTvn cdfWeibull cdfWeibullInv cdir ceil ChangeDir chdir chiBarSquare chol choldn cholsol cholup chrs close code cols colsf combinate combinated complex con cond conj cons ConScore contour conv convertsatostr convertstrtosa corrm corrms corrvc corrx corrxs cos cosh counts countwts crossprd crout croutp csrcol csrlin csvReadM csvReadSA cumprodc cumsumc curve cvtos datacreate datacreatecomplex datalist dataload dataloop dataopen datasave date datestr datestring datestrymd dayinyr dayofweek dbAddDatabase dbClose dbCommit dbCreateQuery dbExecQuery dbGetConnectOptions dbGetDatabaseName dbGetDriverName dbGetDrivers dbGetHostName dbGetLastErrorNum dbGetLastErrorText dbGetNumericalPrecPolicy dbGetPassword dbGetPort dbGetTableHeaders dbGetTables dbGetUserName dbHasFeature dbIsDriverAvailable dbIsOpen dbIsOpenError dbOpen dbQueryBindValue dbQueryClear dbQueryCols dbQueryExecPrepared dbQueryFetchAllM dbQueryFetchAllSA dbQueryFetchOneM dbQueryFetchOneSA dbQueryFinish dbQueryGetBoundValue dbQueryGetBoundValues dbQueryGetField dbQueryGetLastErrorNum dbQueryGetLastErrorText dbQueryGetLastInsertID dbQueryGetLastQuery dbQueryGetPosition dbQueryIsActive dbQueryIsForwardOnly dbQueryIsNull dbQueryIsSelect dbQueryIsValid dbQueryPrepare dbQueryRows dbQuerySeek dbQuerySeekFirst dbQuerySeekLast dbQuerySeekNext dbQuerySeekPrevious dbQuerySetForwardOnly dbRemoveDatabase dbRollback dbSetConnectOptions dbSetDatabaseName dbSetHostName dbSetNumericalPrecPolicy dbSetPort dbSetUserName dbTransaction DeleteFile delif delrows denseToSp denseToSpRE denToZero design det detl dfft dffti diag diagrv digamma doswin DOSWinCloseall DOSWinOpen dotfeq dotfeqmt dotfge dotfgemt dotfgt dotfgtmt dotfle dotflemt dotflt dotfltmt dotfne dotfnemt draw drop dsCreate dstat dstatmt dstatmtControlCreate dtdate dtday dttime dttodtv dttostr dttoutc dtvnormal dtvtodt dtvtoutc dummy dummybr dummydn eig eigh eighv eigv elapsedTradingDays endwind envget eof eqSolve eqSolvemt eqSolvemtControlCreate eqSolvemtOutCreate eqSolveset erf erfc erfccplx erfcplx error etdays ethsec etstr EuropeanBinomCall EuropeanBinomCall_Greeks EuropeanBinomCall_ImpVol EuropeanBinomPut EuropeanBinomPut_Greeks EuropeanBinomPut_ImpVol EuropeanBSCall EuropeanBSCall_Greeks EuropeanBSCall_ImpVol EuropeanBSPut EuropeanBSPut_Greeks EuropeanBSPut_ImpVol exctsmpl exec execbg exp extern eye fcheckerr fclearerr feq feqmt fflush fft ffti fftm fftmi fftn fge fgemt fgets fgetsa fgetsat fgetst fgt fgtmt fileinfo filesa fle flemt floor flt fltmt fmod fne fnemt fonts fopen formatcv formatnv fputs fputst fseek fstrerror ftell ftocv ftos ftostrC gamma gammacplx gammaii gausset gdaAppend gdaCreate gdaDStat gdaDStatMat gdaGetIndex gdaGetName gdaGetNames gdaGetOrders gdaGetType gdaGetTypes gdaGetVarInfo gdaIsCplx gdaLoad gdaPack gdaRead gdaReadByIndex gdaReadSome gdaReadSparse gdaReadStruct gdaReportVarInfo gdaSave gdaUpdate gdaUpdateAndPack gdaVars gdaWrite gdaWrite32 gdaWriteSome getarray getdims getf getGAUSShome getmatrix getmatrix4D getname getnamef getNextTradingDay getNextWeekDay getnr getorders getpath getPreviousTradingDay getPreviousWeekDay getRow getscalar3D getscalar4D getTrRow getwind glm gradcplx gradMT gradMTm gradMTT gradMTTm gradp graphprt graphset hasimag header headermt hess hessMT hessMTg hessMTgw hessMTm hessMTmw hessMTT hessMTTg hessMTTgw hessMTTm hessMTw hessp hist histf histp hsec imag indcv indexcat indices indices2 indicesf indicesfn indnv indsav integrate1d integrateControlCreate intgrat2 intgrat3 inthp1 inthp2 inthp3 inthp4 inthpControlCreate intquad1 intquad2 intquad3 intrleav intrleavsa intrsect intsimp inv invpd invswp iscplx iscplxf isden isinfnanmiss ismiss key keyav keyw lag lag1 lagn lapEighb lapEighi lapEighvb lapEighvi lapgEig lapgEigh lapgEighv lapgEigv lapgSchur lapgSvdcst lapgSvds lapgSvdst lapSvdcusv lapSvds lapSvdusv ldlp ldlsol linSolve listwise ln lncdfbvn lncdfbvn2 lncdfmvn lncdfn lncdfn2 lncdfnc lnfact lngammacplx lnpdfmvn lnpdfmvt lnpdfn lnpdft loadd loadstruct loadwind loess loessmt loessmtControlCreate log loglog logx logy lower lowmat lowmat1 ltrisol lu lusol machEpsilon make makevars makewind margin matalloc matinit mattoarray maxbytes maxc maxindc maxv maxvec mbesselei mbesselei0 mbesselei1 mbesseli mbesseli0 mbesseli1 meanc median mergeby mergevar minc minindc minv miss missex missrv moment momentd movingave movingaveExpwgt movingaveWgt nextindex nextn nextnevn nextwind ntos null null1 numCombinations ols olsmt olsmtControlCreate olsqr olsqr2 olsqrmt ones optn optnevn orth outtyp pacf packedToSp packr parse pause pdfCauchy pdfChi pdfExp pdfGenPareto pdfHyperGeo pdfLaplace pdfLogistic pdfn pdfPoisson pdfRayleigh pdfWeibull pi pinv pinvmt plotAddArrow plotAddBar plotAddBox plotAddHist plotAddHistF plotAddHistP plotAddPolar plotAddScatter plotAddShape plotAddTextbox plotAddTS plotAddXY plotArea plotBar plotBox plotClearLayout plotContour plotCustomLayout plotGetDefaults plotHist plotHistF plotHistP plotLayout plotLogLog plotLogX plotLogY plotOpenWindow plotPolar plotSave plotScatter plotSetAxesPen plotSetBar plotSetBarFill plotSetBarStacked plotSetBkdColor plotSetFill plotSetGrid plotSetLegend plotSetLineColor plotSetLineStyle plotSetLineSymbol plotSetLineThickness plotSetNewWindow plotSetTitle plotSetWhichYAxis plotSetXAxisShow plotSetXLabel plotSetXRange plotSetXTicInterval plotSetXTicLabel plotSetYAxisShow plotSetYLabel plotSetYRange plotSetZAxisShow plotSetZLabel plotSurface plotTS plotXY polar polychar polyeval polygamma polyint polymake polymat polymroot polymult polyroot pqgwin previousindex princomp printfm printfmt prodc psi putarray putf putvals pvCreate pvGetIndex pvGetParNames pvGetParVector pvLength pvList pvPack pvPacki pvPackm pvPackmi pvPacks pvPacksi pvPacksm pvPacksmi pvPutParVector pvTest pvUnpack QNewton QNewtonmt QNewtonmtControlCreate QNewtonmtOutCreate QNewtonSet QProg QProgmt QProgmtInCreate qqr qqre qqrep qr qre qrep qrsol qrtsol qtyr qtyre qtyrep quantile quantiled qyr qyre qyrep qz rank rankindx readr real reclassify reclassifyCuts recode recserar recsercp recserrc rerun rescale reshape rets rev rfft rffti rfftip rfftn rfftnp rfftp rndBernoulli rndBeta rndBinomial rndCauchy rndChiSquare rndCon rndCreateState rndExp rndGamma rndGeo rndGumbel rndHyperGeo rndi rndKMbeta rndKMgam rndKMi rndKMn rndKMnb rndKMp rndKMu rndKMvm rndLaplace rndLCbeta rndLCgam rndLCi rndLCn rndLCnb rndLCp rndLCu rndLCvm rndLogNorm rndMTu rndMVn rndMVt rndn rndnb rndNegBinomial rndp rndPoisson rndRayleigh rndStateSkip rndu rndvm rndWeibull rndWishart rotater round rows rowsf rref sampleData satostrC saved saveStruct savewind scale scale3d scalerr scalinfnanmiss scalmiss schtoc schur searchsourcepath seekr select selif seqa seqm setdif setdifsa setvars setvwrmode setwind shell shiftr sin singleindex sinh sleep solpd sortc sortcc sortd sorthc sorthcc sortind sortindc sortmc sortr sortrc spBiconjGradSol spChol spConjGradSol spCreate spDenseSubmat spDiagRvMat spEigv spEye spLDL spline spLU spNumNZE spOnes spreadSheetReadM spreadSheetReadSA spreadSheetWrite spScale spSubmat spToDense spTrTDense spTScalar spZeros sqpSolve sqpSolveMT sqpSolveMTControlCreate sqpSolveMTlagrangeCreate sqpSolveMToutCreate sqpSolveSet sqrt statements stdc stdsc stocv stof strcombine strindx strlen strput strrindx strsect strsplit strsplitPad strtodt strtof strtofcplx strtriml strtrimr strtrunc strtruncl strtruncpad strtruncr submat subscat substute subvec sumc sumr surface svd svd1 svd2 svdcusv svds svdusv sysstate tab tan tanh tempname time timedt timestr timeutc title tkf2eps tkf2ps tocart todaydt toeplitz token topolar trapchk trigamma trimr trunc type typecv typef union unionsa uniqindx uniqindxsa unique uniquesa upmat upmat1 upper utctodt utctodtv utrisol vals varCovMS varCovXS varget vargetl varmall varmares varput varputl vartypef vcm vcms vcx vcxs vec vech vecr vector vget view viewxyz vlist vnamecv volume vput vread vtypecv wait waitc walkindex where window writer xlabel xlsGetSheetCount xlsGetSheetSize xlsGetSheetTypes xlsMakeRange xlsReadM xlsReadSA xlsWrite xlsWriteM xlsWriteSA xpnd xtics xy xyz ylabel ytics zeros zeta zlabel ztics cdfEmpirical dot h5create h5open h5read h5readAttribute h5write h5writeAttribute ldl plotAddErrorBar plotAddSurface plotCDFEmpirical plotSetColormap plotSetContourLabels plotSetLegendFont plotSetTextInterpreter plotSetXTicCount plotSetYTicCount plotSetZLevels powerm strjoin sylvester strtrim",
-literal:"DB_AFTER_LAST_ROW DB_ALL_TABLES DB_BATCH_OPERATIONS DB_BEFORE_FIRST_ROW DB_BLOB DB_EVENT_NOTIFICATIONS DB_FINISH_QUERY DB_HIGH_PRECISION DB_LAST_INSERT_ID DB_LOW_PRECISION_DOUBLE DB_LOW_PRECISION_INT32 DB_LOW_PRECISION_INT64 DB_LOW_PRECISION_NUMBERS DB_MULTIPLE_RESULT_SETS DB_NAMED_PLACEHOLDERS DB_POSITIONAL_PLACEHOLDERS DB_PREPARED_QUERIES DB_QUERY_SIZE DB_SIMPLE_LOCKING DB_SYSTEM_TABLES DB_TABLES DB_TRANSACTIONS DB_UNICODE DB_VIEWS __STDIN __STDOUT __STDERR __FILE_DIR"
-},a=e.COMMENT("@","@"),r={className:"meta",begin:"#",end:"$",keywords:{
-"meta-keyword":"define definecs|10 undef ifdef ifndef iflight ifdllcall ifmac ifos2win ifunix else endif lineson linesoff srcfile srcline"
-},contains:[{begin:/\\\n/,relevance:0},{beginKeywords:"include",end:"$",
-keywords:{"meta-keyword":"include"},contains:[{className:"meta-string",
-begin:'"',end:'"',illegal:"\\n"}]
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a]},n={begin:/\bstruct\s+/,
-end:/\s/,keywords:"struct",contains:[{className:"type",
-begin:e.UNDERSCORE_IDENT_RE,relevance:0}]},s=[{className:"params",begin:/\(/,
-end:/\)/,excludeBegin:!0,excludeEnd:!0,endsWithParent:!0,relevance:0,contains:[{
-className:"literal",begin:/\.\.\./},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,a,n]
-}],o={className:"title",begin:e.UNDERSCORE_IDENT_RE,relevance:0},d=(t,r,n)=>{
-const d=e.inherit({className:"function",beginKeywords:t,end:r,excludeEnd:!0,
-contains:[].concat(s)},n||{})
-;return d.contains.push(o),d.contains.push(e.C_NUMBER_MODE),
-d.contains.push(e.C_BLOCK_COMMENT_MODE),d.contains.push(a),d},l={
-className:"built_in",begin:"\\b("+t.built_in.split(" ").join("|")+")\\b"},i={
-className:"string",begin:'"',end:'"',contains:[e.BACKSLASH_ESCAPE],relevance:0
-},c={begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,keywords:t,
-relevance:0,contains:[{beginKeywords:t.keyword},l,{className:"built_in",
-begin:e.UNDERSCORE_IDENT_RE,relevance:0}]},p={begin:/\(/,end:/\)/,relevance:0,
-keywords:{built_in:t.built_in,literal:t.literal},
-contains:[e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,a,l,c,i,"self"]}
-;return c.contains.push(p),{name:"GAUSS",aliases:["gss"],case_insensitive:!0,
-keywords:t,illegal:/(\{[%#]|[%#]\}| <- )/,
-contains:[e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,i,r,{
-className:"keyword",
-begin:/\bexternal (matrix|string|array|sparse matrix|struct|proc|keyword|fn)/
-},d("proc keyword",";"),d("fn","="),{beginKeywords:"for threadfor",end:/;/,
-relevance:0,contains:[e.C_BLOCK_COMMENT_MODE,a,p]},{variants:[{
-begin:e.UNDERSCORE_IDENT_RE+"\\."+e.UNDERSCORE_IDENT_RE},{
-begin:e.UNDERSCORE_IDENT_RE+"\\s*="}],relevance:0},c,n]}}})());
-hljs.registerLanguage("gcode",(()=>{"use strict";return e=>{
-const a=e.inherit(e.C_NUMBER_MODE,{
-begin:"([-+]?((\\.\\d+)|(\\d+)(\\.\\d*)?))|"+e.C_NUMBER_RE
-}),n=[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.COMMENT(/\(/,/\)/),a,e.inherit(e.APOS_STRING_MODE,{
-illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"name",
-begin:"([G])([0-9]+\\.?[0-9]?)"},{className:"name",
-begin:"([M])([0-9]+\\.?[0-9]?)"},{className:"attr",begin:"(VC|VS|#)",
-end:"(\\d+)"},{className:"attr",begin:"(VZOFX|VZOFY|VZOFZ)"},{
-className:"built_in",
-begin:"(ATAN|ABS|ACOS|ASIN|SIN|COS|EXP|FIX|FUP|ROUND|LN|TAN)(\\[)",contains:[a],
-end:"\\]"},{className:"symbol",variants:[{begin:"N",end:"\\d+",illegal:"\\W"}]}]
-;return{name:"G-code (ISO 6983)",aliases:["nc"],case_insensitive:!0,keywords:{
-$pattern:"[A-Z_][A-Z0-9_.]*",
-keyword:"IF DO WHILE ENDWHILE CALL ENDIF SUB ENDSUB GOTO REPEAT ENDREPEAT EQ LT GT NE GE LE OR XOR"
-},contains:[{className:"meta",begin:"%"},{className:"meta",begin:"([O])([0-9]+)"
-}].concat(n)}}})());
-hljs.registerLanguage("gherkin",(()=>{"use strict";return e=>({name:"Gherkin",
-aliases:["feature"],
-keywords:"Feature Background Ability Business Need Scenario Scenarios Scenario Outline Scenario Template Examples Given And Then But When",
-contains:[{className:"symbol",begin:"\\*",relevance:0},{className:"meta",
-begin:"@[^@\\s]+"},{begin:"\\|",end:"\\|\\w*$",contains:[{className:"string",
-begin:"[^|]+"}]},{className:"variable",begin:"<",end:">"},e.HASH_COMMENT_MODE,{
-className:"string",begin:'"""',end:'"""'},e.QUOTE_STRING_MODE]})})());
-hljs.registerLanguage("glsl",(()=>{"use strict";return e=>({name:"GLSL",
-keywords:{
-keyword:"break continue discard do else for if return while switch case default attribute binding buffer ccw centroid centroid varying coherent column_major const cw depth_any depth_greater depth_less depth_unchanged early_fragment_tests equal_spacing flat fractional_even_spacing fractional_odd_spacing highp in index inout invariant invocations isolines layout line_strip lines lines_adjacency local_size_x local_size_y local_size_z location lowp max_vertices mediump noperspective offset origin_upper_left out packed patch pixel_center_integer point_mode points precise precision quads r11f_g11f_b10f r16 r16_snorm r16f r16i r16ui r32f r32i r32ui r8 r8_snorm r8i r8ui readonly restrict rg16 rg16_snorm rg16f rg16i rg16ui rg32f rg32i rg32ui rg8 rg8_snorm rg8i rg8ui rgb10_a2 rgb10_a2ui rgba16 rgba16_snorm rgba16f rgba16i rgba16ui rgba32f rgba32i rgba32ui rgba8 rgba8_snorm rgba8i rgba8ui row_major sample shared smooth std140 std430 stream triangle_strip triangles triangles_adjacency uniform varying vertices volatile writeonly",
-type:"atomic_uint bool bvec2 bvec3 bvec4 dmat2 dmat2x2 dmat2x3 dmat2x4 dmat3 dmat3x2 dmat3x3 dmat3x4 dmat4 dmat4x2 dmat4x3 dmat4x4 double dvec2 dvec3 dvec4 float iimage1D iimage1DArray iimage2D iimage2DArray iimage2DMS iimage2DMSArray iimage2DRect iimage3D iimageBuffer iimageCube iimageCubeArray image1D image1DArray image2D image2DArray image2DMS image2DMSArray image2DRect image3D imageBuffer imageCube imageCubeArray int isampler1D isampler1DArray isampler2D isampler2DArray isampler2DMS isampler2DMSArray isampler2DRect isampler3D isamplerBuffer isamplerCube isamplerCubeArray ivec2 ivec3 ivec4 mat2 mat2x2 mat2x3 mat2x4 mat3 mat3x2 mat3x3 mat3x4 mat4 mat4x2 mat4x3 mat4x4 sampler1D sampler1DArray sampler1DArrayShadow sampler1DShadow sampler2D sampler2DArray sampler2DArrayShadow sampler2DMS sampler2DMSArray sampler2DRect sampler2DRectShadow sampler2DShadow sampler3D samplerBuffer samplerCube samplerCubeArray samplerCubeArrayShadow samplerCubeShadow image1D uimage1DArray uimage2D uimage2DArray uimage2DMS uimage2DMSArray uimage2DRect uimage3D uimageBuffer uimageCube uimageCubeArray uint usampler1D usampler1DArray usampler2D usampler2DArray usampler2DMS usampler2DMSArray usampler2DRect usampler3D samplerBuffer usamplerCube usamplerCubeArray uvec2 uvec3 uvec4 vec2 vec3 vec4 void",
-built_in:"gl_MaxAtomicCounterBindings gl_MaxAtomicCounterBufferSize gl_MaxClipDistances gl_MaxClipPlanes gl_MaxCombinedAtomicCounterBuffers gl_MaxCombinedAtomicCounters gl_MaxCombinedImageUniforms gl_MaxCombinedImageUnitsAndFragmentOutputs gl_MaxCombinedTextureImageUnits gl_MaxComputeAtomicCounterBuffers gl_MaxComputeAtomicCounters gl_MaxComputeImageUniforms gl_MaxComputeTextureImageUnits gl_MaxComputeUniformComponents gl_MaxComputeWorkGroupCount gl_MaxComputeWorkGroupSize gl_MaxDrawBuffers gl_MaxFragmentAtomicCounterBuffers gl_MaxFragmentAtomicCounters gl_MaxFragmentImageUniforms gl_MaxFragmentInputComponents gl_MaxFragmentInputVectors gl_MaxFragmentUniformComponents gl_MaxFragmentUniformVectors gl_MaxGeometryAtomicCounterBuffers gl_MaxGeometryAtomicCounters gl_MaxGeometryImageUniforms gl_MaxGeometryInputComponents gl_MaxGeometryOutputComponents gl_MaxGeometryOutputVertices gl_MaxGeometryTextureImageUnits gl_MaxGeometryTotalOutputComponents gl_MaxGeometryUniformComponents gl_MaxGeometryVaryingComponents gl_MaxImageSamples gl_MaxImageUnits gl_MaxLights gl_MaxPatchVertices gl_MaxProgramTexelOffset gl_MaxTessControlAtomicCounterBuffers gl_MaxTessControlAtomicCounters gl_MaxTessControlImageUniforms gl_MaxTessControlInputComponents gl_MaxTessControlOutputComponents gl_MaxTessControlTextureImageUnits gl_MaxTessControlTotalOutputComponents gl_MaxTessControlUniformComponents gl_MaxTessEvaluationAtomicCounterBuffers gl_MaxTessEvaluationAtomicCounters gl_MaxTessEvaluationImageUniforms gl_MaxTessEvaluationInputComponents gl_MaxTessEvaluationOutputComponents gl_MaxTessEvaluationTextureImageUnits gl_MaxTessEvaluationUniformComponents gl_MaxTessGenLevel gl_MaxTessPatchComponents gl_MaxTextureCoords gl_MaxTextureImageUnits gl_MaxTextureUnits gl_MaxVaryingComponents gl_MaxVaryingFloats gl_MaxVaryingVectors gl_MaxVertexAtomicCounterBuffers gl_MaxVertexAtomicCounters gl_MaxVertexAttribs gl_MaxVertexImageUniforms gl_MaxVertexOutputComponents gl_MaxVertexOutputVectors gl_MaxVertexTextureImageUnits gl_MaxVertexUniformComponents gl_MaxVertexUniformVectors gl_MaxViewports gl_MinProgramTexelOffset gl_BackColor gl_BackLightModelProduct gl_BackLightProduct gl_BackMaterial gl_BackSecondaryColor gl_ClipDistance gl_ClipPlane gl_ClipVertex gl_Color gl_DepthRange gl_EyePlaneQ gl_EyePlaneR gl_EyePlaneS gl_EyePlaneT gl_Fog gl_FogCoord gl_FogFragCoord gl_FragColor gl_FragCoord gl_FragData gl_FragDepth gl_FrontColor gl_FrontFacing gl_FrontLightModelProduct gl_FrontLightProduct gl_FrontMaterial gl_FrontSecondaryColor gl_GlobalInvocationID gl_InstanceID gl_InvocationID gl_Layer gl_LightModel gl_LightSource gl_LocalInvocationID gl_LocalInvocationIndex gl_ModelViewMatrix gl_ModelViewMatrixInverse gl_ModelViewMatrixInverseTranspose gl_ModelViewMatrixTranspose gl_ModelViewProjectionMatrix gl_ModelViewProjectionMatrixInverse gl_ModelViewProjectionMatrixInverseTranspose gl_ModelViewProjectionMatrixTranspose gl_MultiTexCoord0 gl_MultiTexCoord1 gl_MultiTexCoord2 gl_MultiTexCoord3 gl_MultiTexCoord4 gl_MultiTexCoord5 gl_MultiTexCoord6 gl_MultiTexCoord7 gl_Normal gl_NormalMatrix gl_NormalScale gl_NumSamples gl_NumWorkGroups gl_ObjectPlaneQ gl_ObjectPlaneR gl_ObjectPlaneS gl_ObjectPlaneT gl_PatchVerticesIn gl_Point gl_PointCoord gl_PointSize gl_Position gl_PrimitiveID gl_PrimitiveIDIn gl_ProjectionMatrix gl_ProjectionMatrixInverse gl_ProjectionMatrixInverseTranspose gl_ProjectionMatrixTranspose gl_SampleID gl_SampleMask gl_SampleMaskIn gl_SamplePosition gl_SecondaryColor gl_TessCoord gl_TessLevelInner gl_TessLevelOuter gl_TexCoord gl_TextureEnvColor gl_TextureMatrix gl_TextureMatrixInverse gl_TextureMatrixInverseTranspose gl_TextureMatrixTranspose gl_Vertex gl_VertexID gl_ViewportIndex gl_WorkGroupID gl_WorkGroupSize gl_in gl_out EmitStreamVertex EmitVertex EndPrimitive EndStreamPrimitive abs acos acosh all any asin asinh atan atanh atomicAdd atomicAnd atomicCompSwap atomicCounter atomicCounterDecrement atomicCounterIncrement atomicExchange atomicMax atomicMin atomicOr atomicXor barrier bitCount bitfieldExtract bitfieldInsert bitfieldReverse ceil clamp cos cosh cross dFdx dFdy degrees determinant distance dot equal exp exp2 faceforward findLSB findMSB floatBitsToInt floatBitsToUint floor fma fract frexp ftransform fwidth greaterThan greaterThanEqual groupMemoryBarrier imageAtomicAdd imageAtomicAnd imageAtomicCompSwap imageAtomicExchange imageAtomicMax imageAtomicMin imageAtomicOr imageAtomicXor imageLoad imageSize imageStore imulExtended intBitsToFloat interpolateAtCentroid interpolateAtOffset interpolateAtSample inverse inversesqrt isinf isnan ldexp length lessThan lessThanEqual log log2 matrixCompMult max memoryBarrier memoryBarrierAtomicCounter memoryBarrierBuffer memoryBarrierImage memoryBarrierShared min mix mod modf noise1 noise2 noise3 noise4 normalize not notEqual outerProduct packDouble2x32 packHalf2x16 packSnorm2x16 packSnorm4x8 packUnorm2x16 packUnorm4x8 pow radians reflect refract round roundEven shadow1D shadow1DLod shadow1DProj shadow1DProjLod shadow2D shadow2DLod shadow2DProj shadow2DProjLod sign sin sinh smoothstep sqrt step tan tanh texelFetch texelFetchOffset texture texture1D texture1DLod texture1DProj texture1DProjLod texture2D texture2DLod texture2DProj texture2DProjLod texture3D texture3DLod texture3DProj texture3DProjLod textureCube textureCubeLod textureGather textureGatherOffset textureGatherOffsets textureGrad textureGradOffset textureLod textureLodOffset textureOffset textureProj textureProjGrad textureProjGradOffset textureProjLod textureProjLodOffset textureProjOffset textureQueryLevels textureQueryLod textureSize transpose trunc uaddCarry uintBitsToFloat umulExtended unpackDouble2x32 unpackHalf2x16 unpackSnorm2x16 unpackSnorm4x8 unpackUnorm2x16 unpackUnorm4x8 usubBorrow",
-literal:"true false"},illegal:'"',
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"#",end:"$"}]})})());
-hljs.registerLanguage("gml",(()=>{"use strict";return e=>({name:"GML",
-case_insensitive:!1,keywords:{
-keyword:"begin end if then else while do for break continue with until repeat exit and or xor not return mod div switch case default var globalvar enum function constructor delete #macro #region #endregion",
-built_in:"is_real is_string is_array is_undefined is_int32 is_int64 is_ptr is_vec3 is_vec4 is_matrix is_bool is_method is_struct is_infinity is_nan is_numeric typeof variable_global_exists variable_global_get variable_global_set variable_instance_exists variable_instance_get variable_instance_set variable_instance_get_names variable_struct_exists variable_struct_get variable_struct_get_names variable_struct_names_count variable_struct_remove variable_struct_set array_delete array_insert array_length array_length_1d array_length_2d array_height_2d array_equals array_create array_copy array_pop array_push array_resize array_sort random random_range irandom irandom_range random_set_seed random_get_seed randomize randomise choose abs round floor ceil sign frac sqrt sqr exp ln log2 log10 sin cos tan arcsin arccos arctan arctan2 dsin dcos dtan darcsin darccos darctan darctan2 degtorad radtodeg power logn min max mean median clamp lerp dot_product dot_product_3d dot_product_normalised dot_product_3d_normalised dot_product_normalized dot_product_3d_normalized math_set_epsilon math_get_epsilon angle_difference point_distance_3d point_distance point_direction lengthdir_x lengthdir_y real string int64 ptr string_format chr ansi_char ord string_length string_byte_length string_pos string_copy string_char_at string_ord_at string_byte_at string_set_byte_at string_delete string_insert string_lower string_upper string_repeat string_letters string_digits string_lettersdigits string_replace string_replace_all string_count string_hash_to_newline clipboard_has_text clipboard_set_text clipboard_get_text date_current_datetime date_create_datetime date_valid_datetime date_inc_year date_inc_month date_inc_week date_inc_day date_inc_hour date_inc_minute date_inc_second date_get_year date_get_month date_get_week date_get_day date_get_hour date_get_minute date_get_second date_get_weekday date_get_day_of_year date_get_hour_of_year date_get_minute_of_year date_get_second_of_year date_year_span date_month_span date_week_span date_day_span date_hour_span date_minute_span date_second_span date_compare_datetime date_compare_date date_compare_time date_date_of date_time_of date_datetime_string date_date_string date_time_string date_days_in_month date_days_in_year date_leap_year date_is_today date_set_timezone date_get_timezone game_set_speed game_get_speed motion_set motion_add place_free place_empty place_meeting place_snapped move_random move_snap move_towards_point move_contact_solid move_contact_all move_outside_solid move_outside_all move_bounce_solid move_bounce_all move_wrap distance_to_point distance_to_object position_empty position_meeting path_start path_end mp_linear_step mp_potential_step mp_linear_step_object mp_potential_step_object mp_potential_settings mp_linear_path mp_potential_path mp_linear_path_object mp_potential_path_object mp_grid_create mp_grid_destroy mp_grid_clear_all mp_grid_clear_cell mp_grid_clear_rectangle mp_grid_add_cell mp_grid_get_cell mp_grid_add_rectangle mp_grid_add_instances mp_grid_path mp_grid_draw mp_grid_to_ds_grid collision_point collision_rectangle collision_circle collision_ellipse collision_line collision_point_list collision_rectangle_list collision_circle_list collision_ellipse_list collision_line_list instance_position_list instance_place_list point_in_rectangle point_in_triangle point_in_circle rectangle_in_rectangle rectangle_in_triangle rectangle_in_circle instance_find instance_exists instance_number instance_position instance_nearest instance_furthest instance_place instance_create_depth instance_create_layer instance_copy instance_change instance_destroy position_destroy position_change instance_id_get instance_deactivate_all instance_deactivate_object instance_deactivate_region instance_activate_all instance_activate_object instance_activate_region room_goto room_goto_previous room_goto_next room_previous room_next room_restart game_end game_restart game_load game_save game_save_buffer game_load_buffer event_perform event_user event_perform_object event_inherited show_debug_message show_debug_overlay debug_event debug_get_callstack alarm_get alarm_set font_texture_page_size keyboard_set_map keyboard_get_map keyboard_unset_map keyboard_check keyboard_check_pressed keyboard_check_released keyboard_check_direct keyboard_get_numlock keyboard_set_numlock keyboard_key_press keyboard_key_release keyboard_clear io_clear mouse_check_button mouse_check_button_pressed mouse_check_button_released mouse_wheel_up mouse_wheel_down mouse_clear draw_self draw_sprite draw_sprite_pos draw_sprite_ext draw_sprite_stretched draw_sprite_stretched_ext draw_sprite_tiled draw_sprite_tiled_ext draw_sprite_part draw_sprite_part_ext draw_sprite_general draw_clear draw_clear_alpha draw_point draw_line draw_line_width draw_rectangle draw_roundrect draw_roundrect_ext draw_triangle draw_circle draw_ellipse draw_set_circle_precision draw_arrow draw_button draw_path draw_healthbar draw_getpixel draw_getpixel_ext draw_set_colour draw_set_color draw_set_alpha draw_get_colour draw_get_color draw_get_alpha merge_colour make_colour_rgb make_colour_hsv colour_get_red colour_get_green colour_get_blue colour_get_hue colour_get_saturation colour_get_value merge_color make_color_rgb make_color_hsv color_get_red color_get_green color_get_blue color_get_hue color_get_saturation color_get_value merge_color screen_save screen_save_part draw_set_font draw_set_halign draw_set_valign draw_text draw_text_ext string_width string_height string_width_ext string_height_ext draw_text_transformed draw_text_ext_transformed draw_text_colour draw_text_ext_colour draw_text_transformed_colour draw_text_ext_transformed_colour draw_text_color draw_text_ext_color draw_text_transformed_color draw_text_ext_transformed_color draw_point_colour draw_line_colour draw_line_width_colour draw_rectangle_colour draw_roundrect_colour draw_roundrect_colour_ext draw_triangle_colour draw_circle_colour draw_ellipse_colour draw_point_color draw_line_color draw_line_width_color draw_rectangle_color draw_roundrect_color draw_roundrect_color_ext draw_triangle_color draw_circle_color draw_ellipse_color draw_primitive_begin draw_vertex draw_vertex_colour draw_vertex_color draw_primitive_end sprite_get_uvs font_get_uvs sprite_get_texture font_get_texture texture_get_width texture_get_height texture_get_uvs draw_primitive_begin_texture draw_vertex_texture draw_vertex_texture_colour draw_vertex_texture_color texture_global_scale surface_create surface_create_ext surface_resize surface_free surface_exists surface_get_width surface_get_height surface_get_texture surface_set_target surface_set_target_ext surface_reset_target surface_depth_disable surface_get_depth_disable draw_surface draw_surface_stretched draw_surface_tiled draw_surface_part draw_surface_ext draw_surface_stretched_ext draw_surface_tiled_ext draw_surface_part_ext draw_surface_general surface_getpixel surface_getpixel_ext surface_save surface_save_part surface_copy surface_copy_part application_surface_draw_enable application_get_position application_surface_enable application_surface_is_enabled display_get_width display_get_height display_get_orientation display_get_gui_width display_get_gui_height display_reset display_mouse_get_x display_mouse_get_y display_mouse_set display_set_ui_visibility window_set_fullscreen window_get_fullscreen window_set_caption window_set_min_width window_set_max_width window_set_min_height window_set_max_height window_get_visible_rects window_get_caption window_set_cursor window_get_cursor window_set_colour window_get_colour window_set_color window_get_color window_set_position window_set_size window_set_rectangle window_center window_get_x window_get_y window_get_width window_get_height window_mouse_get_x window_mouse_get_y window_mouse_set window_view_mouse_get_x window_view_mouse_get_y window_views_mouse_get_x window_views_mouse_get_y audio_listener_position audio_listener_velocity audio_listener_orientation audio_emitter_position audio_emitter_create audio_emitter_free audio_emitter_exists audio_emitter_pitch audio_emitter_velocity audio_emitter_falloff audio_emitter_gain audio_play_sound audio_play_sound_on audio_play_sound_at audio_stop_sound audio_resume_music audio_music_is_playing audio_resume_sound audio_pause_sound audio_pause_music audio_channel_num audio_sound_length audio_get_type audio_falloff_set_model audio_play_music audio_stop_music audio_master_gain audio_music_gain audio_sound_gain audio_sound_pitch audio_stop_all audio_resume_all audio_pause_all audio_is_playing audio_is_paused audio_exists audio_sound_set_track_position audio_sound_get_track_position audio_emitter_get_gain audio_emitter_get_pitch audio_emitter_get_x audio_emitter_get_y audio_emitter_get_z audio_emitter_get_vx audio_emitter_get_vy audio_emitter_get_vz audio_listener_set_position audio_listener_set_velocity audio_listener_set_orientation audio_listener_get_data audio_set_master_gain audio_get_master_gain audio_sound_get_gain audio_sound_get_pitch audio_get_name audio_sound_set_track_position audio_sound_get_track_position audio_create_stream audio_destroy_stream audio_create_sync_group audio_destroy_sync_group audio_play_in_sync_group audio_start_sync_group audio_stop_sync_group audio_pause_sync_group audio_resume_sync_group audio_sync_group_get_track_pos audio_sync_group_debug audio_sync_group_is_playing audio_debug audio_group_load audio_group_unload audio_group_is_loaded audio_group_load_progress audio_group_name audio_group_stop_all audio_group_set_gain audio_create_buffer_sound audio_free_buffer_sound audio_create_play_queue audio_free_play_queue audio_queue_sound audio_get_recorder_count audio_get_recorder_info audio_start_recording audio_stop_recording audio_sound_get_listener_mask audio_emitter_get_listener_mask audio_get_listener_mask audio_sound_set_listener_mask audio_emitter_set_listener_mask audio_set_listener_mask audio_get_listener_count audio_get_listener_info audio_system show_message show_message_async clickable_add clickable_add_ext clickable_change clickable_change_ext clickable_delete clickable_exists clickable_set_style show_question show_question_async get_integer get_string get_integer_async get_string_async get_login_async get_open_filename get_save_filename get_open_filename_ext get_save_filename_ext show_error highscore_clear highscore_add highscore_value highscore_name draw_highscore sprite_exists sprite_get_name sprite_get_number sprite_get_width sprite_get_height sprite_get_xoffset sprite_get_yoffset sprite_get_bbox_left sprite_get_bbox_right sprite_get_bbox_top sprite_get_bbox_bottom sprite_save sprite_save_strip sprite_set_cache_size sprite_set_cache_size_ext sprite_get_tpe sprite_prefetch sprite_prefetch_multi sprite_flush sprite_flush_multi sprite_set_speed sprite_get_speed_type sprite_get_speed font_exists font_get_name font_get_fontname font_get_bold font_get_italic font_get_first font_get_last font_get_size font_set_cache_size path_exists path_get_name path_get_length path_get_time path_get_kind path_get_closed path_get_precision path_get_number path_get_point_x path_get_point_y path_get_point_speed path_get_x path_get_y path_get_speed script_exists script_get_name timeline_add timeline_delete timeline_clear timeline_exists timeline_get_name timeline_moment_clear timeline_moment_add_script timeline_size timeline_max_moment object_exists object_get_name object_get_sprite object_get_solid object_get_visible object_get_persistent object_get_mask object_get_parent object_get_physics object_is_ancestor room_exists room_get_name sprite_set_offset sprite_duplicate sprite_assign sprite_merge sprite_add sprite_replace sprite_create_from_surface sprite_add_from_surface sprite_delete sprite_set_alpha_from_sprite sprite_collision_mask font_add_enable_aa font_add_get_enable_aa font_add font_add_sprite font_add_sprite_ext font_replace font_replace_sprite font_replace_sprite_ext font_delete path_set_kind path_set_closed path_set_precision path_add path_assign path_duplicate path_append path_delete path_add_point path_insert_point path_change_point path_delete_point path_clear_points path_reverse path_mirror path_flip path_rotate path_rescale path_shift script_execute object_set_sprite object_set_solid object_set_visible object_set_persistent object_set_mask room_set_width room_set_height room_set_persistent room_set_background_colour room_set_background_color room_set_view room_set_viewport room_get_viewport room_set_view_enabled room_add room_duplicate room_assign room_instance_add room_instance_clear room_get_camera room_set_camera asset_get_index asset_get_type file_text_open_from_string file_text_open_read file_text_open_write file_text_open_append file_text_close file_text_write_string file_text_write_real file_text_writeln file_text_read_string file_text_read_real file_text_readln file_text_eof file_text_eoln file_exists file_delete file_rename file_copy directory_exists directory_create directory_destroy file_find_first file_find_next file_find_close file_attributes filename_name filename_path filename_dir filename_drive filename_ext filename_change_ext file_bin_open file_bin_rewrite file_bin_close file_bin_position file_bin_size file_bin_seek file_bin_write_byte file_bin_read_byte parameter_count parameter_string environment_get_variable ini_open_from_string ini_open ini_close ini_read_string ini_read_real ini_write_string ini_write_real ini_key_exists ini_section_exists ini_key_delete ini_section_delete ds_set_precision ds_exists ds_stack_create ds_stack_destroy ds_stack_clear ds_stack_copy ds_stack_size ds_stack_empty ds_stack_push ds_stack_pop ds_stack_top ds_stack_write ds_stack_read ds_queue_create ds_queue_destroy ds_queue_clear ds_queue_copy ds_queue_size ds_queue_empty ds_queue_enqueue ds_queue_dequeue ds_queue_head ds_queue_tail ds_queue_write ds_queue_read ds_list_create ds_list_destroy ds_list_clear ds_list_copy ds_list_size ds_list_empty ds_list_add ds_list_insert ds_list_replace ds_list_delete ds_list_find_index ds_list_find_value ds_list_mark_as_list ds_list_mark_as_map ds_list_sort ds_list_shuffle ds_list_write ds_list_read ds_list_set ds_map_create ds_map_destroy ds_map_clear ds_map_copy ds_map_size ds_map_empty ds_map_add ds_map_add_list ds_map_add_map ds_map_replace ds_map_replace_map ds_map_replace_list ds_map_delete ds_map_exists ds_map_find_value ds_map_find_previous ds_map_find_next ds_map_find_first ds_map_find_last ds_map_write ds_map_read ds_map_secure_save ds_map_secure_load ds_map_secure_load_buffer ds_map_secure_save_buffer ds_map_set ds_priority_create ds_priority_destroy ds_priority_clear ds_priority_copy ds_priority_size ds_priority_empty ds_priority_add ds_priority_change_priority ds_priority_find_priority ds_priority_delete_value ds_priority_delete_min ds_priority_find_min ds_priority_delete_max ds_priority_find_max ds_priority_write ds_priority_read ds_grid_create ds_grid_destroy ds_grid_copy ds_grid_resize ds_grid_width ds_grid_height ds_grid_clear ds_grid_set ds_grid_add ds_grid_multiply ds_grid_set_region ds_grid_add_region ds_grid_multiply_region ds_grid_set_disk ds_grid_add_disk ds_grid_multiply_disk ds_grid_set_grid_region ds_grid_add_grid_region ds_grid_multiply_grid_region ds_grid_get ds_grid_get_sum ds_grid_get_max ds_grid_get_min ds_grid_get_mean ds_grid_get_disk_sum ds_grid_get_disk_min ds_grid_get_disk_max ds_grid_get_disk_mean ds_grid_value_exists ds_grid_value_x ds_grid_value_y ds_grid_value_disk_exists ds_grid_value_disk_x ds_grid_value_disk_y ds_grid_shuffle ds_grid_write ds_grid_read ds_grid_sort ds_grid_set ds_grid_get effect_create_below effect_create_above effect_clear part_type_create part_type_destroy part_type_exists part_type_clear part_type_shape part_type_sprite part_type_size part_type_scale part_type_orientation part_type_life part_type_step part_type_death part_type_speed part_type_direction part_type_gravity part_type_colour1 part_type_colour2 part_type_colour3 part_type_colour_mix part_type_colour_rgb part_type_colour_hsv part_type_color1 part_type_color2 part_type_color3 part_type_color_mix part_type_color_rgb part_type_color_hsv part_type_alpha1 part_type_alpha2 part_type_alpha3 part_type_blend part_system_create part_system_create_layer part_system_destroy part_system_exists part_system_clear part_system_draw_order part_system_depth part_system_position part_system_automatic_update part_system_automatic_draw part_system_update part_system_drawit part_system_get_layer part_system_layer part_particles_create part_particles_create_colour part_particles_create_color part_particles_clear part_particles_count part_emitter_create part_emitter_destroy part_emitter_destroy_all part_emitter_exists part_emitter_clear part_emitter_region part_emitter_burst part_emitter_stream external_call external_define external_free window_handle window_device matrix_get matrix_set matrix_build_identity matrix_build matrix_build_lookat matrix_build_projection_ortho matrix_build_projection_perspective matrix_build_projection_perspective_fov matrix_multiply matrix_transform_vertex matrix_stack_push matrix_stack_pop matrix_stack_multiply matrix_stack_set matrix_stack_clear matrix_stack_top matrix_stack_is_empty browser_input_capture os_get_config os_get_info os_get_language os_get_region os_lock_orientation display_get_dpi_x display_get_dpi_y display_set_gui_size display_set_gui_maximise display_set_gui_maximize device_mouse_dbclick_enable display_set_timing_method display_get_timing_method display_set_sleep_margin display_get_sleep_margin virtual_key_add virtual_key_hide virtual_key_delete virtual_key_show draw_enable_drawevent draw_enable_swf_aa draw_set_swf_aa_level draw_get_swf_aa_level draw_texture_flush draw_flush gpu_set_blendenable gpu_set_ztestenable gpu_set_zfunc gpu_set_zwriteenable gpu_set_lightingenable gpu_set_fog gpu_set_cullmode gpu_set_blendmode gpu_set_blendmode_ext gpu_set_blendmode_ext_sepalpha gpu_set_colorwriteenable gpu_set_colourwriteenable gpu_set_alphatestenable gpu_set_alphatestref gpu_set_alphatestfunc gpu_set_texfilter gpu_set_texfilter_ext gpu_set_texrepeat gpu_set_texrepeat_ext gpu_set_tex_filter gpu_set_tex_filter_ext gpu_set_tex_repeat gpu_set_tex_repeat_ext gpu_set_tex_mip_filter gpu_set_tex_mip_filter_ext gpu_set_tex_mip_bias gpu_set_tex_mip_bias_ext gpu_set_tex_min_mip gpu_set_tex_min_mip_ext gpu_set_tex_max_mip gpu_set_tex_max_mip_ext gpu_set_tex_max_aniso gpu_set_tex_max_aniso_ext gpu_set_tex_mip_enable gpu_set_tex_mip_enable_ext gpu_get_blendenable gpu_get_ztestenable gpu_get_zfunc gpu_get_zwriteenable gpu_get_lightingenable gpu_get_fog gpu_get_cullmode gpu_get_blendmode gpu_get_blendmode_ext gpu_get_blendmode_ext_sepalpha gpu_get_blendmode_src gpu_get_blendmode_dest gpu_get_blendmode_srcalpha gpu_get_blendmode_destalpha gpu_get_colorwriteenable gpu_get_colourwriteenable gpu_get_alphatestenable gpu_get_alphatestref gpu_get_alphatestfunc gpu_get_texfilter gpu_get_texfilter_ext gpu_get_texrepeat gpu_get_texrepeat_ext gpu_get_tex_filter gpu_get_tex_filter_ext gpu_get_tex_repeat gpu_get_tex_repeat_ext gpu_get_tex_mip_filter gpu_get_tex_mip_filter_ext gpu_get_tex_mip_bias gpu_get_tex_mip_bias_ext gpu_get_tex_min_mip gpu_get_tex_min_mip_ext gpu_get_tex_max_mip gpu_get_tex_max_mip_ext gpu_get_tex_max_aniso gpu_get_tex_max_aniso_ext gpu_get_tex_mip_enable gpu_get_tex_mip_enable_ext gpu_push_state gpu_pop_state gpu_get_state gpu_set_state draw_light_define_ambient draw_light_define_direction draw_light_define_point draw_light_enable draw_set_lighting draw_light_get_ambient draw_light_get draw_get_lighting shop_leave_rating url_get_domain url_open url_open_ext url_open_full get_timer achievement_login achievement_logout achievement_post achievement_increment achievement_post_score achievement_available achievement_show_achievements achievement_show_leaderboards achievement_load_friends achievement_load_leaderboard achievement_send_challenge achievement_load_progress achievement_reset achievement_login_status achievement_get_pic achievement_show_challenge_notifications achievement_get_challenges achievement_event achievement_show achievement_get_info cloud_file_save cloud_string_save cloud_synchronise ads_enable ads_disable ads_setup ads_engagement_launch ads_engagement_available ads_engagement_active ads_event ads_event_preload ads_set_reward_callback ads_get_display_height ads_get_display_width ads_move ads_interstitial_available ads_interstitial_display device_get_tilt_x device_get_tilt_y device_get_tilt_z device_is_keypad_open device_mouse_check_button device_mouse_check_button_pressed device_mouse_check_button_released device_mouse_x device_mouse_y device_mouse_raw_x device_mouse_raw_y device_mouse_x_to_gui device_mouse_y_to_gui iap_activate iap_status iap_enumerate_products iap_restore_all iap_acquire iap_consume iap_product_details iap_purchase_details facebook_init facebook_login facebook_status facebook_graph_request facebook_dialog facebook_logout facebook_launch_offerwall facebook_post_message facebook_send_invite facebook_user_id facebook_accesstoken facebook_check_permission facebook_request_read_permissions facebook_request_publish_permissions gamepad_is_supported gamepad_get_device_count gamepad_is_connected gamepad_get_description gamepad_get_button_threshold gamepad_set_button_threshold gamepad_get_axis_deadzone gamepad_set_axis_deadzone gamepad_button_count gamepad_button_check gamepad_button_check_pressed gamepad_button_check_released gamepad_button_value gamepad_axis_count gamepad_axis_value gamepad_set_vibration gamepad_set_colour gamepad_set_color os_is_paused window_has_focus code_is_compiled http_get http_get_file http_post_string http_request json_encode json_decode zip_unzip load_csv base64_encode base64_decode md5_string_unicode md5_string_utf8 md5_file os_is_network_connected sha1_string_unicode sha1_string_utf8 sha1_file os_powersave_enable analytics_event analytics_event_ext win8_livetile_tile_notification win8_livetile_tile_clear win8_livetile_badge_notification win8_livetile_badge_clear win8_livetile_queue_enable win8_secondarytile_pin win8_secondarytile_badge_notification win8_secondarytile_delete win8_livetile_notification_begin win8_livetile_notification_secondary_begin win8_livetile_notification_expiry win8_livetile_notification_tag win8_livetile_notification_text_add win8_livetile_notification_image_add win8_livetile_notification_end win8_appbar_enable win8_appbar_add_element win8_appbar_remove_element win8_settingscharm_add_entry win8_settingscharm_add_html_entry win8_settingscharm_add_xaml_entry win8_settingscharm_set_xaml_property win8_settingscharm_get_xaml_property win8_settingscharm_remove_entry win8_share_image win8_share_screenshot win8_share_file win8_share_url win8_share_text win8_search_enable win8_search_disable win8_search_add_suggestions win8_device_touchscreen_available win8_license_initialize_sandbox win8_license_trial_version winphone_license_trial_version winphone_tile_title winphone_tile_count winphone_tile_back_title winphone_tile_back_content winphone_tile_back_content_wide winphone_tile_front_image winphone_tile_front_image_small winphone_tile_front_image_wide winphone_tile_back_image winphone_tile_back_image_wide winphone_tile_background_colour winphone_tile_background_color winphone_tile_icon_image winphone_tile_small_icon_image winphone_tile_wide_content winphone_tile_cycle_images winphone_tile_small_background_image physics_world_create physics_world_gravity physics_world_update_speed physics_world_update_iterations physics_world_draw_debug physics_pause_enable physics_fixture_create physics_fixture_set_kinematic physics_fixture_set_density physics_fixture_set_awake physics_fixture_set_restitution physics_fixture_set_friction physics_fixture_set_collision_group physics_fixture_set_sensor physics_fixture_set_linear_damping physics_fixture_set_angular_damping physics_fixture_set_circle_shape physics_fixture_set_box_shape physics_fixture_set_edge_shape physics_fixture_set_polygon_shape physics_fixture_set_chain_shape physics_fixture_add_point physics_fixture_bind physics_fixture_bind_ext physics_fixture_delete physics_apply_force physics_apply_impulse physics_apply_angular_impulse physics_apply_local_force physics_apply_local_impulse physics_apply_torque physics_mass_properties physics_draw_debug physics_test_overlap physics_remove_fixture physics_set_friction physics_set_density physics_set_restitution physics_get_friction physics_get_density physics_get_restitution physics_joint_distance_create physics_joint_rope_create physics_joint_revolute_create physics_joint_prismatic_create physics_joint_pulley_create physics_joint_wheel_create physics_joint_weld_create physics_joint_friction_create physics_joint_gear_create physics_joint_enable_motor physics_joint_get_value physics_joint_set_value physics_joint_delete physics_particle_create physics_particle_delete physics_particle_delete_region_circle physics_particle_delete_region_box physics_particle_delete_region_poly physics_particle_set_flags physics_particle_set_category_flags physics_particle_draw physics_particle_draw_ext physics_particle_count physics_particle_get_data physics_particle_get_data_particle physics_particle_group_begin physics_particle_group_circle physics_particle_group_box physics_particle_group_polygon physics_particle_group_add_point physics_particle_group_end physics_particle_group_join physics_particle_group_delete physics_particle_group_count physics_particle_group_get_data physics_particle_group_get_mass physics_particle_group_get_inertia physics_particle_group_get_centre_x physics_particle_group_get_centre_y physics_particle_group_get_vel_x physics_particle_group_get_vel_y physics_particle_group_get_ang_vel physics_particle_group_get_x physics_particle_group_get_y physics_particle_group_get_angle physics_particle_set_group_flags physics_particle_get_group_flags physics_particle_get_max_count physics_particle_get_radius physics_particle_get_density physics_particle_get_damping physics_particle_get_gravity_scale physics_particle_set_max_count physics_particle_set_radius physics_particle_set_density physics_particle_set_damping physics_particle_set_gravity_scale network_create_socket network_create_socket_ext network_create_server network_create_server_raw network_connect network_connect_raw network_send_packet network_send_raw network_send_broadcast network_send_udp network_send_udp_raw network_set_timeout network_set_config network_resolve network_destroy buffer_create buffer_write buffer_read buffer_seek buffer_get_surface buffer_set_surface buffer_delete buffer_exists buffer_get_type buffer_get_alignment buffer_poke buffer_peek buffer_save buffer_save_ext buffer_load buffer_load_ext buffer_load_partial buffer_copy buffer_fill buffer_get_size buffer_tell buffer_resize buffer_md5 buffer_sha1 buffer_base64_encode buffer_base64_decode buffer_base64_decode_ext buffer_sizeof buffer_get_address buffer_create_from_vertex_buffer buffer_create_from_vertex_buffer_ext buffer_copy_from_vertex_buffer buffer_async_group_begin buffer_async_group_option buffer_async_group_end buffer_load_async buffer_save_async gml_release_mode gml_pragma steam_activate_overlay steam_is_overlay_enabled steam_is_overlay_activated steam_get_persona_name steam_initialised steam_is_cloud_enabled_for_app steam_is_cloud_enabled_for_account steam_file_persisted steam_get_quota_total steam_get_quota_free steam_file_write steam_file_write_file steam_file_read steam_file_delete steam_file_exists steam_file_size steam_file_share steam_is_screenshot_requested steam_send_screenshot steam_is_user_logged_on steam_get_user_steam_id steam_user_owns_dlc steam_user_installed_dlc steam_set_achievement steam_get_achievement steam_clear_achievement steam_set_stat_int steam_set_stat_float steam_set_stat_avg_rate steam_get_stat_int steam_get_stat_float steam_get_stat_avg_rate steam_reset_all_stats steam_reset_all_stats_achievements steam_stats_ready steam_create_leaderboard steam_upload_score steam_upload_score_ext steam_download_scores_around_user steam_download_scores steam_download_friends_scores steam_upload_score_buffer steam_upload_score_buffer_ext steam_current_game_language steam_available_languages steam_activate_overlay_browser steam_activate_overlay_user steam_activate_overlay_store steam_get_user_persona_name steam_get_app_id steam_get_user_account_id steam_ugc_download steam_ugc_create_item steam_ugc_start_item_update steam_ugc_set_item_title steam_ugc_set_item_description steam_ugc_set_item_visibility steam_ugc_set_item_tags steam_ugc_set_item_content steam_ugc_set_item_preview steam_ugc_submit_item_update steam_ugc_get_item_update_progress steam_ugc_subscribe_item steam_ugc_unsubscribe_item steam_ugc_num_subscribed_items steam_ugc_get_subscribed_items steam_ugc_get_item_install_info steam_ugc_get_item_update_info steam_ugc_request_item_details steam_ugc_create_query_user steam_ugc_create_query_user_ex steam_ugc_create_query_all steam_ugc_create_query_all_ex steam_ugc_query_set_cloud_filename_filter steam_ugc_query_set_match_any_tag steam_ugc_query_set_search_text steam_ugc_query_set_ranked_by_trend_days steam_ugc_query_add_required_tag steam_ugc_query_add_excluded_tag steam_ugc_query_set_return_long_description steam_ugc_query_set_return_total_only steam_ugc_query_set_allow_cached_response steam_ugc_send_query shader_set shader_get_name shader_reset shader_current shader_is_compiled shader_get_sampler_index shader_get_uniform shader_set_uniform_i shader_set_uniform_i_array shader_set_uniform_f shader_set_uniform_f_array shader_set_uniform_matrix shader_set_uniform_matrix_array shader_enable_corner_id texture_set_stage texture_get_texel_width texture_get_texel_height shaders_are_supported vertex_format_begin vertex_format_end vertex_format_delete vertex_format_add_position vertex_format_add_position_3d vertex_format_add_colour vertex_format_add_color vertex_format_add_normal vertex_format_add_texcoord vertex_format_add_textcoord vertex_format_add_custom vertex_create_buffer vertex_create_buffer_ext vertex_delete_buffer vertex_begin vertex_end vertex_position vertex_position_3d vertex_colour vertex_color vertex_argb vertex_texcoord vertex_normal vertex_float1 vertex_float2 vertex_float3 vertex_float4 vertex_ubyte4 vertex_submit vertex_freeze vertex_get_number vertex_get_buffer_size vertex_create_buffer_from_buffer vertex_create_buffer_from_buffer_ext push_local_notification push_get_first_local_notification push_get_next_local_notification push_cancel_local_notification skeleton_animation_set skeleton_animation_get skeleton_animation_mix skeleton_animation_set_ext skeleton_animation_get_ext skeleton_animation_get_duration skeleton_animation_get_frames skeleton_animation_clear skeleton_skin_set skeleton_skin_get skeleton_attachment_set skeleton_attachment_get skeleton_attachment_create skeleton_collision_draw_set skeleton_bone_data_get skeleton_bone_data_set skeleton_bone_state_get skeleton_bone_state_set skeleton_get_minmax skeleton_get_num_bounds skeleton_get_bounds skeleton_animation_get_frame skeleton_animation_set_frame draw_skeleton draw_skeleton_time draw_skeleton_instance draw_skeleton_collision skeleton_animation_list skeleton_skin_list skeleton_slot_data layer_get_id layer_get_id_at_depth layer_get_depth layer_create layer_destroy layer_destroy_instances layer_add_instance layer_has_instance layer_set_visible layer_get_visible layer_exists layer_x layer_y layer_get_x layer_get_y layer_hspeed layer_vspeed layer_get_hspeed layer_get_vspeed layer_script_begin layer_script_end layer_shader layer_get_script_begin layer_get_script_end layer_get_shader layer_set_target_room layer_get_target_room layer_reset_target_room layer_get_all layer_get_all_elements layer_get_name layer_depth layer_get_element_layer layer_get_element_type layer_element_move layer_force_draw_depth layer_is_draw_depth_forced layer_get_forced_depth layer_background_get_id layer_background_exists layer_background_create layer_background_destroy layer_background_visible layer_background_change layer_background_sprite layer_background_htiled layer_background_vtiled layer_background_stretch layer_background_yscale layer_background_xscale layer_background_blend layer_background_alpha layer_background_index layer_background_speed layer_background_get_visible layer_background_get_sprite layer_background_get_htiled layer_background_get_vtiled layer_background_get_stretch layer_background_get_yscale layer_background_get_xscale layer_background_get_blend layer_background_get_alpha layer_background_get_index layer_background_get_speed layer_sprite_get_id layer_sprite_exists layer_sprite_create layer_sprite_destroy layer_sprite_change layer_sprite_index layer_sprite_speed layer_sprite_xscale layer_sprite_yscale layer_sprite_angle layer_sprite_blend layer_sprite_alpha layer_sprite_x layer_sprite_y layer_sprite_get_sprite layer_sprite_get_index layer_sprite_get_speed layer_sprite_get_xscale layer_sprite_get_yscale layer_sprite_get_angle layer_sprite_get_blend layer_sprite_get_alpha layer_sprite_get_x layer_sprite_get_y layer_tilemap_get_id layer_tilemap_exists layer_tilemap_create layer_tilemap_destroy tilemap_tileset tilemap_x tilemap_y tilemap_set tilemap_set_at_pixel tilemap_get_tileset tilemap_get_tile_width tilemap_get_tile_height tilemap_get_width tilemap_get_height tilemap_get_x tilemap_get_y tilemap_get tilemap_get_at_pixel tilemap_get_cell_x_at_pixel tilemap_get_cell_y_at_pixel tilemap_clear draw_tilemap draw_tile tilemap_set_global_mask tilemap_get_global_mask tilemap_set_mask tilemap_get_mask tilemap_get_frame tile_set_empty tile_set_index tile_set_flip tile_set_mirror tile_set_rotate tile_get_empty tile_get_index tile_get_flip tile_get_mirror tile_get_rotate layer_tile_exists layer_tile_create layer_tile_destroy layer_tile_change layer_tile_xscale layer_tile_yscale layer_tile_blend layer_tile_alpha layer_tile_x layer_tile_y layer_tile_region layer_tile_visible layer_tile_get_sprite layer_tile_get_xscale layer_tile_get_yscale layer_tile_get_blend layer_tile_get_alpha layer_tile_get_x layer_tile_get_y layer_tile_get_region layer_tile_get_visible layer_instance_get_instance instance_activate_layer instance_deactivate_layer camera_create camera_create_view camera_destroy camera_apply camera_get_active camera_get_default camera_set_default camera_set_view_mat camera_set_proj_mat camera_set_update_script camera_set_begin_script camera_set_end_script camera_set_view_pos camera_set_view_size camera_set_view_speed camera_set_view_border camera_set_view_angle camera_set_view_target camera_get_view_mat camera_get_proj_mat camera_get_update_script camera_get_begin_script camera_get_end_script camera_get_view_x camera_get_view_y camera_get_view_width camera_get_view_height camera_get_view_speed_x camera_get_view_speed_y camera_get_view_border_x camera_get_view_border_y camera_get_view_angle camera_get_view_target view_get_camera view_get_visible view_get_xport view_get_yport view_get_wport view_get_hport view_get_surface_id view_set_camera view_set_visible view_set_xport view_set_yport view_set_wport view_set_hport view_set_surface_id gesture_drag_time gesture_drag_distance gesture_flick_speed gesture_double_tap_time gesture_double_tap_distance gesture_pinch_distance gesture_pinch_angle_towards gesture_pinch_angle_away gesture_rotate_time gesture_rotate_angle gesture_tap_count gesture_get_drag_time gesture_get_drag_distance gesture_get_flick_speed gesture_get_double_tap_time gesture_get_double_tap_distance gesture_get_pinch_distance gesture_get_pinch_angle_towards gesture_get_pinch_angle_away gesture_get_rotate_time gesture_get_rotate_angle gesture_get_tap_count keyboard_virtual_show keyboard_virtual_hide keyboard_virtual_status keyboard_virtual_height",
-literal:"self other all noone global local undefined pointer_invalid pointer_null path_action_stop path_action_restart path_action_continue path_action_reverse true false pi GM_build_date GM_version GM_runtime_version  timezone_local timezone_utc gamespeed_fps gamespeed_microseconds  ev_create ev_destroy ev_step ev_alarm ev_keyboard ev_mouse ev_collision ev_other ev_draw ev_draw_begin ev_draw_end ev_draw_pre ev_draw_post ev_keypress ev_keyrelease ev_trigger ev_left_button ev_right_button ev_middle_button ev_no_button ev_left_press ev_right_press ev_middle_press ev_left_release ev_right_release ev_middle_release ev_mouse_enter ev_mouse_leave ev_mouse_wheel_up ev_mouse_wheel_down ev_global_left_button ev_global_right_button ev_global_middle_button ev_global_left_press ev_global_right_press ev_global_middle_press ev_global_left_release ev_global_right_release ev_global_middle_release ev_joystick1_left ev_joystick1_right ev_joystick1_up ev_joystick1_down ev_joystick1_button1 ev_joystick1_button2 ev_joystick1_button3 ev_joystick1_button4 ev_joystick1_button5 ev_joystick1_button6 ev_joystick1_button7 ev_joystick1_button8 ev_joystick2_left ev_joystick2_right ev_joystick2_up ev_joystick2_down ev_joystick2_button1 ev_joystick2_button2 ev_joystick2_button3 ev_joystick2_button4 ev_joystick2_button5 ev_joystick2_button6 ev_joystick2_button7 ev_joystick2_button8 ev_outside ev_boundary ev_game_start ev_game_end ev_room_start ev_room_end ev_no_more_lives ev_animation_end ev_end_of_path ev_no_more_health ev_close_button ev_user0 ev_user1 ev_user2 ev_user3 ev_user4 ev_user5 ev_user6 ev_user7 ev_user8 ev_user9 ev_user10 ev_user11 ev_user12 ev_user13 ev_user14 ev_user15 ev_step_normal ev_step_begin ev_step_end ev_gui ev_gui_begin ev_gui_end ev_cleanup ev_gesture ev_gesture_tap ev_gesture_double_tap ev_gesture_drag_start ev_gesture_dragging ev_gesture_drag_end ev_gesture_flick ev_gesture_pinch_start ev_gesture_pinch_in ev_gesture_pinch_out ev_gesture_pinch_end ev_gesture_rotate_start ev_gesture_rotating ev_gesture_rotate_end ev_global_gesture_tap ev_global_gesture_double_tap ev_global_gesture_drag_start ev_global_gesture_dragging ev_global_gesture_drag_end ev_global_gesture_flick ev_global_gesture_pinch_start ev_global_gesture_pinch_in ev_global_gesture_pinch_out ev_global_gesture_pinch_end ev_global_gesture_rotate_start ev_global_gesture_rotating ev_global_gesture_rotate_end vk_nokey vk_anykey vk_enter vk_return vk_shift vk_control vk_alt vk_escape vk_space vk_backspace vk_tab vk_pause vk_printscreen vk_left vk_right vk_up vk_down vk_home vk_end vk_delete vk_insert vk_pageup vk_pagedown vk_f1 vk_f2 vk_f3 vk_f4 vk_f5 vk_f6 vk_f7 vk_f8 vk_f9 vk_f10 vk_f11 vk_f12 vk_numpad0 vk_numpad1 vk_numpad2 vk_numpad3 vk_numpad4 vk_numpad5 vk_numpad6 vk_numpad7 vk_numpad8 vk_numpad9 vk_divide vk_multiply vk_subtract vk_add vk_decimal vk_lshift vk_lcontrol vk_lalt vk_rshift vk_rcontrol vk_ralt  mb_any mb_none mb_left mb_right mb_middle c_aqua c_black c_blue c_dkgray c_fuchsia c_gray c_green c_lime c_ltgray c_maroon c_navy c_olive c_purple c_red c_silver c_teal c_white c_yellow c_orange fa_left fa_center fa_right fa_top fa_middle fa_bottom pr_pointlist pr_linelist pr_linestrip pr_trianglelist pr_trianglestrip pr_trianglefan bm_complex bm_normal bm_add bm_max bm_subtract bm_zero bm_one bm_src_colour bm_inv_src_colour bm_src_color bm_inv_src_color bm_src_alpha bm_inv_src_alpha bm_dest_alpha bm_inv_dest_alpha bm_dest_colour bm_inv_dest_colour bm_dest_color bm_inv_dest_color bm_src_alpha_sat tf_point tf_linear tf_anisotropic mip_off mip_on mip_markedonly audio_falloff_none audio_falloff_inverse_distance audio_falloff_inverse_distance_clamped audio_falloff_linear_distance audio_falloff_linear_distance_clamped audio_falloff_exponent_distance audio_falloff_exponent_distance_clamped audio_old_system audio_new_system audio_mono audio_stereo audio_3d cr_default cr_none cr_arrow cr_cross cr_beam cr_size_nesw cr_size_ns cr_size_nwse cr_size_we cr_uparrow cr_hourglass cr_drag cr_appstart cr_handpoint cr_size_all spritespeed_framespersecond spritespeed_framespergameframe asset_object asset_unknown asset_sprite asset_sound asset_room asset_path asset_script asset_font asset_timeline asset_tiles asset_shader fa_readonly fa_hidden fa_sysfile fa_volumeid fa_directory fa_archive  ds_type_map ds_type_list ds_type_stack ds_type_queue ds_type_grid ds_type_priority ef_explosion ef_ring ef_ellipse ef_firework ef_smoke ef_smokeup ef_star ef_spark ef_flare ef_cloud ef_rain ef_snow pt_shape_pixel pt_shape_disk pt_shape_square pt_shape_line pt_shape_star pt_shape_circle pt_shape_ring pt_shape_sphere pt_shape_flare pt_shape_spark pt_shape_explosion pt_shape_cloud pt_shape_smoke pt_shape_snow ps_distr_linear ps_distr_gaussian ps_distr_invgaussian ps_shape_rectangle ps_shape_ellipse ps_shape_diamond ps_shape_line ty_real ty_string dll_cdecl dll_stdcall matrix_view matrix_projection matrix_world os_win32 os_windows os_macosx os_ios os_android os_symbian os_linux os_unknown os_winphone os_tizen os_win8native os_wiiu os_3ds  os_psvita os_bb10 os_ps4 os_xboxone os_ps3 os_xbox360 os_uwp os_tvos os_switch browser_not_a_browser browser_unknown browser_ie browser_firefox browser_chrome browser_safari browser_safari_mobile browser_opera browser_tizen browser_edge browser_windows_store browser_ie_mobile  device_ios_unknown device_ios_iphone device_ios_iphone_retina device_ios_ipad device_ios_ipad_retina device_ios_iphone5 device_ios_iphone6 device_ios_iphone6plus device_emulator device_tablet display_landscape display_landscape_flipped display_portrait display_portrait_flipped tm_sleep tm_countvsyncs of_challenge_win of_challen ge_lose of_challenge_tie leaderboard_type_number leaderboard_type_time_mins_secs cmpfunc_never cmpfunc_less cmpfunc_equal cmpfunc_lessequal cmpfunc_greater cmpfunc_notequal cmpfunc_greaterequal cmpfunc_always cull_noculling cull_clockwise cull_counterclockwise lighttype_dir lighttype_point iap_ev_storeload iap_ev_product iap_ev_purchase iap_ev_consume iap_ev_restore iap_storeload_ok iap_storeload_failed iap_status_uninitialised iap_status_unavailable iap_status_loading iap_status_available iap_status_processing iap_status_restoring iap_failed iap_unavailable iap_available iap_purchased iap_canceled iap_refunded fb_login_default fb_login_fallback_to_webview fb_login_no_fallback_to_webview fb_login_forcing_webview fb_login_use_system_account fb_login_forcing_safari  phy_joint_anchor_1_x phy_joint_anchor_1_y phy_joint_anchor_2_x phy_joint_anchor_2_y phy_joint_reaction_force_x phy_joint_reaction_force_y phy_joint_reaction_torque phy_joint_motor_speed phy_joint_angle phy_joint_motor_torque phy_joint_max_motor_torque phy_joint_translation phy_joint_speed phy_joint_motor_force phy_joint_max_motor_force phy_joint_length_1 phy_joint_length_2 phy_joint_damping_ratio phy_joint_frequency phy_joint_lower_angle_limit phy_joint_upper_angle_limit phy_joint_angle_limits phy_joint_max_length phy_joint_max_torque phy_joint_max_force phy_debug_render_aabb phy_debug_render_collision_pairs phy_debug_render_coms phy_debug_render_core_shapes phy_debug_render_joints phy_debug_render_obb phy_debug_render_shapes  phy_particle_flag_water phy_particle_flag_zombie phy_particle_flag_wall phy_particle_flag_spring phy_particle_flag_elastic phy_particle_flag_viscous phy_particle_flag_powder phy_particle_flag_tensile phy_particle_flag_colourmixing phy_particle_flag_colormixing phy_particle_group_flag_solid phy_particle_group_flag_rigid phy_particle_data_flag_typeflags phy_particle_data_flag_position phy_particle_data_flag_velocity phy_particle_data_flag_colour phy_particle_data_flag_color phy_particle_data_flag_category  achievement_our_info achievement_friends_info achievement_leaderboard_info achievement_achievement_info achievement_filter_all_players achievement_filter_friends_only achievement_filter_favorites_only achievement_type_achievement_challenge achievement_type_score_challenge achievement_pic_loaded  achievement_show_ui achievement_show_profile achievement_show_leaderboard achievement_show_achievement achievement_show_bank achievement_show_friend_picker achievement_show_purchase_prompt network_socket_tcp network_socket_udp network_socket_bluetooth network_type_connect network_type_disconnect network_type_data network_type_non_blocking_connect network_config_connect_timeout network_config_use_non_blocking_socket network_config_enable_reliable_udp network_config_disable_reliable_udp buffer_fixed buffer_grow buffer_wrap buffer_fast buffer_vbuffer buffer_network buffer_u8 buffer_s8 buffer_u16 buffer_s16 buffer_u32 buffer_s32 buffer_u64 buffer_f16 buffer_f32 buffer_f64 buffer_bool buffer_text buffer_string buffer_surface_copy buffer_seek_start buffer_seek_relative buffer_seek_end buffer_generalerror buffer_outofspace buffer_outofbounds buffer_invalidtype  text_type button_type input_type ANSI_CHARSET DEFAULT_CHARSET EASTEUROPE_CHARSET RUSSIAN_CHARSET SYMBOL_CHARSET SHIFTJIS_CHARSET HANGEUL_CHARSET GB2312_CHARSET CHINESEBIG5_CHARSET JOHAB_CHARSET HEBREW_CHARSET ARABIC_CHARSET GREEK_CHARSET TURKISH_CHARSET VIETNAMESE_CHARSET THAI_CHARSET MAC_CHARSET BALTIC_CHARSET OEM_CHARSET  gp_face1 gp_face2 gp_face3 gp_face4 gp_shoulderl gp_shoulderr gp_shoulderlb gp_shoulderrb gp_select gp_start gp_stickl gp_stickr gp_padu gp_padd gp_padl gp_padr gp_axislh gp_axislv gp_axisrh gp_axisrv ov_friends ov_community ov_players ov_settings ov_gamegroup ov_achievements lb_sort_none lb_sort_ascending lb_sort_descending lb_disp_none lb_disp_numeric lb_disp_time_sec lb_disp_time_ms ugc_result_success ugc_filetype_community ugc_filetype_microtrans ugc_visibility_public ugc_visibility_friends_only ugc_visibility_private ugc_query_RankedByVote ugc_query_RankedByPublicationDate ugc_query_AcceptedForGameRankedByAcceptanceDate ugc_query_RankedByTrend ugc_query_FavoritedByFriendsRankedByPublicationDate ugc_query_CreatedByFriendsRankedByPublicationDate ugc_query_RankedByNumTimesReported ugc_query_CreatedByFollowedUsersRankedByPublicationDate ugc_query_NotYetRated ugc_query_RankedByTotalVotesAsc ugc_query_RankedByVotesUp ugc_query_RankedByTextSearch ugc_sortorder_CreationOrderDesc ugc_sortorder_CreationOrderAsc ugc_sortorder_TitleAsc ugc_sortorder_LastUpdatedDesc ugc_sortorder_SubscriptionDateDesc ugc_sortorder_VoteScoreDesc ugc_sortorder_ForModeration ugc_list_Published ugc_list_VotedOn ugc_list_VotedUp ugc_list_VotedDown ugc_list_WillVoteLater ugc_list_Favorited ugc_list_Subscribed ugc_list_UsedOrPlayed ugc_list_Followed ugc_match_Items ugc_match_Items_Mtx ugc_match_Items_ReadyToUse ugc_match_Collections ugc_match_Artwork ugc_match_Videos ugc_match_Screenshots ugc_match_AllGuides ugc_match_WebGuides ugc_match_IntegratedGuides ugc_match_UsableInGame ugc_match_ControllerBindings  vertex_usage_position vertex_usage_colour vertex_usage_color vertex_usage_normal vertex_usage_texcoord vertex_usage_textcoord vertex_usage_blendweight vertex_usage_blendindices vertex_usage_psize vertex_usage_tangent vertex_usage_binormal vertex_usage_fog vertex_usage_depth vertex_usage_sample vertex_type_float1 vertex_type_float2 vertex_type_float3 vertex_type_float4 vertex_type_colour vertex_type_color vertex_type_ubyte4 layerelementtype_undefined layerelementtype_background layerelementtype_instance layerelementtype_oldtilemap layerelementtype_sprite layerelementtype_tilemap layerelementtype_particlesystem layerelementtype_tile tile_rotate tile_flip tile_mirror tile_index_mask kbv_type_default kbv_type_ascii kbv_type_url kbv_type_email kbv_type_numbers kbv_type_phone kbv_type_phone_name kbv_returnkey_default kbv_returnkey_go kbv_returnkey_google kbv_returnkey_join kbv_returnkey_next kbv_returnkey_route kbv_returnkey_search kbv_returnkey_send kbv_returnkey_yahoo kbv_returnkey_done kbv_returnkey_continue kbv_returnkey_emergency kbv_autocapitalize_none kbv_autocapitalize_words kbv_autocapitalize_sentences kbv_autocapitalize_characters",
-symbol:"argument_relative argument argument0 argument1 argument2 argument3 argument4 argument5 argument6 argument7 argument8 argument9 argument10 argument11 argument12 argument13 argument14 argument15 argument_count x|0 y|0 xprevious yprevious xstart ystart hspeed vspeed direction speed friction gravity gravity_direction path_index path_position path_positionprevious path_speed path_scale path_orientation path_endaction object_index id solid persistent mask_index instance_count instance_id room_speed fps fps_real current_time current_year current_month current_day current_weekday current_hour current_minute current_second alarm timeline_index timeline_position timeline_speed timeline_running timeline_loop room room_first room_last room_width room_height room_caption room_persistent score lives health show_score show_lives show_health caption_score caption_lives caption_health event_type event_number event_object event_action application_surface gamemaker_pro gamemaker_registered gamemaker_version error_occurred error_last debug_mode keyboard_key keyboard_lastkey keyboard_lastchar keyboard_string mouse_x mouse_y mouse_button mouse_lastbutton cursor_sprite visible sprite_index sprite_width sprite_height sprite_xoffset sprite_yoffset image_number image_index image_speed depth image_xscale image_yscale image_angle image_alpha image_blend bbox_left bbox_right bbox_top bbox_bottom layer background_colour  background_showcolour background_color background_showcolor view_enabled view_current view_visible view_xview view_yview view_wview view_hview view_xport view_yport view_wport view_hport view_angle view_hborder view_vborder view_hspeed view_vspeed view_object view_surface_id view_camera game_id game_display_name game_project_name game_save_id working_directory temp_directory program_directory browser_width browser_height os_type os_device os_browser os_version display_aa async_load delta_time webgl_enabled event_data iap_data phy_rotation phy_position_x phy_position_y phy_angular_velocity phy_linear_velocity_x phy_linear_velocity_y phy_speed_x phy_speed_y phy_speed phy_angular_damping phy_linear_damping phy_bullet phy_fixed_rotation phy_active phy_mass phy_inertia phy_com_x phy_com_y phy_dynamic phy_kinematic phy_sleeping phy_collision_points phy_collision_x phy_collision_y phy_col_normal_x phy_col_normal_y phy_position_xprevious phy_position_yprevious"
-},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE]
-})})());
-hljs.registerLanguage("go",(()=>{"use strict";return e=>{const n={
-keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",
-literal:"true false iota nil",
-built_in:"append cap close complex copy imag len make new panic print println real recover delete"
-};return{name:"Go",aliases:["golang"],keywords:n,illegal:"</",
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",
-variants:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{begin:"`",end:"`"}]},{
-className:"number",variants:[{begin:e.C_NUMBER_RE+"[i]",relevance:1
-},e.C_NUMBER_MODE]},{begin:/:=/},{className:"function",beginKeywords:"func",
-end:"\\s*(\\{|$)",excludeEnd:!0,contains:[e.TITLE_MODE,{className:"params",
-begin:/\(/,end:/\)/,keywords:n,illegal:/["']/}]}]}}})());
-hljs.registerLanguage("golo",(()=>{"use strict";return e=>({name:"Golo",
-keywords:{
-keyword:"println readln print import module function local return let var while for foreach times in case when match with break continue augment augmentation each find filter reduce if then else otherwise try catch finally raise throw orIfNull DynamicObject|10 DynamicVariable struct Observable map set vector list array",
-literal:"true false null"},
-contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"@[A-Za-z]+"}]})})());
-hljs.registerLanguage("gradle",(()=>{"use strict";return e=>({name:"Gradle",
-case_insensitive:!0,keywords:{
-keyword:"task project allprojects subprojects artifacts buildscript configurations dependencies repositories sourceSets description delete from into include exclude source classpath destinationDir includes options sourceCompatibility targetCompatibility group flatDir doLast doFirst flatten todir fromdir ant def abstract break case catch continue default do else extends final finally for if implements instanceof native new private protected public return static switch synchronized throw throws transient try volatile while strictfp package import false null super this true antlrtask checkstyle codenarc copy boolean byte char class double float int interface long short void compile runTime file fileTree abs any append asList asWritable call collect compareTo count div dump each eachByte eachFile eachLine every find findAll flatten getAt getErr getIn getOut getText grep immutable inject inspect intersect invokeMethods isCase join leftShift minus multiply newInputStream newOutputStream newPrintWriter newReader newWriter next plus pop power previous print println push putAt read readBytes readLines reverse reverseEach round size sort splitEachLine step subMap times toInteger toList tokenize upto waitForOrKill withPrintWriter withReader withStream withWriter withWriterAppend write writeLine"
-},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,e.REGEXP_MODE]
-})})());
-hljs.registerLanguage("groovy",(()=>{"use strict";function e(e,n={}){
-return n.variants=e,n}return n=>{
-const a="[A-Za-z0-9_$]+",t=e([n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.COMMENT("/\\*\\*","\\*/",{
-relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",
-begin:"@[A-Za-z]+"}]})]),s={className:"regexp",begin:/~?\/[^\/\n]+\//,
-contains:[n.BACKSLASH_ESCAPE]
-},i=e([n.BINARY_NUMBER_MODE,n.C_NUMBER_MODE]),r=e([{begin:/"""/,end:/"""/},{
-begin:/'''/,end:/'''/},{begin:"\\$/",end:"/\\$",relevance:10
-},n.APOS_STRING_MODE,n.QUOTE_STRING_MODE],{className:"string"});return{
-name:"Groovy",keywords:{built_in:"this super",literal:"true false null",
-keyword:"byte short char int long boolean float double void def as in assert trait abstract static volatile transient public private protected synchronized final class interface enum if else for while switch case break default continue throw throws try catch finally implements extends new import package return instanceof"
-},contains:[n.SHEBANG({binary:"groovy",relevance:10}),t,r,s,i,{
-className:"class",beginKeywords:"class interface trait enum",end:/\{/,
-illegal:":",contains:[{beginKeywords:"extends implements"
-},n.UNDERSCORE_TITLE_MODE]},{className:"meta",begin:"@[A-Za-z]+",relevance:0},{
-className:"attr",begin:a+"[ \t]*:",relevance:0},{begin:/\?/,end:/:/,relevance:0,
-contains:[t,r,s,i,"self"]},{className:"symbol",
-begin:"^[ \t]*"+(l=a+":",((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(?=",l,")")),
-excludeBegin:!0,end:a+":",relevance:0}],illegal:/#|<\//};var l}})());
-hljs.registerLanguage("haml",(()=>{"use strict";return e=>({name:"HAML",
-case_insensitive:!0,contains:[{className:"meta",
-begin:"^!!!( (5|1\\.1|Strict|Frameset|Basic|Mobile|RDFa|XML\\b.*))?$",
-relevance:10},e.COMMENT("^\\s*(!=#|=#|-#|/).*$",!1,{relevance:0}),{
-begin:"^\\s*(-|=|!=)(?!#)",starts:{end:"\\n",subLanguage:"ruby"}},{
-className:"tag",begin:"^\\s*%",contains:[{className:"selector-tag",begin:"\\w+"
-},{className:"selector-id",begin:"#[\\w-]+"},{className:"selector-class",
-begin:"\\.[\\w-]+"},{begin:/\{\s*/,end:/\s*\}/,contains:[{begin:":\\w+\\s*=>",
-end:",\\s+",returnBegin:!0,endsWithParent:!0,contains:[{className:"attr",
-begin:":\\w+"},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"\\w+",relevance:0
-}]}]},{begin:"\\(\\s*",end:"\\s*\\)",excludeEnd:!0,contains:[{begin:"\\w+\\s*=",
-end:"\\s+",returnBegin:!0,endsWithParent:!0,contains:[{className:"attr",
-begin:"\\w+",relevance:0},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"\\w+",
-relevance:0}]}]}]},{begin:"^\\s*[=~]\\s*"},{begin:/#\{/,starts:{end:/\}/,
-subLanguage:"ruby"}}]})})());
-hljs.registerLanguage("handlebars",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}return a=>{const t={
-"builtin-name":["action","bindattr","collection","component","concat","debugger","each","each-in","get","hash","if","in","input","link-to","loc","log","lookup","mut","outlet","partial","query-params","render","template","textarea","unbound","unless","view","with","yield"]
-},s=/\[\]|\[[^\]]+\]/,i=/[^\s!"#%&'()*+,.\/;<=>@\[\\\]^`{|}~]+/,r=((...n)=>"("+n.map((n=>e(n))).join("|")+")")(/""|"[^"]+"/,/''|'[^']+'/,s,i),l=n(n("(",/\.|\.\/|\//,")?"),r,(h=n(/(\.|\/)/,r),
-n("(",h,")*"))),c=n("(",s,"|",i,")(?==)"),o={begin:l,lexemes:/[\w.\/]+/
-},m=a.inherit(o,{keywords:{literal:["true","false","undefined","null"]}}),d={
-begin:/\(/,end:/\)/},g={className:"attr",begin:c,relevance:0,starts:{begin:/=/,
-end:/=/,starts:{
-contains:[a.NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,m,d]}}},b={
-contains:[a.NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,{begin:/as\s+\|/,
-keywords:{keyword:"as"},end:/\|/,contains:[{begin:/\w+/}]},g,m,d],returnEnd:!0
-},u=a.inherit(o,{className:"name",keywords:t,starts:a.inherit(b,{end:/\)/})})
-;var h;d.contains=[u];const N=a.inherit(o,{keywords:t,className:"name",
-starts:a.inherit(b,{end:/\}\}/})}),p=a.inherit(o,{keywords:t,className:"name"
-}),E=a.inherit(o,{className:"name",keywords:t,starts:a.inherit(b,{end:/\}\}/})})
-;return{name:"Handlebars",
-aliases:["hbs","html.hbs","html.handlebars","htmlbars"],case_insensitive:!0,
-subLanguage:"xml",contains:[{begin:/\\\{\{/,skip:!0},{begin:/\\\\(?=\{\{)/,
-skip:!0},a.COMMENT(/\{\{!--/,/--\}\}/),a.COMMENT(/\{\{!/,/\}\}/),{
-className:"template-tag",begin:/\{\{\{\{(?!\/)/,end:/\}\}\}\}/,contains:[N],
-starts:{end:/\{\{\{\{\//,returnEnd:!0,subLanguage:"xml"}},{
-className:"template-tag",begin:/\{\{\{\{\//,end:/\}\}\}\}/,contains:[p]},{
-className:"template-tag",begin:/\{\{#/,end:/\}\}/,contains:[N]},{
-className:"template-tag",begin:/\{\{(?=else\}\})/,end:/\}\}/,keywords:"else"},{
-className:"template-tag",begin:/\{\{(?=else if)/,end:/\}\}/,keywords:"else if"
-},{className:"template-tag",begin:/\{\{\//,end:/\}\}/,contains:[p]},{
-className:"template-variable",begin:/\{\{\{/,end:/\}\}\}/,contains:[E]},{
-className:"template-variable",begin:/\{\{/,end:/\}\}/,contains:[E]}]}}})());
-hljs.registerLanguage("haskell",(()=>{"use strict";return e=>{const n={
-variants:[e.COMMENT("--","$"),e.COMMENT(/\{-/,/-\}/,{contains:["self"]})]},i={
-className:"meta",begin:/\{-#/,end:/#-\}/},a={className:"meta",begin:"^#",end:"$"
-},s={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},l={begin:"\\(",
-end:"\\)",illegal:'"',contains:[i,a,{className:"type",
-begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},e.inherit(e.TITLE_MODE,{
-begin:"[_a-z][\\w']*"}),n]};return{name:"Haskell",aliases:["hs"],
-keywords:"let in if then else case of where do module import hiding qualified type data newtype deriving class instance as default infix infixl infixr foreign export ccall stdcall cplusplus jvm dotnet safe unsafe family forall mdo proc rec",
-contains:[{beginKeywords:"module",end:"where",keywords:"module where",
-contains:[l,n],illegal:"\\W\\.|;"},{begin:"\\bimport\\b",end:"$",
-keywords:"import qualified as hiding",contains:[l,n],illegal:"\\W\\.|;"},{
-className:"class",begin:"^(\\s*)?(class|instance)\\b",end:"where",
-keywords:"class family instance where",contains:[s,l,n]},{className:"class",
-begin:"\\b(data|(new)?type)\\b",end:"$",
-keywords:"data family type newtype deriving",contains:[i,s,l,{begin:/\{/,
-end:/\}/,contains:l.contains},n]},{beginKeywords:"default",end:"$",
-contains:[s,l,n]},{beginKeywords:"infix infixl infixr",end:"$",
-contains:[e.C_NUMBER_MODE,n]},{begin:"\\bforeign\\b",end:"$",
-keywords:"foreign import export ccall stdcall cplusplus jvm dotnet safe unsafe",
-contains:[s,e.QUOTE_STRING_MODE,n]},{className:"meta",
-begin:"#!\\/usr\\/bin\\/env runhaskell",end:"$"
-},i,a,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,s,e.inherit(e.TITLE_MODE,{
-begin:"^[_a-z][\\w']*"}),n,{begin:"->|<-"}]}}})());
-hljs.registerLanguage("haxe",(()=>{"use strict";return e=>({name:"Haxe",
-aliases:["hx"],keywords:{
-keyword:"break case cast catch continue default do dynamic else enum extern for function here if import in inline never new override package private get set public return static super switch this throw trace try typedef untyped using var while Int Float String Bool Dynamic Void Array ",
-built_in:"trace this",literal:"true false null _"},contains:[{
-className:"string",begin:"'",end:"'",contains:[e.BACKSLASH_ESCAPE,{
-className:"subst",begin:"\\$\\{",end:"\\}"},{className:"subst",begin:"\\$",
-end:/\W\}/}]
-},e.QUOTE_STRING_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"@:",end:"$"},{className:"meta",begin:"#",end:"$",
-keywords:{"meta-keyword":"if else elseif end error"}},{className:"type",
-begin:":[ \t]*",end:"[^A-Za-z0-9_ \t\\->]",excludeBegin:!0,excludeEnd:!0,
-relevance:0},{className:"type",begin:":[ \t]*",end:"\\W",excludeBegin:!0,
-excludeEnd:!0},{className:"type",begin:"new *",end:"\\W",excludeBegin:!0,
-excludeEnd:!0},{className:"class",beginKeywords:"enum",end:"\\{",
-contains:[e.TITLE_MODE]},{className:"class",beginKeywords:"abstract",
-end:"[\\{$]",contains:[{className:"type",begin:"\\(",end:"\\)",excludeBegin:!0,
-excludeEnd:!0},{className:"type",begin:"from +",end:"\\W",excludeBegin:!0,
-excludeEnd:!0},{className:"type",begin:"to +",end:"\\W",excludeBegin:!0,
-excludeEnd:!0},e.TITLE_MODE],keywords:{keyword:"abstract from to"}},{
-className:"class",begin:"\\b(class|interface) +",end:"[\\{$]",excludeEnd:!0,
-keywords:"class interface",contains:[{className:"keyword",
-begin:"\\b(extends|implements) +",keywords:"extends implements",contains:[{
-className:"type",begin:e.IDENT_RE,relevance:0}]},e.TITLE_MODE]},{
-className:"function",beginKeywords:"function",end:"\\(",excludeEnd:!0,
-illegal:"\\S",contains:[e.TITLE_MODE]}],illegal:/<\//})})());
-hljs.registerLanguage("hsp",(()=>{"use strict";return e=>({name:"HSP",
-case_insensitive:!0,keywords:{$pattern:/[\w._]+/,
-keyword:"goto gosub return break repeat loop continue wait await dim sdim foreach dimtype dup dupptr end stop newmod delmod mref run exgoto on mcall assert logmes newlab resume yield onexit onerror onkey onclick oncmd exist delete mkdir chdir dirlist bload bsave bcopy memfile if else poke wpoke lpoke getstr chdpm memexpand memcpy memset notesel noteadd notedel noteload notesave randomize noteunsel noteget split strrep setease button chgdisp exec dialog mmload mmplay mmstop mci pset pget syscolor mes print title pos circle cls font sysfont objsize picload color palcolor palette redraw width gsel gcopy gzoom gmode bmpsave hsvcolor getkey listbox chkbox combox input mesbox buffer screen bgscr mouse objsel groll line clrobj boxf objprm objmode stick grect grotate gsquare gradf objimage objskip objenable celload celdiv celput newcom querycom delcom cnvstow comres axobj winobj sendmsg comevent comevarg sarrayconv callfunc cnvwtos comevdisp libptr system hspstat hspver stat cnt err strsize looplev sublev iparam wparam lparam refstr refdval int rnd strlen length length2 length3 length4 vartype gettime peek wpeek lpeek varptr varuse noteinfo instr abs limit getease str strmid strf getpath strtrim sin cos tan atan sqrt double absf expf logf limitf powf geteasef mousex mousey mousew hwnd hinstance hdc ginfo objinfo dirinfo sysinfo thismod __hspver__ __hsp30__ __date__ __time__ __line__ __file__ _debug __hspdef__ and or xor not screen_normal screen_palette screen_hide screen_fixedsize screen_tool screen_frame gmode_gdi gmode_mem gmode_rgb0 gmode_alpha gmode_rgb0alpha gmode_add gmode_sub gmode_pixela ginfo_mx ginfo_my ginfo_act ginfo_sel ginfo_wx1 ginfo_wy1 ginfo_wx2 ginfo_wy2 ginfo_vx ginfo_vy ginfo_sizex ginfo_sizey ginfo_winx ginfo_winy ginfo_mesx ginfo_mesy ginfo_r ginfo_g ginfo_b ginfo_paluse ginfo_dispx ginfo_dispy ginfo_cx ginfo_cy ginfo_intid ginfo_newid ginfo_sx ginfo_sy objinfo_mode objinfo_bmscr objinfo_hwnd notemax notesize dir_cur dir_exe dir_win dir_sys dir_cmdline dir_desktop dir_mydoc dir_tv font_normal font_bold font_italic font_underline font_strikeout font_antialias objmode_normal objmode_guifont objmode_usefont gsquare_grad msgothic msmincho do until while wend for next _break _continue switch case default swbreak swend ddim ldim alloc m_pi rad2deg deg2rad ease_linear ease_quad_in ease_quad_out ease_quad_inout ease_cubic_in ease_cubic_out ease_cubic_inout ease_quartic_in ease_quartic_out ease_quartic_inout ease_bounce_in ease_bounce_out ease_bounce_inout ease_shake_in ease_shake_out ease_shake_inout ease_loop"
-},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{
-className:"string",begin:/\{"/,end:/"\}/,contains:[e.BACKSLASH_ESCAPE]
-},e.COMMENT(";","$",{relevance:0}),{className:"meta",begin:"#",end:"$",
-keywords:{
-"meta-keyword":"addion cfunc cmd cmpopt comfunc const defcfunc deffunc define else endif enum epack func global if ifdef ifndef include modcfunc modfunc modinit modterm module pack packopt regcmd runtime undef usecom uselib"
-},contains:[e.inherit(e.QUOTE_STRING_MODE,{className:"meta-string"
-}),e.NUMBER_MODE,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]
-},{className:"symbol",begin:"^\\*(\\w+|@)"},e.NUMBER_MODE,e.C_NUMBER_MODE]})
-})());
-hljs.registerLanguage("htmlbars",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}return a=>{const t=function(a){const t={
-"builtin-name":["action","bindattr","collection","component","concat","debugger","each","each-in","get","hash","if","in","input","link-to","loc","log","lookup","mut","outlet","partial","query-params","render","template","textarea","unbound","unless","view","with","yield"]
-},s=/\[\]|\[[^\]]+\]/,i=/[^\s!"#%&'()*+,.\/;<=>@\[\\\]^`{|}~]+/,r=((...n)=>"("+n.map((n=>e(n))).join("|")+")")(/""|"[^"]+"/,/''|'[^']+'/,s,i),l=n(n("(",/\.|\.\/|\//,")?"),r,(c=n(/(\.|\/)/,r),
-n("(",c,")*")));var c;const o=n("(",s,"|",i,")(?==)"),m={begin:l,
-lexemes:/[\w.\/]+/},d=a.inherit(m,{keywords:{
-literal:["true","false","undefined","null"]}}),g={begin:/\(/,end:/\)/},b={
-className:"attr",begin:o,relevance:0,starts:{begin:/=/,end:/=/,starts:{
-contains:[a.NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,d,g]}}},u={
-contains:[a.NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,{begin:/as\s+\|/,
-keywords:{keyword:"as"},end:/\|/,contains:[{begin:/\w+/}]},b,d,g],returnEnd:!0
-},h=a.inherit(m,{className:"name",keywords:t,starts:a.inherit(u,{end:/\)/})})
-;g.contains=[h];const N=a.inherit(m,{keywords:t,className:"name",
-starts:a.inherit(u,{end:/\}\}/})}),p=a.inherit(m,{keywords:t,className:"name"
-}),E=a.inherit(m,{className:"name",keywords:t,starts:a.inherit(u,{end:/\}\}/})})
-;return{name:"Handlebars",
-aliases:["hbs","html.hbs","html.handlebars","htmlbars"],case_insensitive:!0,
-subLanguage:"xml",contains:[{begin:/\\\{\{/,skip:!0},{begin:/\\\\(?=\{\{)/,
-skip:!0},a.COMMENT(/\{\{!--/,/--\}\}/),a.COMMENT(/\{\{!/,/\}\}/),{
-className:"template-tag",begin:/\{\{\{\{(?!\/)/,end:/\}\}\}\}/,contains:[N],
-starts:{end:/\{\{\{\{\//,returnEnd:!0,subLanguage:"xml"}},{
-className:"template-tag",begin:/\{\{\{\{\//,end:/\}\}\}\}/,contains:[p]},{
-className:"template-tag",begin:/\{\{#/,end:/\}\}/,contains:[N]},{
-className:"template-tag",begin:/\{\{(?=else\}\})/,end:/\}\}/,keywords:"else"},{
-className:"template-tag",begin:/\{\{(?=else if)/,end:/\}\}/,keywords:"else if"
-},{className:"template-tag",begin:/\{\{\//,end:/\}\}/,contains:[p]},{
-className:"template-variable",begin:/\{\{\{/,end:/\}\}\}/,contains:[E]},{
-className:"template-variable",begin:/\{\{/,end:/\}\}/,contains:[E]}]}}(a)
-;return t.name="HTMLbars",a.getLanguage("handlebars")&&(t.disableAutodetect=!0),
-t}})());
-hljs.registerLanguage("http",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a="HTTP/(2|1\\.[01])",s={className:"attribute",
-begin:e("^",/[A-Za-z][A-Za-z0-9-]*/,"(?=\\:\\s)"),starts:{contains:[{
-className:"punctuation",begin:/: /,relevance:0,starts:{end:"$",relevance:0}}]}
-},t=[s,{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}}];return{
-name:"HTTP",aliases:["https"],illegal:/\S/,contains:[{begin:"^(?="+a+" \\d{3})",
-end:/$/,contains:[{className:"meta",begin:a},{className:"number",
-begin:"\\b\\d{3}\\b"}],starts:{end:/\b\B/,illegal:/\S/,contains:t}},{
-begin:"(?=^[A-Z]+ (.*?) "+a+"$)",end:/$/,contains:[{className:"string",
-begin:" ",end:" ",excludeBegin:!0,excludeEnd:!0},{className:"meta",begin:a},{
-className:"keyword",begin:"[A-Z]+"}],starts:{end:/\b\B/,illegal:/\S/,contains:t}
-},n.inherit(s,{relevance:0})]}}})());
-hljs.registerLanguage("hy",(()=>{"use strict";return e=>{
-var a="a-zA-Z_\\-!.?+*=<>&#'",t="["+a+"]["+a+"0-9/;:]*",i={$pattern:t,
-"builtin-name":"!= % %= & &= * ** **= *= *map + += , --build-class-- --import-- -= . / // //= /= < << <<= <= = > >= >> >>= @ @= ^ ^= abs accumulate all and any ap-compose ap-dotimes ap-each ap-each-while ap-filter ap-first ap-if ap-last ap-map ap-map-when ap-pipe ap-reduce ap-reject apply as-> ascii assert assoc bin break butlast callable calling-module-name car case cdr chain chr coll? combinations compile compress cond cons cons? continue count curry cut cycle dec def default-method defclass defmacro defmacro-alias defmacro/g! defmain defmethod defmulti defn defn-alias defnc defnr defreader defseq del delattr delete-route dict-comp dir disassemble dispatch-reader-macro distinct divmod do doto drop drop-last drop-while empty? end-sequence eval eval-and-compile eval-when-compile even? every? except exec filter first flatten float? fn fnc fnr for for* format fraction genexpr gensym get getattr global globals group-by hasattr hash hex id identity if if* if-not if-python2 import in inc input instance? integer integer-char? integer? interleave interpose is is-coll is-cons is-empty is-even is-every is-float is-instance is-integer is-integer-char is-iterable is-iterator is-keyword is-neg is-none is-not is-numeric is-odd is-pos is-string is-symbol is-zero isinstance islice issubclass iter iterable? iterate iterator? keyword keyword? lambda last len let lif lif-not list* list-comp locals loop macro-error macroexpand macroexpand-1 macroexpand-all map max merge-with method-decorator min multi-decorator multicombinations name neg? next none? nonlocal not not-in not? nth numeric? oct odd? open or ord partition permutations pos? post-route postwalk pow prewalk print product profile/calls profile/cpu put-route quasiquote quote raise range read read-str recursive-replace reduce remove repeat repeatedly repr require rest round route route-with-methods rwm second seq set-comp setattr setv some sorted string string? sum switch symbol? take take-nth take-while tee try unless unquote unquote-splicing vars walk when while with with* with-decorator with-gensyms xi xor yield yield-from zero? zip zip-longest | |= ~"
-},r={begin:t,relevance:0},n={className:"number",begin:"[-+]?\\d+(\\.\\d+)?",
-relevance:0},s=e.inherit(e.QUOTE_STRING_MODE,{illegal:null
-}),o=e.COMMENT(";","$",{relevance:0}),l={className:"literal",
-begin:/\b([Tt]rue|[Ff]alse|nil|None)\b/},c={begin:"[\\[\\{]",end:"[\\]\\}]"},d={
-className:"comment",begin:"\\^"+t},m=e.COMMENT("\\^\\{","\\}"),p={
-className:"symbol",begin:"[:]{1,2}"+t},u={begin:"\\(",end:"\\)"},f={
-endsWithParent:!0,relevance:0},g={className:"name",relevance:0,keywords:i,
-begin:t,starts:f},h=[u,s,d,m,o,p,c,n,l,r]
-;return u.contains=[e.COMMENT("comment",""),g,f],f.contains=h,c.contains=h,{
-name:"Hy",aliases:["hylang"],illegal:/\S/,
-contains:[e.SHEBANG(),u,s,d,m,o,p,c,n,l]}}})());
-hljs.registerLanguage("inform7",(()=>{"use strict";return e=>({name:"Inform 7",
-aliases:["i7"],case_insensitive:!0,keywords:{
-keyword:"thing room person man woman animal container supporter backdrop door scenery open closed locked inside gender is are say understand kind of rule"
-},contains:[{className:"string",begin:'"',end:'"',relevance:0,contains:[{
-className:"subst",begin:"\\[",end:"\\]"}]},{className:"section",
-begin:/^(Volume|Book|Part|Chapter|Section|Table)\b/,end:"$"},{
-begin:/^(Check|Carry out|Report|Instead of|To|Rule|When|Before|After)\b/,
-end:":",contains:[{begin:"\\(This",end:"\\)"}]},{className:"comment",
-begin:"\\[",end:"\\]",contains:["self"]}]})})());
-hljs.registerLanguage("ini",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}return s=>{const a={className:"number",
-relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{begin:s.NUMBER_RE}]
-},i=s.COMMENT();i.variants=[{begin:/;/,end:/$/},{begin:/#/,end:/$/}];const t={
-className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{begin:/\$\{(.*?)\}/
-}]},r={className:"literal",begin:/\bon|off|true|false|yes|no\b/},l={
-className:"string",contains:[s.BACKSLASH_ESCAPE],variants:[{begin:"'''",
-end:"'''",relevance:10},{begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'
-},{begin:"'",end:"'"}]},c={begin:/\[/,end:/\]/,contains:[i,r,t,l,a,"self"],
-relevance:0
-},g="("+[/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/].map((n=>e(n))).join("|")+")"
-;return{name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/,
-contains:[i,{className:"section",begin:/\[+/,end:/\]+/},{
-begin:n(g,"(\\s*\\.\\s*",g,")*",n("(?=",/\s*=\s*[^#\s]/,")")),className:"attr",
-starts:{end:/$/,contains:[i,c,r,t,l,a]}}]}}})());
-hljs.registerLanguage("irpf90",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const t=/(_[a-z_\d]+)?/,i=/([de][+-]?\d+)?/,a={
-className:"number",variants:[{begin:e(/\b\d+/,/\.(\d*)/,i,t)},{
-begin:e(/\b\d+/,i,t)},{begin:e(/\.\d+/,i,t)}],relevance:0};return{name:"IRPF90",
-case_insensitive:!0,keywords:{literal:".False. .True.",
-keyword:"kind do while private call intrinsic where elsewhere type endtype endmodule endselect endinterface end enddo endif if forall endforall only contains default return stop then public subroutine|10 function program .and. .or. .not. .le. .eq. .ge. .gt. .lt. goto save else use module select case access blank direct exist file fmt form formatted iostat name named nextrec number opened rec recl sequential status unformatted unit continue format pause cycle exit c_null_char c_alert c_backspace c_form_feed flush wait decimal round iomsg synchronous nopass non_overridable pass protected volatile abstract extends import non_intrinsic value deferred generic final enumerator class associate bind enum c_int c_short c_long c_long_long c_signed_char c_size_t c_int8_t c_int16_t c_int32_t c_int64_t c_int_least8_t c_int_least16_t c_int_least32_t c_int_least64_t c_int_fast8_t c_int_fast16_t c_int_fast32_t c_int_fast64_t c_intmax_t C_intptr_t c_float c_double c_long_double c_float_complex c_double_complex c_long_double_complex c_bool c_char c_null_ptr c_null_funptr c_new_line c_carriage_return c_horizontal_tab c_vertical_tab iso_c_binding c_loc c_funloc c_associated  c_f_pointer c_ptr c_funptr iso_fortran_env character_storage_size error_unit file_storage_size input_unit iostat_end iostat_eor numeric_storage_size output_unit c_f_procpointer ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode newunit contiguous recursive pad position action delim readwrite eor advance nml interface procedure namelist include sequence elemental pure integer real character complex logical dimension allocatable|10 parameter external implicit|10 none double precision assign intent optional pointer target in out common equivalence data begin_provider &begin_provider end_provider begin_shell end_shell begin_template end_template subst assert touch soft_touch provide no_dep free irp_if irp_else irp_endif irp_write irp_read",
-built_in:"alog alog10 amax0 amax1 amin0 amin1 amod cabs ccos cexp clog csin csqrt dabs dacos dasin datan datan2 dcos dcosh ddim dexp dint dlog dlog10 dmax1 dmin1 dmod dnint dsign dsin dsinh dsqrt dtan dtanh float iabs idim idint idnint ifix isign max0 max1 min0 min1 sngl algama cdabs cdcos cdexp cdlog cdsin cdsqrt cqabs cqcos cqexp cqlog cqsin cqsqrt dcmplx dconjg derf derfc dfloat dgamma dimag dlgama iqint qabs qacos qasin qatan qatan2 qcmplx qconjg qcos qcosh qdim qerf qerfc qexp qgamma qimag qlgama qlog qlog10 qmax1 qmin1 qmod qnint qsign qsin qsinh qsqrt qtan qtanh abs acos aimag aint anint asin atan atan2 char cmplx conjg cos cosh exp ichar index int log log10 max min nint sign sin sinh sqrt tan tanh print write dim lge lgt lle llt mod nullify allocate deallocate adjustl adjustr all allocated any associated bit_size btest ceiling count cshift date_and_time digits dot_product eoshift epsilon exponent floor fraction huge iand ibclr ibits ibset ieor ior ishft ishftc lbound len_trim matmul maxexponent maxloc maxval merge minexponent minloc minval modulo mvbits nearest pack present product radix random_number random_seed range repeat reshape rrspacing scale scan selected_int_kind selected_real_kind set_exponent shape size spacing spread sum system_clock tiny transpose trim ubound unpack verify achar iachar transfer dble entry dprod cpu_time command_argument_count get_command get_command_argument get_environment_variable is_iostat_end ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode is_iostat_eor move_alloc new_line selected_char_kind same_type_as extends_type_of acosh asinh atanh bessel_j0 bessel_j1 bessel_jn bessel_y0 bessel_y1 bessel_yn erf erfc erfc_scaled gamma log_gamma hypot norm2 atomic_define atomic_ref execute_command_line leadz trailz storage_size merge_bits bge bgt ble blt dshiftl dshiftr findloc iall iany iparity image_index lcobound ucobound maskl maskr num_images parity popcnt poppar shifta shiftl shiftr this_image IRP_ALIGN irp_here"
-},illegal:/\/\*/,contains:[n.inherit(n.APOS_STRING_MODE,{className:"string",
-relevance:0}),n.inherit(n.QUOTE_STRING_MODE,{className:"string",relevance:0}),{
-className:"function",beginKeywords:"subroutine function program",
-illegal:"[${=\\n]",contains:[n.UNDERSCORE_TITLE_MODE,{className:"params",
-begin:"\\(",end:"\\)"}]},n.COMMENT("!","$",{relevance:0
-}),n.COMMENT("begin_doc","end_doc",{relevance:10}),a]}}})());
-hljs.registerLanguage("isbl",(()=>{"use strict";return S=>{
-const E="[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_!][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]*",_={
-className:"number",begin:S.NUMBER_RE,relevance:0},T={className:"string",
-variants:[{begin:'"',end:'"'},{begin:"'",end:"'"}]},R={className:"doctag",
-begin:"\\b(?:TODO|DONE|BEGIN|END|STUB|CHG|FIXME|NOTE|BUG|XXX)\\b",relevance:0
-},O={variants:[{className:"comment",begin:"//",end:"$",relevance:0,
-contains:[S.PHRASAL_WORDS_MODE,R]},{className:"comment",begin:"/\\*",end:"\\*/",
-relevance:0,contains:[S.PHRASAL_WORDS_MODE,R]}]},C={$pattern:E,
-keyword:"and \u0438 else \u0438\u043d\u0430\u0447\u0435 endexcept endfinally endforeach \u043a\u043e\u043d\u0435\u0446\u0432\u0441\u0435 endif \u043a\u043e\u043d\u0435\u0446\u0435\u0441\u043b\u0438 endwhile \u043a\u043e\u043d\u0435\u0446\u043f\u043e\u043a\u0430 except exitfor finally foreach \u0432\u0441\u0435 if \u0435\u0441\u043b\u0438 in \u0432 not \u043d\u0435 or \u0438\u043b\u0438 try while \u043f\u043e\u043a\u0430 ",
-built_in:"SYSRES_CONST_ACCES_RIGHT_TYPE_EDIT SYSRES_CONST_ACCES_RIGHT_TYPE_FULL SYSRES_CONST_ACCES_RIGHT_TYPE_VIEW SYSRES_CONST_ACCESS_MODE_REQUISITE_CODE SYSRES_CONST_ACCESS_NO_ACCESS_VIEW SYSRES_CONST_ACCESS_NO_ACCESS_VIEW_CODE SYSRES_CONST_ACCESS_RIGHTS_ADD_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_ADD_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_CHANGE_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_CHANGE_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_DELETE_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_DELETE_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_EXECUTE_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_EXECUTE_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_NO_ACCESS_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_NO_ACCESS_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_RATIFY_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_RATIFY_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_VIEW SYSRES_CONST_ACCESS_RIGHTS_VIEW_CODE SYSRES_CONST_ACCESS_RIGHTS_VIEW_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_VIEW_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_TYPE_CHANGE SYSRES_CONST_ACCESS_TYPE_CHANGE_CODE SYSRES_CONST_ACCESS_TYPE_EXISTS SYSRES_CONST_ACCESS_TYPE_EXISTS_CODE SYSRES_CONST_ACCESS_TYPE_FULL SYSRES_CONST_ACCESS_TYPE_FULL_CODE SYSRES_CONST_ACCESS_TYPE_VIEW SYSRES_CONST_ACCESS_TYPE_VIEW_CODE SYSRES_CONST_ACTION_TYPE_ABORT SYSRES_CONST_ACTION_TYPE_ACCEPT SYSRES_CONST_ACTION_TYPE_ACCESS_RIGHTS SYSRES_CONST_ACTION_TYPE_ADD_ATTACHMENT SYSRES_CONST_ACTION_TYPE_CHANGE_CARD SYSRES_CONST_ACTION_TYPE_CHANGE_KIND SYSRES_CONST_ACTION_TYPE_CHANGE_STORAGE SYSRES_CONST_ACTION_TYPE_CONTINUE SYSRES_CONST_ACTION_TYPE_COPY SYSRES_CONST_ACTION_TYPE_CREATE SYSRES_CONST_ACTION_TYPE_CREATE_VERSION SYSRES_CONST_ACTION_TYPE_DELETE SYSRES_CONST_ACTION_TYPE_DELETE_ATTACHMENT SYSRES_CONST_ACTION_TYPE_DELETE_VERSION SYSRES_CONST_ACTION_TYPE_DISABLE_DELEGATE_ACCESS_RIGHTS SYSRES_CONST_ACTION_TYPE_ENABLE_DELEGATE_ACCESS_RIGHTS SYSRES_CONST_ACTION_TYPE_ENCRYPTION_BY_CERTIFICATE SYSRES_CONST_ACTION_TYPE_ENCRYPTION_BY_CERTIFICATE_AND_PASSWORD SYSRES_CONST_ACTION_TYPE_ENCRYPTION_BY_PASSWORD SYSRES_CONST_ACTION_TYPE_EXPORT_WITH_LOCK SYSRES_CONST_ACTION_TYPE_EXPORT_WITHOUT_LOCK SYSRES_CONST_ACTION_TYPE_IMPORT_WITH_UNLOCK SYSRES_CONST_ACTION_TYPE_IMPORT_WITHOUT_UNLOCK SYSRES_CONST_ACTION_TYPE_LIFE_CYCLE_STAGE SYSRES_CONST_ACTION_TYPE_LOCK SYSRES_CONST_ACTION_TYPE_LOCK_FOR_SERVER SYSRES_CONST_ACTION_TYPE_LOCK_MODIFY SYSRES_CONST_ACTION_TYPE_MARK_AS_READED SYSRES_CONST_ACTION_TYPE_MARK_AS_UNREADED SYSRES_CONST_ACTION_TYPE_MODIFY SYSRES_CONST_ACTION_TYPE_MODIFY_CARD SYSRES_CONST_ACTION_TYPE_MOVE_TO_ARCHIVE SYSRES_CONST_ACTION_TYPE_OFF_ENCRYPTION SYSRES_CONST_ACTION_TYPE_PASSWORD_CHANGE SYSRES_CONST_ACTION_TYPE_PERFORM SYSRES_CONST_ACTION_TYPE_RECOVER_FROM_LOCAL_COPY SYSRES_CONST_ACTION_TYPE_RESTART SYSRES_CONST_ACTION_TYPE_RESTORE_FROM_ARCHIVE SYSRES_CONST_ACTION_TYPE_REVISION SYSRES_CONST_ACTION_TYPE_SEND_BY_MAIL SYSRES_CONST_ACTION_TYPE_SIGN SYSRES_CONST_ACTION_TYPE_START SYSRES_CONST_ACTION_TYPE_UNLOCK SYSRES_CONST_ACTION_TYPE_UNLOCK_FROM_SERVER SYSRES_CONST_ACTION_TYPE_VERSION_STATE SYSRES_CONST_ACTION_TYPE_VERSION_VISIBILITY SYSRES_CONST_ACTION_TYPE_VIEW SYSRES_CONST_ACTION_TYPE_VIEW_SHADOW_COPY SYSRES_CONST_ACTION_TYPE_WORKFLOW_DESCRIPTION_MODIFY SYSRES_CONST_ACTION_TYPE_WRITE_HISTORY SYSRES_CONST_ACTIVE_VERSION_STATE_PICK_VALUE SYSRES_CONST_ADD_REFERENCE_MODE_NAME SYSRES_CONST_ADDITION_REQUISITE_CODE SYSRES_CONST_ADDITIONAL_PARAMS_REQUISITE_CODE SYSRES_CONST_ADITIONAL_JOB_END_DATE_REQUISITE_NAME SYSRES_CONST_ADITIONAL_JOB_READ_REQUISITE_NAME SYSRES_CONST_ADITIONAL_JOB_START_DATE_REQUISITE_NAME SYSRES_CONST_ADITIONAL_JOB_STATE_REQUISITE_NAME SYSRES_CONST_ADMINISTRATION_HISTORY_ADDING_USER_TO_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_ADDING_USER_TO_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_COMP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_COMP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_USER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_USER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_CREATION SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_CREATION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_DELETION SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_DELETION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_COMP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_COMP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_FROM_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_FROM_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_RESTRICTION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_RESTRICTION_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_PRIVILEGE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_PRIVILEGE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_RIGHTS_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_RIGHTS_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_IS_MAIN_SERVER_CHANGED_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_IS_MAIN_SERVER_CHANGED_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_IS_PUBLIC_CHANGED_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_IS_PUBLIC_CHANGED_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_RESTRICTION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_RESTRICTION_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_PRIVILEGE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_PRIVILEGE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_RIGHTS_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_RIGHTS_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_CREATION SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_CREATION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_DELETION SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_DELETION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_CATEGORY_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_CATEGORY_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_COMP_TITLE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_COMP_TITLE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_FULL_NAME_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_FULL_NAME_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_PARENT_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_PARENT_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_AUTH_TYPE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_AUTH_TYPE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_LOGIN_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_LOGIN_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_STATUS_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_STATUS_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_USER_PASSWORD_CHANGE SYSRES_CONST_ADMINISTRATION_HISTORY_USER_PASSWORD_CHANGE_ACTION SYSRES_CONST_ALL_ACCEPT_CONDITION_RUS SYSRES_CONST_ALL_USERS_GROUP SYSRES_CONST_ALL_USERS_GROUP_NAME SYSRES_CONST_ALL_USERS_SERVER_GROUP_NAME SYSRES_CONST_ALLOWED_ACCESS_TYPE_CODE SYSRES_CONST_ALLOWED_ACCESS_TYPE_NAME SYSRES_CONST_APP_VIEWER_TYPE_REQUISITE_CODE SYSRES_CONST_APPROVING_SIGNATURE_NAME SYSRES_CONST_APPROVING_SIGNATURE_REQUISITE_CODE SYSRES_CONST_ASSISTANT_SUBSTITUE_TYPE SYSRES_CONST_ASSISTANT_SUBSTITUE_TYPE_CODE SYSRES_CONST_ATTACH_TYPE_COMPONENT_TOKEN SYSRES_CONST_ATTACH_TYPE_DOC SYSRES_CONST_ATTACH_TYPE_EDOC SYSRES_CONST_ATTACH_TYPE_FOLDER SYSRES_CONST_ATTACH_TYPE_JOB SYSRES_CONST_ATTACH_TYPE_REFERENCE SYSRES_CONST_ATTACH_TYPE_TASK SYSRES_CONST_AUTH_ENCODED_PASSWORD SYSRES_CONST_AUTH_ENCODED_PASSWORD_CODE SYSRES_CONST_AUTH_NOVELL SYSRES_CONST_AUTH_PASSWORD SYSRES_CONST_AUTH_PASSWORD_CODE SYSRES_CONST_AUTH_WINDOWS SYSRES_CONST_AUTHENTICATING_SIGNATURE_NAME SYSRES_CONST_AUTHENTICATING_SIGNATURE_REQUISITE_CODE SYSRES_CONST_AUTO_ENUM_METHOD_FLAG SYSRES_CONST_AUTO_NUMERATION_CODE SYSRES_CONST_AUTO_STRONG_ENUM_METHOD_FLAG SYSRES_CONST_AUTOTEXT_NAME_REQUISITE_CODE SYSRES_CONST_AUTOTEXT_TEXT_REQUISITE_CODE SYSRES_CONST_AUTOTEXT_USAGE_ALL SYSRES_CONST_AUTOTEXT_USAGE_ALL_CODE SYSRES_CONST_AUTOTEXT_USAGE_SIGN SYSRES_CONST_AUTOTEXT_USAGE_SIGN_CODE SYSRES_CONST_AUTOTEXT_USAGE_WORK SYSRES_CONST_AUTOTEXT_USAGE_WORK_CODE SYSRES_CONST_AUTOTEXT_USE_ANYWHERE_CODE SYSRES_CONST_AUTOTEXT_USE_ON_SIGNING_CODE SYSRES_CONST_AUTOTEXT_USE_ON_WORK_CODE SYSRES_CONST_BEGIN_DATE_REQUISITE_CODE SYSRES_CONST_BLACK_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_BLUE_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_BTN_PART SYSRES_CONST_CALCULATED_ROLE_TYPE_CODE SYSRES_CONST_CALL_TYPE_VARIABLE_BUTTON_VALUE SYSRES_CONST_CALL_TYPE_VARIABLE_PROGRAM_VALUE SYSRES_CONST_CANCEL_MESSAGE_FUNCTION_RESULT SYSRES_CONST_CARD_PART SYSRES_CONST_CARD_REFERENCE_MODE_NAME SYSRES_CONST_CERTIFICATE_TYPE_REQUISITE_ENCRYPT_VALUE SYSRES_CONST_CERTIFICATE_TYPE_REQUISITE_SIGN_AND_ENCRYPT_VALUE SYSRES_CONST_CERTIFICATE_TYPE_REQUISITE_SIGN_VALUE SYSRES_CONST_CHECK_PARAM_VALUE_DATE_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_FLOAT_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_INTEGER_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_PICK_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_REEFRENCE_PARAM_TYPE SYSRES_CONST_CLOSED_RECORD_FLAG_VALUE_FEMININE SYSRES_CONST_CLOSED_RECORD_FLAG_VALUE_MASCULINE SYSRES_CONST_CODE_COMPONENT_TYPE_ADMIN SYSRES_CONST_CODE_COMPONENT_TYPE_DEVELOPER SYSRES_CONST_CODE_COMPONENT_TYPE_DOCS SYSRES_CONST_CODE_COMPONENT_TYPE_EDOC_CARDS SYSRES_CONST_CODE_COMPONENT_TYPE_EXTERNAL_EXECUTABLE SYSRES_CONST_CODE_COMPONENT_TYPE_OTHER SYSRES_CONST_CODE_COMPONENT_TYPE_REFERENCE SYSRES_CONST_CODE_COMPONENT_TYPE_REPORT SYSRES_CONST_CODE_COMPONENT_TYPE_SCRIPT SYSRES_CONST_CODE_COMPONENT_TYPE_URL SYSRES_CONST_CODE_REQUISITE_ACCESS SYSRES_CONST_CODE_REQUISITE_CODE SYSRES_CONST_CODE_REQUISITE_COMPONENT SYSRES_CONST_CODE_REQUISITE_DESCRIPTION SYSRES_CONST_CODE_REQUISITE_EXCLUDE_COMPONENT SYSRES_CONST_CODE_REQUISITE_RECORD SYSRES_CONST_COMMENT_REQ_CODE SYSRES_CONST_COMMON_SETTINGS_REQUISITE_CODE SYSRES_CONST_COMP_CODE_GRD SYSRES_CONST_COMPONENT_GROUP_TYPE_REQUISITE_CODE SYSRES_CONST_COMPONENT_TYPE_ADMIN_COMPONENTS SYSRES_CONST_COMPONENT_TYPE_DEVELOPER_COMPONENTS SYSRES_CONST_COMPONENT_TYPE_DOCS SYSRES_CONST_COMPONENT_TYPE_EDOC_CARDS SYSRES_CONST_COMPONENT_TYPE_EDOCS SYSRES_CONST_COMPONENT_TYPE_EXTERNAL_EXECUTABLE SYSRES_CONST_COMPONENT_TYPE_OTHER SYSRES_CONST_COMPONENT_TYPE_REFERENCE_TYPES SYSRES_CONST_COMPONENT_TYPE_REFERENCES SYSRES_CONST_COMPONENT_TYPE_REPORTS SYSRES_CONST_COMPONENT_TYPE_SCRIPTS SYSRES_CONST_COMPONENT_TYPE_URL SYSRES_CONST_COMPONENTS_REMOTE_SERVERS_VIEW_CODE SYSRES_CONST_CONDITION_BLOCK_DESCRIPTION SYSRES_CONST_CONST_FIRM_STATUS_COMMON SYSRES_CONST_CONST_FIRM_STATUS_INDIVIDUAL SYSRES_CONST_CONST_NEGATIVE_VALUE SYSRES_CONST_CONST_POSITIVE_VALUE SYSRES_CONST_CONST_SERVER_STATUS_DONT_REPLICATE SYSRES_CONST_CONST_SERVER_STATUS_REPLICATE SYSRES_CONST_CONTENTS_REQUISITE_CODE SYSRES_CONST_DATA_TYPE_BOOLEAN SYSRES_CONST_DATA_TYPE_DATE SYSRES_CONST_DATA_TYPE_FLOAT SYSRES_CONST_DATA_TYPE_INTEGER SYSRES_CONST_DATA_TYPE_PICK SYSRES_CONST_DATA_TYPE_REFERENCE SYSRES_CONST_DATA_TYPE_STRING SYSRES_CONST_DATA_TYPE_TEXT SYSRES_CONST_DATA_TYPE_VARIANT SYSRES_CONST_DATE_CLOSE_REQ_CODE SYSRES_CONST_DATE_FORMAT_DATE_ONLY_CHAR SYSRES_CONST_DATE_OPEN_REQ_CODE SYSRES_CONST_DATE_REQUISITE SYSRES_CONST_DATE_REQUISITE_CODE SYSRES_CONST_DATE_REQUISITE_NAME SYSRES_CONST_DATE_REQUISITE_TYPE SYSRES_CONST_DATE_TYPE_CHAR SYSRES_CONST_DATETIME_FORMAT_VALUE SYSRES_CONST_DEA_ACCESS_RIGHTS_ACTION_CODE SYSRES_CONST_DESCRIPTION_LOCALIZE_ID_REQUISITE_CODE SYSRES_CONST_DESCRIPTION_REQUISITE_CODE SYSRES_CONST_DET1_PART SYSRES_CONST_DET2_PART SYSRES_CONST_DET3_PART SYSRES_CONST_DET4_PART SYSRES_CONST_DET5_PART SYSRES_CONST_DET6_PART SYSRES_CONST_DETAIL_DATASET_KEY_REQUISITE_CODE SYSRES_CONST_DETAIL_PICK_REQUISITE_CODE SYSRES_CONST_DETAIL_REQ_CODE SYSRES_CONST_DO_NOT_USE_ACCESS_TYPE_CODE SYSRES_CONST_DO_NOT_USE_ACCESS_TYPE_NAME SYSRES_CONST_DO_NOT_USE_ON_VIEW_ACCESS_TYPE_CODE SYSRES_CONST_DO_NOT_USE_ON_VIEW_ACCESS_TYPE_NAME SYSRES_CONST_DOCUMENT_STORAGES_CODE SYSRES_CONST_DOCUMENT_TEMPLATES_TYPE_NAME SYSRES_CONST_DOUBLE_REQUISITE_CODE SYSRES_CONST_EDITOR_CLOSE_FILE_OBSERV_TYPE_CODE SYSRES_CONST_EDITOR_CLOSE_PROCESS_OBSERV_TYPE_CODE SYSRES_CONST_EDITOR_TYPE_REQUISITE_CODE SYSRES_CONST_EDITORS_APPLICATION_NAME_REQUISITE_CODE SYSRES_CONST_EDITORS_CREATE_SEVERAL_PROCESSES_REQUISITE_CODE SYSRES_CONST_EDITORS_EXTENSION_REQUISITE_CODE SYSRES_CONST_EDITORS_OBSERVER_BY_PROCESS_TYPE SYSRES_CONST_EDITORS_REFERENCE_CODE SYSRES_CONST_EDITORS_REPLACE_SPEC_CHARS_REQUISITE_CODE SYSRES_CONST_EDITORS_USE_PLUGINS_REQUISITE_CODE SYSRES_CONST_EDITORS_VIEW_DOCUMENT_OPENED_TO_EDIT_CODE SYSRES_CONST_EDOC_CARD_TYPE_REQUISITE_CODE SYSRES_CONST_EDOC_CARD_TYPES_LINK_REQUISITE_CODE SYSRES_CONST_EDOC_CERTIFICATE_AND_PASSWORD_ENCODE_CODE SYSRES_CONST_EDOC_CERTIFICATE_ENCODE_CODE SYSRES_CONST_EDOC_DATE_REQUISITE_CODE SYSRES_CONST_EDOC_KIND_REFERENCE_CODE SYSRES_CONST_EDOC_KINDS_BY_TEMPLATE_ACTION_CODE SYSRES_CONST_EDOC_MANAGE_ACCESS_CODE SYSRES_CONST_EDOC_NONE_ENCODE_CODE SYSRES_CONST_EDOC_NUMBER_REQUISITE_CODE SYSRES_CONST_EDOC_PASSWORD_ENCODE_CODE SYSRES_CONST_EDOC_READONLY_ACCESS_CODE SYSRES_CONST_EDOC_SHELL_LIFE_TYPE_VIEW_VALUE SYSRES_CONST_EDOC_SIZE_RESTRICTION_PRIORITY_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_CHECK_ACCESS_RIGHTS_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_COMPUTER_NAME_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_DATABASE_NAME_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_EDIT_IN_STORAGE_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_LOCAL_PATH_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_SHARED_SOURCE_NAME_REQUISITE_CODE SYSRES_CONST_EDOC_TEMPLATE_REQUISITE_CODE SYSRES_CONST_EDOC_TYPES_REFERENCE_CODE SYSRES_CONST_EDOC_VERSION_ACTIVE_STAGE_CODE SYSRES_CONST_EDOC_VERSION_DESIGN_STAGE_CODE SYSRES_CONST_EDOC_VERSION_OBSOLETE_STAGE_CODE SYSRES_CONST_EDOC_WRITE_ACCES_CODE SYSRES_CONST_EDOCUMENT_CARD_REQUISITES_REFERENCE_CODE_SELECTED_REQUISITE SYSRES_CONST_ENCODE_CERTIFICATE_TYPE_CODE SYSRES_CONST_END_DATE_REQUISITE_CODE SYSRES_CONST_ENUMERATION_TYPE_REQUISITE_CODE SYSRES_CONST_EXECUTE_ACCESS_RIGHTS_TYPE_CODE SYSRES_CONST_EXECUTIVE_FILE_STORAGE_TYPE SYSRES_CONST_EXIST_CONST SYSRES_CONST_EXIST_VALUE SYSRES_CONST_EXPORT_LOCK_TYPE_ASK SYSRES_CONST_EXPORT_LOCK_TYPE_WITH_LOCK SYSRES_CONST_EXPORT_LOCK_TYPE_WITHOUT_LOCK SYSRES_CONST_EXPORT_VERSION_TYPE_ASK SYSRES_CONST_EXPORT_VERSION_TYPE_LAST SYSRES_CONST_EXPORT_VERSION_TYPE_LAST_ACTIVE SYSRES_CONST_EXTENSION_REQUISITE_CODE SYSRES_CONST_FILTER_NAME_REQUISITE_CODE SYSRES_CONST_FILTER_REQUISITE_CODE SYSRES_CONST_FILTER_TYPE_COMMON_CODE SYSRES_CONST_FILTER_TYPE_COMMON_NAME SYSRES_CONST_FILTER_TYPE_USER_CODE SYSRES_CONST_FILTER_TYPE_USER_NAME SYSRES_CONST_FILTER_VALUE_REQUISITE_NAME SYSRES_CONST_FLOAT_NUMBER_FORMAT_CHAR SYSRES_CONST_FLOAT_REQUISITE_TYPE SYSRES_CONST_FOLDER_AUTHOR_VALUE SYSRES_CONST_FOLDER_KIND_ANY_OBJECTS SYSRES_CONST_FOLDER_KIND_COMPONENTS SYSRES_CONST_FOLDER_KIND_EDOCS SYSRES_CONST_FOLDER_KIND_JOBS SYSRES_CONST_FOLDER_KIND_TASKS SYSRES_CONST_FOLDER_TYPE_COMMON SYSRES_CONST_FOLDER_TYPE_COMPONENT SYSRES_CONST_FOLDER_TYPE_FAVORITES SYSRES_CONST_FOLDER_TYPE_INBOX SYSRES_CONST_FOLDER_TYPE_OUTBOX SYSRES_CONST_FOLDER_TYPE_QUICK_LAUNCH SYSRES_CONST_FOLDER_TYPE_SEARCH SYSRES_CONST_FOLDER_TYPE_SHORTCUTS SYSRES_CONST_FOLDER_TYPE_USER SYSRES_CONST_FROM_DICTIONARY_ENUM_METHOD_FLAG SYSRES_CONST_FULL_SUBSTITUTE_TYPE SYSRES_CONST_FULL_SUBSTITUTE_TYPE_CODE SYSRES_CONST_FUNCTION_CANCEL_RESULT SYSRES_CONST_FUNCTION_CATEGORY_SYSTEM SYSRES_CONST_FUNCTION_CATEGORY_USER SYSRES_CONST_FUNCTION_FAILURE_RESULT SYSRES_CONST_FUNCTION_SAVE_RESULT SYSRES_CONST_GENERATED_REQUISITE SYSRES_CONST_GREEN_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_GROUP_ACCOUNT_TYPE_VALUE_CODE SYSRES_CONST_GROUP_CATEGORY_NORMAL_CODE SYSRES_CONST_GROUP_CATEGORY_NORMAL_NAME SYSRES_CONST_GROUP_CATEGORY_SERVICE_CODE SYSRES_CONST_GROUP_CATEGORY_SERVICE_NAME SYSRES_CONST_GROUP_COMMON_CATEGORY_FIELD_VALUE SYSRES_CONST_GROUP_FULL_NAME_REQUISITE_CODE SYSRES_CONST_GROUP_NAME_REQUISITE_CODE SYSRES_CONST_GROUP_RIGHTS_T_REQUISITE_CODE SYSRES_CONST_GROUP_SERVER_CODES_REQUISITE_CODE SYSRES_CONST_GROUP_SERVER_NAME_REQUISITE_CODE SYSRES_CONST_GROUP_SERVICE_CATEGORY_FIELD_VALUE SYSRES_CONST_GROUP_USER_REQUISITE_CODE SYSRES_CONST_GROUPS_REFERENCE_CODE SYSRES_CONST_GROUPS_REQUISITE_CODE SYSRES_CONST_HIDDEN_MODE_NAME SYSRES_CONST_HIGH_LVL_REQUISITE_CODE SYSRES_CONST_HISTORY_ACTION_CREATE_CODE SYSRES_CONST_HISTORY_ACTION_DELETE_CODE SYSRES_CONST_HISTORY_ACTION_EDIT_CODE SYSRES_CONST_HOUR_CHAR SYSRES_CONST_ID_REQUISITE_CODE SYSRES_CONST_IDSPS_REQUISITE_CODE SYSRES_CONST_IMAGE_MODE_COLOR SYSRES_CONST_IMAGE_MODE_GREYSCALE SYSRES_CONST_IMAGE_MODE_MONOCHROME SYSRES_CONST_IMPORTANCE_HIGH SYSRES_CONST_IMPORTANCE_LOW SYSRES_CONST_IMPORTANCE_NORMAL SYSRES_CONST_IN_DESIGN_VERSION_STATE_PICK_VALUE SYSRES_CONST_INCOMING_WORK_RULE_TYPE_CODE SYSRES_CONST_INT_REQUISITE SYSRES_CONST_INT_REQUISITE_TYPE SYSRES_CONST_INTEGER_NUMBER_FORMAT_CHAR SYSRES_CONST_INTEGER_TYPE_CHAR SYSRES_CONST_IS_GENERATED_REQUISITE_NEGATIVE_VALUE SYSRES_CONST_IS_PUBLIC_ROLE_REQUISITE_CODE SYSRES_CONST_IS_REMOTE_USER_NEGATIVE_VALUE SYSRES_CONST_IS_REMOTE_USER_POSITIVE_VALUE SYSRES_CONST_IS_STORED_REQUISITE_NEGATIVE_VALUE SYSRES_CONST_IS_STORED_REQUISITE_STORED_VALUE SYSRES_CONST_ITALIC_LIFE_CYCLE_STAGE_DRAW_STYLE SYSRES_CONST_JOB_BLOCK_DESCRIPTION SYSRES_CONST_JOB_KIND_CONTROL_JOB SYSRES_CONST_JOB_KIND_JOB SYSRES_CONST_JOB_KIND_NOTICE SYSRES_CONST_JOB_STATE_ABORTED SYSRES_CONST_JOB_STATE_COMPLETE SYSRES_CONST_JOB_STATE_WORKING SYSRES_CONST_KIND_REQUISITE_CODE SYSRES_CONST_KIND_REQUISITE_NAME SYSRES_CONST_KINDS_CREATE_SHADOW_COPIES_REQUISITE_CODE SYSRES_CONST_KINDS_DEFAULT_EDOC_LIFE_STAGE_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_ALL_TEPLATES_ALLOWED_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_ALLOW_LIFE_CYCLE_STAGE_CHANGING_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_ALLOW_MULTIPLE_ACTIVE_VERSIONS_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_SHARE_ACCES_RIGHTS_BY_DEFAULT_CODE SYSRES_CONST_KINDS_EDOC_TEMPLATE_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_TYPE_REQUISITE_CODE SYSRES_CONST_KINDS_SIGNERS_REQUISITES_CODE SYSRES_CONST_KOD_INPUT_TYPE SYSRES_CONST_LAST_UPDATE_DATE_REQUISITE_CODE SYSRES_CONST_LIFE_CYCLE_START_STAGE_REQUISITE_CODE SYSRES_CONST_LILAC_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_LINK_OBJECT_KIND_COMPONENT SYSRES_CONST_LINK_OBJECT_KIND_DOCUMENT SYSRES_CONST_LINK_OBJECT_KIND_EDOC SYSRES_CONST_LINK_OBJECT_KIND_FOLDER SYSRES_CONST_LINK_OBJECT_KIND_JOB SYSRES_CONST_LINK_OBJECT_KIND_REFERENCE SYSRES_CONST_LINK_OBJECT_KIND_TASK SYSRES_CONST_LINK_REF_TYPE_REQUISITE_CODE SYSRES_CONST_LIST_REFERENCE_MODE_NAME SYSRES_CONST_LOCALIZATION_DICTIONARY_MAIN_VIEW_CODE SYSRES_CONST_MAIN_VIEW_CODE SYSRES_CONST_MANUAL_ENUM_METHOD_FLAG SYSRES_CONST_MASTER_COMP_TYPE_REQUISITE_CODE SYSRES_CONST_MASTER_TABLE_REC_ID_REQUISITE_CODE SYSRES_CONST_MAXIMIZED_MODE_NAME SYSRES_CONST_ME_VALUE SYSRES_CONST_MESSAGE_ATTENTION_CAPTION SYSRES_CONST_MESSAGE_CONFIRMATION_CAPTION SYSRES_CONST_MESSAGE_ERROR_CAPTION SYSRES_CONST_MESSAGE_INFORMATION_CAPTION SYSRES_CONST_MINIMIZED_MODE_NAME SYSRES_CONST_MINUTE_CHAR SYSRES_CONST_MODULE_REQUISITE_CODE SYSRES_CONST_MONITORING_BLOCK_DESCRIPTION SYSRES_CONST_MONTH_FORMAT_VALUE SYSRES_CONST_NAME_LOCALIZE_ID_REQUISITE_CODE SYSRES_CONST_NAME_REQUISITE_CODE SYSRES_CONST_NAME_SINGULAR_REQUISITE_CODE SYSRES_CONST_NAMEAN_INPUT_TYPE SYSRES_CONST_NEGATIVE_PICK_VALUE SYSRES_CONST_NEGATIVE_VALUE SYSRES_CONST_NO SYSRES_CONST_NO_PICK_VALUE SYSRES_CONST_NO_SIGNATURE_REQUISITE_CODE SYSRES_CONST_NO_VALUE SYSRES_CONST_NONE_ACCESS_RIGHTS_TYPE_CODE SYSRES_CONST_NONOPERATING_RECORD_FLAG_VALUE SYSRES_CONST_NONOPERATING_RECORD_FLAG_VALUE_MASCULINE SYSRES_CONST_NORMAL_ACCESS_RIGHTS_TYPE_CODE SYSRES_CONST_NORMAL_LIFE_CYCLE_STAGE_DRAW_STYLE SYSRES_CONST_NORMAL_MODE_NAME SYSRES_CONST_NOT_ALLOWED_ACCESS_TYPE_CODE SYSRES_CONST_NOT_ALLOWED_ACCESS_TYPE_NAME SYSRES_CONST_NOTE_REQUISITE_CODE SYSRES_CONST_NOTICE_BLOCK_DESCRIPTION SYSRES_CONST_NUM_REQUISITE SYSRES_CONST_NUM_STR_REQUISITE_CODE SYSRES_CONST_NUMERATION_AUTO_NOT_STRONG SYSRES_CONST_NUMERATION_AUTO_STRONG SYSRES_CONST_NUMERATION_FROM_DICTONARY SYSRES_CONST_NUMERATION_MANUAL SYSRES_CONST_NUMERIC_TYPE_CHAR SYSRES_CONST_NUMREQ_REQUISITE_CODE SYSRES_CONST_OBSOLETE_VERSION_STATE_PICK_VALUE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE_CODE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE_FEMININE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE_MASCULINE SYSRES_CONST_OPTIONAL_FORM_COMP_REQCODE_PREFIX SYSRES_CONST_ORANGE_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_ORIGINALREF_REQUISITE_CODE SYSRES_CONST_OURFIRM_REF_CODE SYSRES_CONST_OURFIRM_REQUISITE_CODE SYSRES_CONST_OURFIRM_VAR SYSRES_CONST_OUTGOING_WORK_RULE_TYPE_CODE SYSRES_CONST_PICK_NEGATIVE_RESULT SYSRES_CONST_PICK_POSITIVE_RESULT SYSRES_CONST_PICK_REQUISITE SYSRES_CONST_PICK_REQUISITE_TYPE SYSRES_CONST_PICK_TYPE_CHAR SYSRES_CONST_PLAN_STATUS_REQUISITE_CODE SYSRES_CONST_PLATFORM_VERSION_COMMENT SYSRES_CONST_PLUGINS_SETTINGS_DESCRIPTION_REQUISITE_CODE SYSRES_CONST_POSITIVE_PICK_VALUE SYSRES_CONST_POWER_TO_CREATE_ACTION_CODE SYSRES_CONST_POWER_TO_SIGN_ACTION_CODE SYSRES_CONST_PRIORITY_REQUISITE_CODE SYSRES_CONST_QUALIFIED_TASK_TYPE SYSRES_CONST_QUALIFIED_TASK_TYPE_CODE SYSRES_CONST_RECSTAT_REQUISITE_CODE SYSRES_CONST_RED_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_REF_ID_T_REF_TYPE_REQUISITE_CODE SYSRES_CONST_REF_REQUISITE SYSRES_CONST_REF_REQUISITE_TYPE SYSRES_CONST_REF_REQUISITES_REFERENCE_CODE_SELECTED_REQUISITE SYSRES_CONST_REFERENCE_RECORD_HISTORY_CREATE_ACTION_CODE SYSRES_CONST_REFERENCE_RECORD_HISTORY_DELETE_ACTION_CODE SYSRES_CONST_REFERENCE_RECORD_HISTORY_MODIFY_ACTION_CODE SYSRES_CONST_REFERENCE_TYPE_CHAR SYSRES_CONST_REFERENCE_TYPE_REQUISITE_NAME SYSRES_CONST_REFERENCES_ADD_PARAMS_REQUISITE_CODE SYSRES_CONST_REFERENCES_DISPLAY_REQUISITE_REQUISITE_CODE SYSRES_CONST_REMOTE_SERVER_STATUS_WORKING SYSRES_CONST_REMOTE_SERVER_TYPE_MAIN SYSRES_CONST_REMOTE_SERVER_TYPE_SECONDARY SYSRES_CONST_REMOTE_USER_FLAG_VALUE_CODE SYSRES_CONST_REPORT_APP_EDITOR_INTERNAL SYSRES_CONST_REPORT_BASE_REPORT_ID_REQUISITE_CODE SYSRES_CONST_REPORT_BASE_REPORT_REQUISITE_CODE SYSRES_CONST_REPORT_SCRIPT_REQUISITE_CODE SYSRES_CONST_REPORT_TEMPLATE_REQUISITE_CODE SYSRES_CONST_REPORT_VIEWER_CODE_REQUISITE_CODE SYSRES_CONST_REQ_ALLOW_COMPONENT_DEFAULT_VALUE SYSRES_CONST_REQ_ALLOW_RECORD_DEFAULT_VALUE SYSRES_CONST_REQ_ALLOW_SERVER_COMPONENT_DEFAULT_VALUE SYSRES_CONST_REQ_MODE_AVAILABLE_CODE SYSRES_CONST_REQ_MODE_EDIT_CODE SYSRES_CONST_REQ_MODE_HIDDEN_CODE SYSRES_CONST_REQ_MODE_NOT_AVAILABLE_CODE SYSRES_CONST_REQ_MODE_VIEW_CODE SYSRES_CONST_REQ_NUMBER_REQUISITE_CODE SYSRES_CONST_REQ_SECTION_VALUE SYSRES_CONST_REQ_TYPE_VALUE SYSRES_CONST_REQUISITE_FORMAT_BY_UNIT SYSRES_CONST_REQUISITE_FORMAT_DATE_FULL SYSRES_CONST_REQUISITE_FORMAT_DATE_TIME SYSRES_CONST_REQUISITE_FORMAT_LEFT SYSRES_CONST_REQUISITE_FORMAT_RIGHT SYSRES_CONST_REQUISITE_FORMAT_WITHOUT_UNIT SYSRES_CONST_REQUISITE_NUMBER_REQUISITE_CODE SYSRES_CONST_REQUISITE_SECTION_ACTIONS SYSRES_CONST_REQUISITE_SECTION_BUTTON SYSRES_CONST_REQUISITE_SECTION_BUTTONS SYSRES_CONST_REQUISITE_SECTION_CARD SYSRES_CONST_REQUISITE_SECTION_TABLE SYSRES_CONST_REQUISITE_SECTION_TABLE10 SYSRES_CONST_REQUISITE_SECTION_TABLE11 SYSRES_CONST_REQUISITE_SECTION_TABLE12 SYSRES_CONST_REQUISITE_SECTION_TABLE13 SYSRES_CONST_REQUISITE_SECTION_TABLE14 SYSRES_CONST_REQUISITE_SECTION_TABLE15 SYSRES_CONST_REQUISITE_SECTION_TABLE16 SYSRES_CONST_REQUISITE_SECTION_TABLE17 SYSRES_CONST_REQUISITE_SECTION_TABLE18 SYSRES_CONST_REQUISITE_SECTION_TABLE19 SYSRES_CONST_REQUISITE_SECTION_TABLE2 SYSRES_CONST_REQUISITE_SECTION_TABLE20 SYSRES_CONST_REQUISITE_SECTION_TABLE21 SYSRES_CONST_REQUISITE_SECTION_TABLE22 SYSRES_CONST_REQUISITE_SECTION_TABLE23 SYSRES_CONST_REQUISITE_SECTION_TABLE24 SYSRES_CONST_REQUISITE_SECTION_TABLE3 SYSRES_CONST_REQUISITE_SECTION_TABLE4 SYSRES_CONST_REQUISITE_SECTION_TABLE5 SYSRES_CONST_REQUISITE_SECTION_TABLE6 SYSRES_CONST_REQUISITE_SECTION_TABLE7 SYSRES_CONST_REQUISITE_SECTION_TABLE8 SYSRES_CONST_REQUISITE_SECTION_TABLE9 SYSRES_CONST_REQUISITES_PSEUDOREFERENCE_REQUISITE_NUMBER_REQUISITE_CODE SYSRES_CONST_RIGHT_ALIGNMENT_CODE SYSRES_CONST_ROLES_REFERENCE_CODE SYSRES_CONST_ROUTE_STEP_AFTER_RUS SYSRES_CONST_ROUTE_STEP_AND_CONDITION_RUS SYSRES_CONST_ROUTE_STEP_OR_CONDITION_RUS SYSRES_CONST_ROUTE_TYPE_COMPLEX SYSRES_CONST_ROUTE_TYPE_PARALLEL SYSRES_CONST_ROUTE_TYPE_SERIAL SYSRES_CONST_SBDATASETDESC_NEGATIVE_VALUE SYSRES_CONST_SBDATASETDESC_POSITIVE_VALUE SYSRES_CONST_SBVIEWSDESC_POSITIVE_VALUE SYSRES_CONST_SCRIPT_BLOCK_DESCRIPTION SYSRES_CONST_SEARCH_BY_TEXT_REQUISITE_CODE SYSRES_CONST_SEARCHES_COMPONENT_CONTENT SYSRES_CONST_SEARCHES_CRITERIA_ACTION_NAME SYSRES_CONST_SEARCHES_EDOC_CONTENT SYSRES_CONST_SEARCHES_FOLDER_CONTENT SYSRES_CONST_SEARCHES_JOB_CONTENT SYSRES_CONST_SEARCHES_REFERENCE_CODE SYSRES_CONST_SEARCHES_TASK_CONTENT SYSRES_CONST_SECOND_CHAR SYSRES_CONST_SECTION_REQUISITE_ACTIONS_VALUE SYSRES_CONST_SECTION_REQUISITE_CARD_VALUE SYSRES_CONST_SECTION_REQUISITE_CODE SYSRES_CONST_SECTION_REQUISITE_DETAIL_1_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_2_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_3_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_4_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_5_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_6_VALUE SYSRES_CONST_SELECT_REFERENCE_MODE_NAME SYSRES_CONST_SELECT_TYPE_SELECTABLE SYSRES_CONST_SELECT_TYPE_SELECTABLE_ONLY_CHILD SYSRES_CONST_SELECT_TYPE_SELECTABLE_WITH_CHILD SYSRES_CONST_SELECT_TYPE_UNSLECTABLE SYSRES_CONST_SERVER_TYPE_MAIN SYSRES_CONST_SERVICE_USER_CATEGORY_FIELD_VALUE SYSRES_CONST_SETTINGS_USER_REQUISITE_CODE SYSRES_CONST_SIGNATURE_AND_ENCODE_CERTIFICATE_TYPE_CODE SYSRES_CONST_SIGNATURE_CERTIFICATE_TYPE_CODE SYSRES_CONST_SINGULAR_TITLE_REQUISITE_CODE SYSRES_CONST_SQL_SERVER_AUTHENTIFICATION_FLAG_VALUE_CODE SYSRES_CONST_SQL_SERVER_ENCODE_AUTHENTIFICATION_FLAG_VALUE_CODE SYSRES_CONST_STANDART_ROUTE_REFERENCE_CODE SYSRES_CONST_STANDART_ROUTE_REFERENCE_COMMENT_REQUISITE_CODE SYSRES_CONST_STANDART_ROUTES_GROUPS_REFERENCE_CODE SYSRES_CONST_STATE_REQ_NAME SYSRES_CONST_STATE_REQUISITE_ACTIVE_VALUE SYSRES_CONST_STATE_REQUISITE_CLOSED_VALUE SYSRES_CONST_STATE_REQUISITE_CODE SYSRES_CONST_STATIC_ROLE_TYPE_CODE SYSRES_CONST_STATUS_PLAN_DEFAULT_VALUE SYSRES_CONST_STATUS_VALUE_AUTOCLEANING SYSRES_CONST_STATUS_VALUE_BLUE_SQUARE SYSRES_CONST_STATUS_VALUE_COMPLETE SYSRES_CONST_STATUS_VALUE_GREEN_SQUARE SYSRES_CONST_STATUS_VALUE_ORANGE_SQUARE SYSRES_CONST_STATUS_VALUE_PURPLE_SQUARE SYSRES_CONST_STATUS_VALUE_RED_SQUARE SYSRES_CONST_STATUS_VALUE_SUSPEND SYSRES_CONST_STATUS_VALUE_YELLOW_SQUARE SYSRES_CONST_STDROUTE_SHOW_TO_USERS_REQUISITE_CODE SYSRES_CONST_STORAGE_TYPE_FILE SYSRES_CONST_STORAGE_TYPE_SQL_SERVER SYSRES_CONST_STR_REQUISITE SYSRES_CONST_STRIKEOUT_LIFE_CYCLE_STAGE_DRAW_STYLE SYSRES_CONST_STRING_FORMAT_LEFT_ALIGN_CHAR SYSRES_CONST_STRING_FORMAT_RIGHT_ALIGN_CHAR SYSRES_CONST_STRING_REQUISITE_CODE SYSRES_CONST_STRING_REQUISITE_TYPE SYSRES_CONST_STRING_TYPE_CHAR SYSRES_CONST_SUBSTITUTES_PSEUDOREFERENCE_CODE SYSRES_CONST_SUBTASK_BLOCK_DESCRIPTION SYSRES_CONST_SYSTEM_SETTING_CURRENT_USER_PARAM_VALUE SYSRES_CONST_SYSTEM_SETTING_EMPTY_VALUE_PARAM_VALUE SYSRES_CONST_SYSTEM_VERSION_COMMENT SYSRES_CONST_TASK_ACCESS_TYPE_ALL SYSRES_CONST_TASK_ACCESS_TYPE_ALL_MEMBERS SYSRES_CONST_TASK_ACCESS_TYPE_MANUAL SYSRES_CONST_TASK_ENCODE_TYPE_CERTIFICATION SYSRES_CONST_TASK_ENCODE_TYPE_CERTIFICATION_AND_PASSWORD SYSRES_CONST_TASK_ENCODE_TYPE_NONE SYSRES_CONST_TASK_ENCODE_TYPE_PASSWORD SYSRES_CONST_TASK_ROUTE_ALL_CONDITION SYSRES_CONST_TASK_ROUTE_AND_CONDITION SYSRES_CONST_TASK_ROUTE_OR_CONDITION SYSRES_CONST_TASK_STATE_ABORTED SYSRES_CONST_TASK_STATE_COMPLETE SYSRES_CONST_TASK_STATE_CONTINUED SYSRES_CONST_TASK_STATE_CONTROL SYSRES_CONST_TASK_STATE_INIT SYSRES_CONST_TASK_STATE_WORKING SYSRES_CONST_TASK_TITLE SYSRES_CONST_TASK_TYPES_GROUPS_REFERENCE_CODE SYSRES_CONST_TASK_TYPES_REFERENCE_CODE SYSRES_CONST_TEMPLATES_REFERENCE_CODE SYSRES_CONST_TEST_DATE_REQUISITE_NAME SYSRES_CONST_TEST_DEV_DATABASE_NAME SYSRES_CONST_TEST_DEV_SYSTEM_CODE SYSRES_CONST_TEST_EDMS_DATABASE_NAME SYSRES_CONST_TEST_EDMS_MAIN_CODE SYSRES_CONST_TEST_EDMS_MAIN_DB_NAME SYSRES_CONST_TEST_EDMS_SECOND_CODE SYSRES_CONST_TEST_EDMS_SECOND_DB_NAME SYSRES_CONST_TEST_EDMS_SYSTEM_CODE SYSRES_CONST_TEST_NUMERIC_REQUISITE_NAME SYSRES_CONST_TEXT_REQUISITE SYSRES_CONST_TEXT_REQUISITE_CODE SYSRES_CONST_TEXT_REQUISITE_TYPE SYSRES_CONST_TEXT_TYPE_CHAR SYSRES_CONST_TYPE_CODE_REQUISITE_CODE SYSRES_CONST_TYPE_REQUISITE_CODE SYSRES_CONST_UNDEFINED_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_UNITS_SECTION_ID_REQUISITE_CODE SYSRES_CONST_UNITS_SECTION_REQUISITE_CODE SYSRES_CONST_UNOPERATING_RECORD_FLAG_VALUE_CODE SYSRES_CONST_UNSTORED_DATA_REQUISITE_CODE SYSRES_CONST_UNSTORED_DATA_REQUISITE_NAME SYSRES_CONST_USE_ACCESS_TYPE_CODE SYSRES_CONST_USE_ACCESS_TYPE_NAME SYSRES_CONST_USER_ACCOUNT_TYPE_VALUE_CODE SYSRES_CONST_USER_ADDITIONAL_INFORMATION_REQUISITE_CODE SYSRES_CONST_USER_AND_GROUP_ID_FROM_PSEUDOREFERENCE_REQUISITE_CODE SYSRES_CONST_USER_CATEGORY_NORMAL SYSRES_CONST_USER_CERTIFICATE_REQUISITE_CODE SYSRES_CONST_USER_CERTIFICATE_STATE_REQUISITE_CODE SYSRES_CONST_USER_CERTIFICATE_SUBJECT_NAME_REQUISITE_CODE SYSRES_CONST_USER_CERTIFICATE_THUMBPRINT_REQUISITE_CODE SYSRES_CONST_USER_COMMON_CATEGORY SYSRES_CONST_USER_COMMON_CATEGORY_CODE SYSRES_CONST_USER_FULL_NAME_REQUISITE_CODE SYSRES_CONST_USER_GROUP_TYPE_REQUISITE_CODE SYSRES_CONST_USER_LOGIN_REQUISITE_CODE SYSRES_CONST_USER_REMOTE_CONTROLLER_REQUISITE_CODE SYSRES_CONST_USER_REMOTE_SYSTEM_REQUISITE_CODE SYSRES_CONST_USER_RIGHTS_T_REQUISITE_CODE SYSRES_CONST_USER_SERVER_NAME_REQUISITE_CODE SYSRES_CONST_USER_SERVICE_CATEGORY SYSRES_CONST_USER_SERVICE_CATEGORY_CODE SYSRES_CONST_USER_STATUS_ADMINISTRATOR_CODE SYSRES_CONST_USER_STATUS_ADMINISTRATOR_NAME SYSRES_CONST_USER_STATUS_DEVELOPER_CODE SYSRES_CONST_USER_STATUS_DEVELOPER_NAME SYSRES_CONST_USER_STATUS_DISABLED_CODE SYSRES_CONST_USER_STATUS_DISABLED_NAME SYSRES_CONST_USER_STATUS_SYSTEM_DEVELOPER_CODE SYSRES_CONST_USER_STATUS_USER_CODE SYSRES_CONST_USER_STATUS_USER_NAME SYSRES_CONST_USER_STATUS_USER_NAME_DEPRECATED SYSRES_CONST_USER_TYPE_FIELD_VALUE_USER SYSRES_CONST_USER_TYPE_REQUISITE_CODE SYSRES_CONST_USERS_CONTROLLER_REQUISITE_CODE SYSRES_CONST_USERS_IS_MAIN_SERVER_REQUISITE_CODE SYSRES_CONST_USERS_REFERENCE_CODE SYSRES_CONST_USERS_REGISTRATION_CERTIFICATES_ACTION_NAME SYSRES_CONST_USERS_REQUISITE_CODE SYSRES_CONST_USERS_SYSTEM_REQUISITE_CODE SYSRES_CONST_USERS_USER_ACCESS_RIGHTS_TYPR_REQUISITE_CODE SYSRES_CONST_USERS_USER_AUTHENTICATION_REQUISITE_CODE SYSRES_CONST_USERS_USER_COMPONENT_REQUISITE_CODE SYSRES_CONST_USERS_USER_GROUP_REQUISITE_CODE SYSRES_CONST_USERS_VIEW_CERTIFICATES_ACTION_NAME SYSRES_CONST_VIEW_DEFAULT_CODE SYSRES_CONST_VIEW_DEFAULT_NAME SYSRES_CONST_VIEWER_REQUISITE_CODE SYSRES_CONST_WAITING_BLOCK_DESCRIPTION SYSRES_CONST_WIZARD_FORM_LABEL_TEST_STRING  SYSRES_CONST_WIZARD_QUERY_PARAM_HEIGHT_ETALON_STRING SYSRES_CONST_WIZARD_REFERENCE_COMMENT_REQUISITE_CODE SYSRES_CONST_WORK_RULES_DESCRIPTION_REQUISITE_CODE SYSRES_CONST_WORK_TIME_CALENDAR_REFERENCE_CODE SYSRES_CONST_WORK_WORKFLOW_HARD_ROUTE_TYPE_VALUE SYSRES_CONST_WORK_WORKFLOW_HARD_ROUTE_TYPE_VALUE_CODE SYSRES_CONST_WORK_WORKFLOW_HARD_ROUTE_TYPE_VALUE_CODE_RUS SYSRES_CONST_WORK_WORKFLOW_SOFT_ROUTE_TYPE_VALUE_CODE_RUS SYSRES_CONST_WORKFLOW_ROUTE_TYPR_HARD SYSRES_CONST_WORKFLOW_ROUTE_TYPR_SOFT SYSRES_CONST_XML_ENCODING SYSRES_CONST_XREC_STAT_REQUISITE_CODE SYSRES_CONST_XRECID_FIELD_NAME SYSRES_CONST_YES SYSRES_CONST_YES_NO_2_REQUISITE_CODE SYSRES_CONST_YES_NO_REQUISITE_CODE SYSRES_CONST_YES_NO_T_REF_TYPE_REQUISITE_CODE SYSRES_CONST_YES_PICK_VALUE SYSRES_CONST_YES_VALUE CR FALSE nil NO_VALUE NULL TAB TRUE YES_VALUE ADMINISTRATORS_GROUP_NAME CUSTOMIZERS_GROUP_NAME DEVELOPERS_GROUP_NAME SERVICE_USERS_GROUP_NAME DECISION_BLOCK_FIRST_OPERAND_PROPERTY DECISION_BLOCK_NAME_PROPERTY DECISION_BLOCK_OPERATION_PROPERTY DECISION_BLOCK_RESULT_TYPE_PROPERTY DECISION_BLOCK_SECOND_OPERAND_PROPERTY ANY_FILE_EXTENTION COMPRESSED_DOCUMENT_EXTENSION EXTENDED_DOCUMENT_EXTENSION SHORT_COMPRESSED_DOCUMENT_EXTENSION SHORT_EXTENDED_DOCUMENT_EXTENSION JOB_BLOCK_ABORT_DEADLINE_PROPERTY JOB_BLOCK_AFTER_FINISH_EVENT JOB_BLOCK_AFTER_QUERY_PARAMETERS_EVENT JOB_BLOCK_ATTACHMENT_PROPERTY JOB_BLOCK_ATTACHMENTS_RIGHTS_GROUP_PROPERTY JOB_BLOCK_ATTACHMENTS_RIGHTS_TYPE_PROPERTY JOB_BLOCK_BEFORE_QUERY_PARAMETERS_EVENT JOB_BLOCK_BEFORE_START_EVENT JOB_BLOCK_CREATED_JOBS_PROPERTY JOB_BLOCK_DEADLINE_PROPERTY JOB_BLOCK_EXECUTION_RESULTS_PROPERTY JOB_BLOCK_IS_PARALLEL_PROPERTY JOB_BLOCK_IS_RELATIVE_ABORT_DEADLINE_PROPERTY JOB_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY JOB_BLOCK_JOB_TEXT_PROPERTY JOB_BLOCK_NAME_PROPERTY JOB_BLOCK_NEED_SIGN_ON_PERFORM_PROPERTY JOB_BLOCK_PERFORMER_PROPERTY JOB_BLOCK_RELATIVE_ABORT_DEADLINE_TYPE_PROPERTY JOB_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY JOB_BLOCK_SUBJECT_PROPERTY ENGLISH_LANGUAGE_CODE RUSSIAN_LANGUAGE_CODE smHidden smMaximized smMinimized smNormal wmNo wmYes COMPONENT_TOKEN_LINK_KIND DOCUMENT_LINK_KIND EDOCUMENT_LINK_KIND FOLDER_LINK_KIND JOB_LINK_KIND REFERENCE_LINK_KIND TASK_LINK_KIND COMPONENT_TOKEN_LOCK_TYPE EDOCUMENT_VERSION_LOCK_TYPE MONITOR_BLOCK_AFTER_FINISH_EVENT MONITOR_BLOCK_BEFORE_START_EVENT MONITOR_BLOCK_DEADLINE_PROPERTY MONITOR_BLOCK_INTERVAL_PROPERTY MONITOR_BLOCK_INTERVAL_TYPE_PROPERTY MONITOR_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY MONITOR_BLOCK_NAME_PROPERTY MONITOR_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY MONITOR_BLOCK_SEARCH_SCRIPT_PROPERTY NOTICE_BLOCK_AFTER_FINISH_EVENT NOTICE_BLOCK_ATTACHMENT_PROPERTY NOTICE_BLOCK_ATTACHMENTS_RIGHTS_GROUP_PROPERTY NOTICE_BLOCK_ATTACHMENTS_RIGHTS_TYPE_PROPERTY NOTICE_BLOCK_BEFORE_START_EVENT NOTICE_BLOCK_CREATED_NOTICES_PROPERTY NOTICE_BLOCK_DEADLINE_PROPERTY NOTICE_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY NOTICE_BLOCK_NAME_PROPERTY NOTICE_BLOCK_NOTICE_TEXT_PROPERTY NOTICE_BLOCK_PERFORMER_PROPERTY NOTICE_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY NOTICE_BLOCK_SUBJECT_PROPERTY dseAfterCancel dseAfterClose dseAfterDelete dseAfterDeleteOutOfTransaction dseAfterInsert dseAfterOpen dseAfterScroll dseAfterUpdate dseAfterUpdateOutOfTransaction dseBeforeCancel dseBeforeClose dseBeforeDelete dseBeforeDetailUpdate dseBeforeInsert dseBeforeOpen dseBeforeUpdate dseOnAnyRequisiteChange dseOnCloseRecord dseOnDeleteError dseOnOpenRecord dseOnPrepareUpdate dseOnUpdateError dseOnUpdateRatifiedRecord dseOnValidDelete dseOnValidUpdate reOnChange reOnChangeValues SELECTION_BEGIN_ROUTE_EVENT SELECTION_END_ROUTE_EVENT CURRENT_PERIOD_IS_REQUIRED PREVIOUS_CARD_TYPE_NAME SHOW_RECORD_PROPERTIES_FORM ACCESS_RIGHTS_SETTING_DIALOG_CODE ADMINISTRATOR_USER_CODE ANALYTIC_REPORT_TYPE asrtHideLocal asrtHideRemote CALCULATED_ROLE_TYPE_CODE COMPONENTS_REFERENCE_DEVELOPER_VIEW_CODE DCTS_TEST_PROTOCOLS_FOLDER_PATH E_EDOC_VERSION_ALREADY_APPROVINGLY_SIGNED E_EDOC_VERSION_ALREADY_APPROVINGLY_SIGNED_BY_USER E_EDOC_VERSION_ALREDY_SIGNED E_EDOC_VERSION_ALREDY_SIGNED_BY_USER EDOC_TYPES_CODE_REQUISITE_FIELD_NAME EDOCUMENTS_ALIAS_NAME FILES_FOLDER_PATH FILTER_OPERANDS_DELIMITER FILTER_OPERATIONS_DELIMITER FORMCARD_NAME FORMLIST_NAME GET_EXTENDED_DOCUMENT_EXTENSION_CREATION_MODE GET_EXTENDED_DOCUMENT_EXTENSION_IMPORT_MODE INTEGRATED_REPORT_TYPE IS_BUILDER_APPLICATION_ROLE IS_BUILDER_APPLICATION_ROLE2 IS_BUILDER_USERS ISBSYSDEV LOG_FOLDER_PATH mbCancel mbNo mbNoToAll mbOK mbYes mbYesToAll MEMORY_DATASET_DESRIPTIONS_FILENAME mrNo mrNoToAll mrYes mrYesToAll MULTIPLE_SELECT_DIALOG_CODE NONOPERATING_RECORD_FLAG_FEMININE NONOPERATING_RECORD_FLAG_MASCULINE OPERATING_RECORD_FLAG_FEMININE OPERATING_RECORD_FLAG_MASCULINE PROFILING_SETTINGS_COMMON_SETTINGS_CODE_VALUE PROGRAM_INITIATED_LOOKUP_ACTION ratDelete ratEdit ratInsert REPORT_TYPE REQUIRED_PICK_VALUES_VARIABLE rmCard rmList SBRTE_PROGID_DEV SBRTE_PROGID_RELEASE STATIC_ROLE_TYPE_CODE SUPPRESS_EMPTY_TEMPLATE_CREATION SYSTEM_USER_CODE UPDATE_DIALOG_DATASET USED_IN_OBJECT_HINT_PARAM USER_INITIATED_LOOKUP_ACTION USER_NAME_FORMAT USER_SELECTION_RESTRICTIONS WORKFLOW_TEST_PROTOCOLS_FOLDER_PATH ELS_SUBTYPE_CONTROL_NAME ELS_FOLDER_KIND_CONTROL_NAME REPEAT_PROCESS_CURRENT_OBJECT_EXCEPTION_NAME PRIVILEGE_COMPONENT_FULL_ACCESS PRIVILEGE_DEVELOPMENT_EXPORT PRIVILEGE_DEVELOPMENT_IMPORT PRIVILEGE_DOCUMENT_DELETE PRIVILEGE_ESD PRIVILEGE_FOLDER_DELETE PRIVILEGE_MANAGE_ACCESS_RIGHTS PRIVILEGE_MANAGE_REPLICATION PRIVILEGE_MANAGE_SESSION_SERVER PRIVILEGE_OBJECT_FULL_ACCESS PRIVILEGE_OBJECT_VIEW PRIVILEGE_RESERVE_LICENSE PRIVILEGE_SYSTEM_CUSTOMIZE PRIVILEGE_SYSTEM_DEVELOP PRIVILEGE_SYSTEM_INSTALL PRIVILEGE_TASK_DELETE PRIVILEGE_USER_PLUGIN_SETTINGS_CUSTOMIZE PRIVILEGES_PSEUDOREFERENCE_CODE ACCESS_TYPES_PSEUDOREFERENCE_CODE ALL_AVAILABLE_COMPONENTS_PSEUDOREFERENCE_CODE ALL_AVAILABLE_PRIVILEGES_PSEUDOREFERENCE_CODE ALL_REPLICATE_COMPONENTS_PSEUDOREFERENCE_CODE AVAILABLE_DEVELOPERS_COMPONENTS_PSEUDOREFERENCE_CODE COMPONENTS_PSEUDOREFERENCE_CODE FILTRATER_SETTINGS_CONFLICTS_PSEUDOREFERENCE_CODE GROUPS_PSEUDOREFERENCE_CODE RECEIVE_PROTOCOL_PSEUDOREFERENCE_CODE REFERENCE_REQUISITE_PSEUDOREFERENCE_CODE REFERENCE_REQUISITES_PSEUDOREFERENCE_CODE REFTYPES_PSEUDOREFERENCE_CODE REPLICATION_SEANCES_DIARY_PSEUDOREFERENCE_CODE SEND_PROTOCOL_PSEUDOREFERENCE_CODE SUBSTITUTES_PSEUDOREFERENCE_CODE SYSTEM_SETTINGS_PSEUDOREFERENCE_CODE UNITS_PSEUDOREFERENCE_CODE USERS_PSEUDOREFERENCE_CODE VIEWERS_PSEUDOREFERENCE_CODE CERTIFICATE_TYPE_ENCRYPT CERTIFICATE_TYPE_SIGN CERTIFICATE_TYPE_SIGN_AND_ENCRYPT STORAGE_TYPE_FILE STORAGE_TYPE_NAS_CIFS STORAGE_TYPE_SAPERION STORAGE_TYPE_SQL_SERVER COMPTYPE2_REQUISITE_DOCUMENTS_VALUE COMPTYPE2_REQUISITE_TASKS_VALUE COMPTYPE2_REQUISITE_FOLDERS_VALUE COMPTYPE2_REQUISITE_REFERENCES_VALUE SYSREQ_CODE SYSREQ_COMPTYPE2 SYSREQ_CONST_AVAILABLE_FOR_WEB SYSREQ_CONST_COMMON_CODE SYSREQ_CONST_COMMON_VALUE SYSREQ_CONST_FIRM_CODE SYSREQ_CONST_FIRM_STATUS SYSREQ_CONST_FIRM_VALUE SYSREQ_CONST_SERVER_STATUS SYSREQ_CONTENTS SYSREQ_DATE_OPEN SYSREQ_DATE_CLOSE SYSREQ_DESCRIPTION SYSREQ_DESCRIPTION_LOCALIZE_ID SYSREQ_DOUBLE SYSREQ_EDOC_ACCESS_TYPE SYSREQ_EDOC_AUTHOR SYSREQ_EDOC_CREATED SYSREQ_EDOC_DELEGATE_RIGHTS_REQUISITE_CODE SYSREQ_EDOC_EDITOR SYSREQ_EDOC_ENCODE_TYPE SYSREQ_EDOC_ENCRYPTION_PLUGIN_NAME SYSREQ_EDOC_ENCRYPTION_PLUGIN_VERSION SYSREQ_EDOC_EXPORT_DATE SYSREQ_EDOC_EXPORTER SYSREQ_EDOC_KIND SYSREQ_EDOC_LIFE_STAGE_NAME SYSREQ_EDOC_LOCKED_FOR_SERVER_CODE SYSREQ_EDOC_MODIFIED SYSREQ_EDOC_NAME SYSREQ_EDOC_NOTE SYSREQ_EDOC_QUALIFIED_ID SYSREQ_EDOC_SESSION_KEY SYSREQ_EDOC_SESSION_KEY_ENCRYPTION_PLUGIN_NAME SYSREQ_EDOC_SESSION_KEY_ENCRYPTION_PLUGIN_VERSION SYSREQ_EDOC_SIGNATURE_TYPE SYSREQ_EDOC_SIGNED SYSREQ_EDOC_STORAGE SYSREQ_EDOC_STORAGES_ARCHIVE_STORAGE SYSREQ_EDOC_STORAGES_CHECK_RIGHTS SYSREQ_EDOC_STORAGES_COMPUTER_NAME SYSREQ_EDOC_STORAGES_EDIT_IN_STORAGE SYSREQ_EDOC_STORAGES_EXECUTIVE_STORAGE SYSREQ_EDOC_STORAGES_FUNCTION SYSREQ_EDOC_STORAGES_INITIALIZED SYSREQ_EDOC_STORAGES_LOCAL_PATH SYSREQ_EDOC_STORAGES_SAPERION_DATABASE_NAME SYSREQ_EDOC_STORAGES_SEARCH_BY_TEXT SYSREQ_EDOC_STORAGES_SERVER_NAME SYSREQ_EDOC_STORAGES_SHARED_SOURCE_NAME SYSREQ_EDOC_STORAGES_TYPE SYSREQ_EDOC_TEXT_MODIFIED SYSREQ_EDOC_TYPE_ACT_CODE SYSREQ_EDOC_TYPE_ACT_DESCRIPTION SYSREQ_EDOC_TYPE_ACT_DESCRIPTION_LOCALIZE_ID SYSREQ_EDOC_TYPE_ACT_ON_EXECUTE SYSREQ_EDOC_TYPE_ACT_ON_EXECUTE_EXISTS SYSREQ_EDOC_TYPE_ACT_SECTION SYSREQ_EDOC_TYPE_ADD_PARAMS SYSREQ_EDOC_TYPE_COMMENT SYSREQ_EDOC_TYPE_EVENT_TEXT SYSREQ_EDOC_TYPE_NAME_IN_SINGULAR SYSREQ_EDOC_TYPE_NAME_IN_SINGULAR_LOCALIZE_ID SYSREQ_EDOC_TYPE_NAME_LOCALIZE_ID SYSREQ_EDOC_TYPE_NUMERATION_METHOD SYSREQ_EDOC_TYPE_PSEUDO_REQUISITE_CODE SYSREQ_EDOC_TYPE_REQ_CODE SYSREQ_EDOC_TYPE_REQ_DESCRIPTION SYSREQ_EDOC_TYPE_REQ_DESCRIPTION_LOCALIZE_ID SYSREQ_EDOC_TYPE_REQ_IS_LEADING SYSREQ_EDOC_TYPE_REQ_IS_REQUIRED SYSREQ_EDOC_TYPE_REQ_NUMBER SYSREQ_EDOC_TYPE_REQ_ON_CHANGE SYSREQ_EDOC_TYPE_REQ_ON_CHANGE_EXISTS SYSREQ_EDOC_TYPE_REQ_ON_SELECT SYSREQ_EDOC_TYPE_REQ_ON_SELECT_KIND SYSREQ_EDOC_TYPE_REQ_SECTION SYSREQ_EDOC_TYPE_VIEW_CARD SYSREQ_EDOC_TYPE_VIEW_CODE SYSREQ_EDOC_TYPE_VIEW_COMMENT SYSREQ_EDOC_TYPE_VIEW_IS_MAIN SYSREQ_EDOC_TYPE_VIEW_NAME SYSREQ_EDOC_TYPE_VIEW_NAME_LOCALIZE_ID SYSREQ_EDOC_VERSION_AUTHOR SYSREQ_EDOC_VERSION_CRC SYSREQ_EDOC_VERSION_DATA SYSREQ_EDOC_VERSION_EDITOR SYSREQ_EDOC_VERSION_EXPORT_DATE SYSREQ_EDOC_VERSION_EXPORTER SYSREQ_EDOC_VERSION_HIDDEN SYSREQ_EDOC_VERSION_LIFE_STAGE SYSREQ_EDOC_VERSION_MODIFIED SYSREQ_EDOC_VERSION_NOTE SYSREQ_EDOC_VERSION_SIGNATURE_TYPE SYSREQ_EDOC_VERSION_SIGNED SYSREQ_EDOC_VERSION_SIZE SYSREQ_EDOC_VERSION_SOURCE SYSREQ_EDOC_VERSION_TEXT_MODIFIED SYSREQ_EDOCKIND_DEFAULT_VERSION_STATE_CODE SYSREQ_FOLDER_KIND SYSREQ_FUNC_CATEGORY SYSREQ_FUNC_COMMENT SYSREQ_FUNC_GROUP SYSREQ_FUNC_GROUP_COMMENT SYSREQ_FUNC_GROUP_NUMBER SYSREQ_FUNC_HELP SYSREQ_FUNC_PARAM_DEF_VALUE SYSREQ_FUNC_PARAM_IDENT SYSREQ_FUNC_PARAM_NUMBER SYSREQ_FUNC_PARAM_TYPE SYSREQ_FUNC_TEXT SYSREQ_GROUP_CATEGORY SYSREQ_ID SYSREQ_LAST_UPDATE SYSREQ_LEADER_REFERENCE SYSREQ_LINE_NUMBER SYSREQ_MAIN_RECORD_ID SYSREQ_NAME SYSREQ_NAME_LOCALIZE_ID SYSREQ_NOTE SYSREQ_ORIGINAL_RECORD SYSREQ_OUR_FIRM SYSREQ_PROFILING_SETTINGS_BATCH_LOGING SYSREQ_PROFILING_SETTINGS_BATCH_SIZE SYSREQ_PROFILING_SETTINGS_PROFILING_ENABLED SYSREQ_PROFILING_SETTINGS_SQL_PROFILING_ENABLED SYSREQ_PROFILING_SETTINGS_START_LOGGED SYSREQ_RECORD_STATUS SYSREQ_REF_REQ_FIELD_NAME SYSREQ_REF_REQ_FORMAT SYSREQ_REF_REQ_GENERATED SYSREQ_REF_REQ_LENGTH SYSREQ_REF_REQ_PRECISION SYSREQ_REF_REQ_REFERENCE SYSREQ_REF_REQ_SECTION SYSREQ_REF_REQ_STORED SYSREQ_REF_REQ_TOKENS SYSREQ_REF_REQ_TYPE SYSREQ_REF_REQ_VIEW SYSREQ_REF_TYPE_ACT_CODE SYSREQ_REF_TYPE_ACT_DESCRIPTION SYSREQ_REF_TYPE_ACT_DESCRIPTION_LOCALIZE_ID SYSREQ_REF_TYPE_ACT_ON_EXECUTE SYSREQ_REF_TYPE_ACT_ON_EXECUTE_EXISTS SYSREQ_REF_TYPE_ACT_SECTION SYSREQ_REF_TYPE_ADD_PARAMS SYSREQ_REF_TYPE_COMMENT SYSREQ_REF_TYPE_COMMON_SETTINGS SYSREQ_REF_TYPE_DISPLAY_REQUISITE_NAME SYSREQ_REF_TYPE_EVENT_TEXT SYSREQ_REF_TYPE_MAIN_LEADING_REF SYSREQ_REF_TYPE_NAME_IN_SINGULAR SYSREQ_REF_TYPE_NAME_IN_SINGULAR_LOCALIZE_ID SYSREQ_REF_TYPE_NAME_LOCALIZE_ID SYSREQ_REF_TYPE_NUMERATION_METHOD SYSREQ_REF_TYPE_REQ_CODE SYSREQ_REF_TYPE_REQ_DESCRIPTION SYSREQ_REF_TYPE_REQ_DESCRIPTION_LOCALIZE_ID SYSREQ_REF_TYPE_REQ_IS_CONTROL SYSREQ_REF_TYPE_REQ_IS_FILTER SYSREQ_REF_TYPE_REQ_IS_LEADING SYSREQ_REF_TYPE_REQ_IS_REQUIRED SYSREQ_REF_TYPE_REQ_NUMBER SYSREQ_REF_TYPE_REQ_ON_CHANGE SYSREQ_REF_TYPE_REQ_ON_CHANGE_EXISTS SYSREQ_REF_TYPE_REQ_ON_SELECT SYSREQ_REF_TYPE_REQ_ON_SELECT_KIND SYSREQ_REF_TYPE_REQ_SECTION SYSREQ_REF_TYPE_VIEW_CARD SYSREQ_REF_TYPE_VIEW_CODE SYSREQ_REF_TYPE_VIEW_COMMENT SYSREQ_REF_TYPE_VIEW_IS_MAIN SYSREQ_REF_TYPE_VIEW_NAME SYSREQ_REF_TYPE_VIEW_NAME_LOCALIZE_ID SYSREQ_REFERENCE_TYPE_ID SYSREQ_STATE SYSREQ_STAT\u0415 SYSREQ_SYSTEM_SETTINGS_VALUE SYSREQ_TYPE SYSREQ_UNIT SYSREQ_UNIT_ID SYSREQ_USER_GROUPS_GROUP_FULL_NAME SYSREQ_USER_GROUPS_GROUP_NAME SYSREQ_USER_GROUPS_GROUP_SERVER_NAME SYSREQ_USERS_ACCESS_RIGHTS SYSREQ_USERS_AUTHENTICATION SYSREQ_USERS_CATEGORY SYSREQ_USERS_COMPONENT SYSREQ_USERS_COMPONENT_USER_IS_PUBLIC SYSREQ_USERS_DOMAIN SYSREQ_USERS_FULL_USER_NAME SYSREQ_USERS_GROUP SYSREQ_USERS_IS_MAIN_SERVER SYSREQ_USERS_LOGIN SYSREQ_USERS_REFERENCE_USER_IS_PUBLIC SYSREQ_USERS_STATUS SYSREQ_USERS_USER_CERTIFICATE SYSREQ_USERS_USER_CERTIFICATE_INFO SYSREQ_USERS_USER_CERTIFICATE_PLUGIN_NAME SYSREQ_USERS_USER_CERTIFICATE_PLUGIN_VERSION SYSREQ_USERS_USER_CERTIFICATE_STATE SYSREQ_USERS_USER_CERTIFICATE_SUBJECT_NAME SYSREQ_USERS_USER_CERTIFICATE_THUMBPRINT SYSREQ_USERS_USER_DEFAULT_CERTIFICATE SYSREQ_USERS_USER_DESCRIPTION SYSREQ_USERS_USER_GLOBAL_NAME SYSREQ_USERS_USER_LOGIN SYSREQ_USERS_USER_MAIN_SERVER SYSREQ_USERS_USER_TYPE SYSREQ_WORK_RULES_FOLDER_ID RESULT_VAR_NAME RESULT_VAR_NAME_ENG AUTO_NUMERATION_RULE_ID CANT_CHANGE_ID_REQUISITE_RULE_ID CANT_CHANGE_OURFIRM_REQUISITE_RULE_ID CHECK_CHANGING_REFERENCE_RECORD_USE_RULE_ID CHECK_CODE_REQUISITE_RULE_ID CHECK_DELETING_REFERENCE_RECORD_USE_RULE_ID CHECK_FILTRATER_CHANGES_RULE_ID CHECK_RECORD_INTERVAL_RULE_ID CHECK_REFERENCE_INTERVAL_RULE_ID CHECK_REQUIRED_DATA_FULLNESS_RULE_ID CHECK_REQUIRED_REQUISITES_FULLNESS_RULE_ID MAKE_RECORD_UNRATIFIED_RULE_ID RESTORE_AUTO_NUMERATION_RULE_ID SET_FIRM_CONTEXT_FROM_RECORD_RULE_ID SET_FIRST_RECORD_IN_LIST_FORM_RULE_ID SET_IDSPS_VALUE_RULE_ID SET_NEXT_CODE_VALUE_RULE_ID SET_OURFIRM_BOUNDS_RULE_ID SET_OURFIRM_REQUISITE_RULE_ID SCRIPT_BLOCK_AFTER_FINISH_EVENT SCRIPT_BLOCK_BEFORE_START_EVENT SCRIPT_BLOCK_EXECUTION_RESULTS_PROPERTY SCRIPT_BLOCK_NAME_PROPERTY SCRIPT_BLOCK_SCRIPT_PROPERTY SUBTASK_BLOCK_ABORT_DEADLINE_PROPERTY SUBTASK_BLOCK_AFTER_FINISH_EVENT SUBTASK_BLOCK_ASSIGN_PARAMS_EVENT SUBTASK_BLOCK_ATTACHMENTS_PROPERTY SUBTASK_BLOCK_ATTACHMENTS_RIGHTS_GROUP_PROPERTY SUBTASK_BLOCK_ATTACHMENTS_RIGHTS_TYPE_PROPERTY SUBTASK_BLOCK_BEFORE_START_EVENT SUBTASK_BLOCK_CREATED_TASK_PROPERTY SUBTASK_BLOCK_CREATION_EVENT SUBTASK_BLOCK_DEADLINE_PROPERTY SUBTASK_BLOCK_IMPORTANCE_PROPERTY SUBTASK_BLOCK_INITIATOR_PROPERTY SUBTASK_BLOCK_IS_RELATIVE_ABORT_DEADLINE_PROPERTY SUBTASK_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY SUBTASK_BLOCK_JOBS_TYPE_PROPERTY SUBTASK_BLOCK_NAME_PROPERTY SUBTASK_BLOCK_PARALLEL_ROUTE_PROPERTY SUBTASK_BLOCK_PERFORMERS_PROPERTY SUBTASK_BLOCK_RELATIVE_ABORT_DEADLINE_TYPE_PROPERTY SUBTASK_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY SUBTASK_BLOCK_REQUIRE_SIGN_PROPERTY SUBTASK_BLOCK_STANDARD_ROUTE_PROPERTY SUBTASK_BLOCK_START_EVENT SUBTASK_BLOCK_STEP_CONTROL_PROPERTY SUBTASK_BLOCK_SUBJECT_PROPERTY SUBTASK_BLOCK_TASK_CONTROL_PROPERTY SUBTASK_BLOCK_TEXT_PROPERTY SUBTASK_BLOCK_UNLOCK_ATTACHMENTS_ON_STOP_PROPERTY SUBTASK_BLOCK_USE_STANDARD_ROUTE_PROPERTY SUBTASK_BLOCK_WAIT_FOR_TASK_COMPLETE_PROPERTY SYSCOMP_CONTROL_JOBS SYSCOMP_FOLDERS SYSCOMP_JOBS SYSCOMP_NOTICES SYSCOMP_TASKS SYSDLG_CREATE_EDOCUMENT SYSDLG_CREATE_EDOCUMENT_VERSION SYSDLG_CURRENT_PERIOD SYSDLG_EDIT_FUNCTION_HELP SYSDLG_EDOCUMENT_KINDS_FOR_TEMPLATE SYSDLG_EXPORT_MULTIPLE_EDOCUMENTS SYSDLG_EXPORT_SINGLE_EDOCUMENT SYSDLG_IMPORT_EDOCUMENT SYSDLG_MULTIPLE_SELECT SYSDLG_SETUP_ACCESS_RIGHTS SYSDLG_SETUP_DEFAULT_RIGHTS SYSDLG_SETUP_FILTER_CONDITION SYSDLG_SETUP_SIGN_RIGHTS SYSDLG_SETUP_TASK_OBSERVERS SYSDLG_SETUP_TASK_ROUTE SYSDLG_SETUP_USERS_LIST SYSDLG_SIGN_EDOCUMENT SYSDLG_SIGN_MULTIPLE_EDOCUMENTS SYSREF_ACCESS_RIGHTS_TYPES SYSREF_ADMINISTRATION_HISTORY SYSREF_ALL_AVAILABLE_COMPONENTS SYSREF_ALL_AVAILABLE_PRIVILEGES SYSREF_ALL_REPLICATING_COMPONENTS SYSREF_AVAILABLE_DEVELOPERS_COMPONENTS SYSREF_CALENDAR_EVENTS SYSREF_COMPONENT_TOKEN_HISTORY SYSREF_COMPONENT_TOKENS SYSREF_COMPONENTS SYSREF_CONSTANTS SYSREF_DATA_RECEIVE_PROTOCOL SYSREF_DATA_SEND_PROTOCOL SYSREF_DIALOGS SYSREF_DIALOGS_REQUISITES SYSREF_EDITORS SYSREF_EDOC_CARDS SYSREF_EDOC_TYPES SYSREF_EDOCUMENT_CARD_REQUISITES SYSREF_EDOCUMENT_CARD_TYPES SYSREF_EDOCUMENT_CARD_TYPES_REFERENCE SYSREF_EDOCUMENT_CARDS SYSREF_EDOCUMENT_HISTORY SYSREF_EDOCUMENT_KINDS SYSREF_EDOCUMENT_REQUISITES SYSREF_EDOCUMENT_SIGNATURES SYSREF_EDOCUMENT_TEMPLATES SYSREF_EDOCUMENT_TEXT_STORAGES SYSREF_EDOCUMENT_VIEWS SYSREF_FILTERER_SETUP_CONFLICTS SYSREF_FILTRATER_SETTING_CONFLICTS SYSREF_FOLDER_HISTORY SYSREF_FOLDERS SYSREF_FUNCTION_GROUPS SYSREF_FUNCTION_PARAMS SYSREF_FUNCTIONS SYSREF_JOB_HISTORY SYSREF_LINKS SYSREF_LOCALIZATION_DICTIONARY SYSREF_LOCALIZATION_LANGUAGES SYSREF_MODULES SYSREF_PRIVILEGES SYSREF_RECORD_HISTORY SYSREF_REFERENCE_REQUISITES SYSREF_REFERENCE_TYPE_VIEWS SYSREF_REFERENCE_TYPES SYSREF_REFERENCES SYSREF_REFERENCES_REQUISITES SYSREF_REMOTE_SERVERS SYSREF_REPLICATION_SESSIONS_LOG SYSREF_REPLICATION_SESSIONS_PROTOCOL SYSREF_REPORTS SYSREF_ROLES SYSREF_ROUTE_BLOCK_GROUPS SYSREF_ROUTE_BLOCKS SYSREF_SCRIPTS SYSREF_SEARCHES SYSREF_SERVER_EVENTS SYSREF_SERVER_EVENTS_HISTORY SYSREF_STANDARD_ROUTE_GROUPS SYSREF_STANDARD_ROUTES SYSREF_STATUSES SYSREF_SYSTEM_SETTINGS SYSREF_TASK_HISTORY SYSREF_TASK_KIND_GROUPS SYSREF_TASK_KINDS SYSREF_TASK_RIGHTS SYSREF_TASK_SIGNATURES SYSREF_TASKS SYSREF_UNITS SYSREF_USER_GROUPS SYSREF_USER_GROUPS_REFERENCE SYSREF_USER_SUBSTITUTION SYSREF_USERS SYSREF_USERS_REFERENCE SYSREF_VIEWERS SYSREF_WORKING_TIME_CALENDARS ACCESS_RIGHTS_TABLE_NAME EDMS_ACCESS_TABLE_NAME EDOC_TYPES_TABLE_NAME TEST_DEV_DB_NAME TEST_DEV_SYSTEM_CODE TEST_EDMS_DB_NAME TEST_EDMS_MAIN_CODE TEST_EDMS_MAIN_DB_NAME TEST_EDMS_SECOND_CODE TEST_EDMS_SECOND_DB_NAME TEST_EDMS_SYSTEM_CODE TEST_ISB5_MAIN_CODE TEST_ISB5_SECOND_CODE TEST_SQL_SERVER_2005_NAME TEST_SQL_SERVER_NAME ATTENTION_CAPTION cbsCommandLinks cbsDefault CONFIRMATION_CAPTION ERROR_CAPTION INFORMATION_CAPTION mrCancel mrOk EDOC_VERSION_ACTIVE_STAGE_CODE EDOC_VERSION_DESIGN_STAGE_CODE EDOC_VERSION_OBSOLETE_STAGE_CODE cpDataEnciphermentEnabled cpDigitalSignatureEnabled cpID cpIssuer cpPluginVersion cpSerial cpSubjectName cpSubjSimpleName cpValidFromDate cpValidToDate ISBL_SYNTAX NO_SYNTAX XML_SYNTAX WAIT_BLOCK_AFTER_FINISH_EVENT WAIT_BLOCK_BEFORE_START_EVENT WAIT_BLOCK_DEADLINE_PROPERTY WAIT_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY WAIT_BLOCK_NAME_PROPERTY WAIT_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY SYSRES_COMMON SYSRES_CONST SYSRES_MBFUNC SYSRES_SBDATA SYSRES_SBGUI SYSRES_SBINTF SYSRES_SBREFDSC SYSRES_SQLERRORS SYSRES_SYSCOMP atUser atGroup atRole aemEnabledAlways aemDisabledAlways aemEnabledOnBrowse aemEnabledOnEdit aemDisabledOnBrowseEmpty apBegin apEnd alLeft alRight asmNever asmNoButCustomize asmAsLastTime asmYesButCustomize asmAlways cirCommon cirRevoked ctSignature ctEncode ctSignatureEncode clbUnchecked clbChecked clbGrayed ceISB ceAlways ceNever ctDocument ctReference ctScript ctUnknown ctReport ctDialog ctFunction ctFolder ctEDocument ctTask ctJob ctNotice ctControlJob cfInternal cfDisplay ciUnspecified ciWrite ciRead ckFolder ckEDocument ckTask ckJob ckComponentToken ckAny ckReference ckScript ckReport ckDialog ctISBLEditor ctBevel ctButton ctCheckListBox ctComboBox ctComboEdit ctGrid ctDBCheckBox ctDBComboBox ctDBEdit ctDBEllipsis ctDBMemo ctDBNavigator ctDBRadioGroup ctDBStatusLabel ctEdit ctGroupBox ctInplaceHint ctMemo ctPanel ctListBox ctRadioButton ctRichEdit ctTabSheet ctWebBrowser ctImage ctHyperLink ctLabel ctDBMultiEllipsis ctRibbon ctRichView ctInnerPanel ctPanelGroup ctBitButton cctDate cctInteger cctNumeric cctPick cctReference cctString cctText cltInternal cltPrimary cltGUI dseBeforeOpen dseAfterOpen dseBeforeClose dseAfterClose dseOnValidDelete dseBeforeDelete dseAfterDelete dseAfterDeleteOutOfTransaction dseOnDeleteError dseBeforeInsert dseAfterInsert dseOnValidUpdate dseBeforeUpdate dseOnUpdateRatifiedRecord dseAfterUpdate dseAfterUpdateOutOfTransaction dseOnUpdateError dseAfterScroll dseOnOpenRecord dseOnCloseRecord dseBeforeCancel dseAfterCancel dseOnUpdateDeadlockError dseBeforeDetailUpdate dseOnPrepareUpdate dseOnAnyRequisiteChange dssEdit dssInsert dssBrowse dssInActive dftDate dftShortDate dftDateTime dftTimeStamp dotDays dotHours dotMinutes dotSeconds dtkndLocal dtkndUTC arNone arView arEdit arFull ddaView ddaEdit emLock emEdit emSign emExportWithLock emImportWithUnlock emChangeVersionNote emOpenForModify emChangeLifeStage emDelete emCreateVersion emImport emUnlockExportedWithLock emStart emAbort emReInit emMarkAsReaded emMarkAsUnreaded emPerform emAccept emResume emChangeRights emEditRoute emEditObserver emRecoveryFromLocalCopy emChangeWorkAccessType emChangeEncodeTypeToCertificate emChangeEncodeTypeToPassword emChangeEncodeTypeToNone emChangeEncodeTypeToCertificatePassword emChangeStandardRoute emGetText emOpenForView emMoveToStorage emCreateObject emChangeVersionHidden emDeleteVersion emChangeLifeCycleStage emApprovingSign emExport emContinue emLockFromEdit emUnLockForEdit emLockForServer emUnlockFromServer emDelegateAccessRights emReEncode ecotFile ecotProcess eaGet eaCopy eaCreate eaCreateStandardRoute edltAll edltNothing edltQuery essmText essmCard esvtLast esvtLastActive esvtSpecified edsfExecutive edsfArchive edstSQLServer edstFile edvstNone edvstEDocumentVersionCopy edvstFile edvstTemplate edvstScannedFile vsDefault vsDesign vsActive vsObsolete etNone etCertificate etPassword etCertificatePassword ecException ecWarning ecInformation estAll estApprovingOnly evtLast evtLastActive evtQuery fdtString fdtNumeric fdtInteger fdtDate fdtText fdtUnknown fdtWideString fdtLargeInteger ftInbox ftOutbox ftFavorites ftCommonFolder ftUserFolder ftComponents ftQuickLaunch ftShortcuts ftSearch grhAuto grhX1 grhX2 grhX3 hltText hltRTF hltHTML iffBMP iffJPEG iffMultiPageTIFF iffSinglePageTIFF iffTIFF iffPNG im8bGrayscale im24bRGB im1bMonochrome itBMP itJPEG itWMF itPNG ikhInformation ikhWarning ikhError ikhNoIcon icUnknown icScript icFunction icIntegratedReport icAnalyticReport icDataSetEventHandler icActionHandler icFormEventHandler icLookUpEventHandler icRequisiteChangeEventHandler icBeforeSearchEventHandler icRoleCalculation icSelectRouteEventHandler icBlockPropertyCalculation icBlockQueryParamsEventHandler icChangeSearchResultEventHandler icBlockEventHandler icSubTaskInitEventHandler icEDocDataSetEventHandler icEDocLookUpEventHandler icEDocActionHandler icEDocFormEventHandler icEDocRequisiteChangeEventHandler icStructuredConversionRule icStructuredConversionEventBefore icStructuredConversionEventAfter icWizardEventHandler icWizardFinishEventHandler icWizardStepEventHandler icWizardStepFinishEventHandler icWizardActionEnableEventHandler icWizardActionExecuteEventHandler icCreateJobsHandler icCreateNoticesHandler icBeforeLookUpEventHandler icAfterLookUpEventHandler icTaskAbortEventHandler icWorkflowBlockActionHandler icDialogDataSetEventHandler icDialogActionHandler icDialogLookUpEventHandler icDialogRequisiteChangeEventHandler icDialogFormEventHandler icDialogValidCloseEventHandler icBlockFormEventHandler icTaskFormEventHandler icReferenceMethod icEDocMethod icDialogMethod icProcessMessageHandler isShow isHide isByUserSettings jkJob jkNotice jkControlJob jtInner jtLeft jtRight jtFull jtCross lbpAbove lbpBelow lbpLeft lbpRight eltPerConnection eltPerUser sfcUndefined sfcBlack sfcGreen sfcRed sfcBlue sfcOrange sfcLilac sfsItalic sfsStrikeout sfsNormal ldctStandardRoute ldctWizard ldctScript ldctFunction ldctRouteBlock ldctIntegratedReport ldctAnalyticReport ldctReferenceType ldctEDocumentType ldctDialog ldctServerEvents mrcrtNone mrcrtUser mrcrtMaximal mrcrtCustom vtEqual vtGreaterOrEqual vtLessOrEqual vtRange rdYesterday rdToday rdTomorrow rdThisWeek rdThisMonth rdThisYear rdNextMonth rdNextWeek rdLastWeek rdLastMonth rdWindow rdFile rdPrinter rdtString rdtNumeric rdtInteger rdtDate rdtReference rdtAccount rdtText rdtPick rdtUnknown rdtLargeInteger rdtDocument reOnChange reOnChangeValues ttGlobal ttLocal ttUser ttSystem ssmBrowse ssmSelect ssmMultiSelect ssmBrowseModal smSelect smLike smCard stNone stAuthenticating stApproving sctString sctStream sstAnsiSort sstNaturalSort svtEqual svtContain soatString soatNumeric soatInteger soatDatetime soatReferenceRecord soatText soatPick soatBoolean soatEDocument soatAccount soatIntegerCollection soatNumericCollection soatStringCollection soatPickCollection soatDatetimeCollection soatBooleanCollection soatReferenceRecordCollection soatEDocumentCollection soatAccountCollection soatContents soatUnknown tarAbortByUser tarAbortByWorkflowException tvtAllWords tvtExactPhrase tvtAnyWord usNone usCompleted usRedSquare usBlueSquare usYellowSquare usGreenSquare usOrangeSquare usPurpleSquare usFollowUp utUnknown utUser utDeveloper utAdministrator utSystemDeveloper utDisconnected btAnd btDetailAnd btOr btNotOr btOnly vmView vmSelect vmNavigation vsmSingle vsmMultiple vsmMultipleCheck vsmNoSelection wfatPrevious wfatNext wfatCancel wfatFinish wfepUndefined wfepText3 wfepText6 wfepText9 wfepSpinEdit wfepDropDown wfepRadioGroup wfepFlag wfepText12 wfepText15 wfepText18 wfepText21 wfepText24 wfepText27 wfepText30 wfepRadioGroupColumn1 wfepRadioGroupColumn2 wfepRadioGroupColumn3 wfetQueryParameter wfetText wfetDelimiter wfetLabel wptString wptInteger wptNumeric wptBoolean wptDateTime wptPick wptText wptUser wptUserList wptEDocumentInfo wptEDocumentInfoList wptReferenceRecordInfo wptReferenceRecordInfoList wptFolderInfo wptTaskInfo wptContents wptFileName wptDate wsrComplete wsrGoNext wsrGoPrevious wsrCustom wsrCancel wsrGoFinal wstForm wstEDocument wstTaskCard wstReferenceRecordCard wstFinal waAll waPerformers waManual wsbStart wsbFinish wsbNotice wsbStep wsbDecision wsbWait wsbMonitor wsbScript wsbConnector wsbSubTask wsbLifeCycleStage wsbPause wdtInteger wdtFloat wdtString wdtPick wdtDateTime wdtBoolean wdtTask wdtJob wdtFolder wdtEDocument wdtReferenceRecord wdtUser wdtGroup wdtRole wdtIntegerCollection wdtFloatCollection wdtStringCollection wdtPickCollection wdtDateTimeCollection wdtBooleanCollection wdtTaskCollection wdtJobCollection wdtFolderCollection wdtEDocumentCollection wdtReferenceRecordCollection wdtUserCollection wdtGroupCollection wdtRoleCollection wdtContents wdtUserList wdtSearchDescription wdtDeadLine wdtPickSet wdtAccountCollection wiLow wiNormal wiHigh wrtSoft wrtHard wsInit wsRunning wsDone wsControlled wsAborted wsContinued wtmFull wtmFromCurrent wtmOnlyCurrent ",
-class:"AltState Application CallType ComponentTokens CreatedJobs CreatedNotices ControlState DialogResult Dialogs EDocuments EDocumentVersionSource Folders GlobalIDs Job Jobs InputValue LookUpReference LookUpRequisiteNames LookUpSearch Object ParentComponent Processes References Requisite ReportName Reports Result Scripts Searches SelectedAttachments SelectedItems SelectMode Sender ServerEvents ServiceFactory ShiftState SubTask SystemDialogs Tasks Wizard Wizards Work \u0412\u044b\u0437\u043e\u0432\u0421\u043f\u043e\u0441\u043e\u0431 \u0418\u043c\u044f\u041e\u0442\u0447\u0435\u0442\u0430 \u0420\u0435\u043a\u0432\u0417\u043d\u0430\u0447 ",
-literal:"null true false nil "},I={begin:"\\.\\s*"+S.UNDERSCORE_IDENT_RE,
-keywords:C,relevance:0},N={className:"type",
-begin:":[ \\t]*(IApplication|IAccessRights|IAccountRepository|IAccountSelectionRestrictions|IAction|IActionList|IAdministrationHistoryDescription|IAnchors|IApplication|IArchiveInfo|IAttachment|IAttachmentList|ICheckListBox|ICheckPointedList|IColumn|IComponent|IComponentDescription|IComponentToken|IComponentTokenFactory|IComponentTokenInfo|ICompRecordInfo|IConnection|IContents|IControl|IControlJob|IControlJobInfo|IControlList|ICrypto|ICrypto2|ICustomJob|ICustomJobInfo|ICustomListBox|ICustomObjectWizardStep|ICustomWork|ICustomWorkInfo|IDataSet|IDataSetAccessInfo|IDataSigner|IDateCriterion|IDateRequisite|IDateRequisiteDescription|IDateValue|IDeaAccessRights|IDeaObjectInfo|IDevelopmentComponentLock|IDialog|IDialogFactory|IDialogPickRequisiteItems|IDialogsFactory|IDICSFactory|IDocRequisite|IDocumentInfo|IDualListDialog|IECertificate|IECertificateInfo|IECertificates|IEditControl|IEditorForm|IEdmsExplorer|IEdmsObject|IEdmsObjectDescription|IEdmsObjectFactory|IEdmsObjectInfo|IEDocument|IEDocumentAccessRights|IEDocumentDescription|IEDocumentEditor|IEDocumentFactory|IEDocumentInfo|IEDocumentStorage|IEDocumentVersion|IEDocumentVersionListDialog|IEDocumentVersionSource|IEDocumentWizardStep|IEDocVerSignature|IEDocVersionState|IEnabledMode|IEncodeProvider|IEncrypter|IEvent|IEventList|IException|IExternalEvents|IExternalHandler|IFactory|IField|IFileDialog|IFolder|IFolderDescription|IFolderDialog|IFolderFactory|IFolderInfo|IForEach|IForm|IFormTitle|IFormWizardStep|IGlobalIDFactory|IGlobalIDInfo|IGrid|IHasher|IHistoryDescription|IHyperLinkControl|IImageButton|IImageControl|IInnerPanel|IInplaceHint|IIntegerCriterion|IIntegerList|IIntegerRequisite|IIntegerValue|IISBLEditorForm|IJob|IJobDescription|IJobFactory|IJobForm|IJobInfo|ILabelControl|ILargeIntegerCriterion|ILargeIntegerRequisite|ILargeIntegerValue|ILicenseInfo|ILifeCycleStage|IList|IListBox|ILocalIDInfo|ILocalization|ILock|IMemoryDataSet|IMessagingFactory|IMetadataRepository|INotice|INoticeInfo|INumericCriterion|INumericRequisite|INumericValue|IObject|IObjectDescription|IObjectImporter|IObjectInfo|IObserver|IPanelGroup|IPickCriterion|IPickProperty|IPickRequisite|IPickRequisiteDescription|IPickRequisiteItem|IPickRequisiteItems|IPickValue|IPrivilege|IPrivilegeList|IProcess|IProcessFactory|IProcessMessage|IProgress|IProperty|IPropertyChangeEvent|IQuery|IReference|IReferenceCriterion|IReferenceEnabledMode|IReferenceFactory|IReferenceHistoryDescription|IReferenceInfo|IReferenceRecordCardWizardStep|IReferenceRequisiteDescription|IReferencesFactory|IReferenceValue|IRefRequisite|IReport|IReportFactory|IRequisite|IRequisiteDescription|IRequisiteDescriptionList|IRequisiteFactory|IRichEdit|IRouteStep|IRule|IRuleList|ISchemeBlock|IScript|IScriptFactory|ISearchCriteria|ISearchCriterion|ISearchDescription|ISearchFactory|ISearchFolderInfo|ISearchForObjectDescription|ISearchResultRestrictions|ISecuredContext|ISelectDialog|IServerEvent|IServerEventFactory|IServiceDialog|IServiceFactory|ISignature|ISignProvider|ISignProvider2|ISignProvider3|ISimpleCriterion|IStringCriterion|IStringList|IStringRequisite|IStringRequisiteDescription|IStringValue|ISystemDialogsFactory|ISystemInfo|ITabSheet|ITask|ITaskAbortReasonInfo|ITaskCardWizardStep|ITaskDescription|ITaskFactory|ITaskInfo|ITaskRoute|ITextCriterion|ITextRequisite|ITextValue|ITreeListSelectDialog|IUser|IUserList|IValue|IView|IWebBrowserControl|IWizard|IWizardAction|IWizardFactory|IWizardFormElement|IWizardParam|IWizardPickParam|IWizardReferenceParam|IWizardStep|IWorkAccessRights|IWorkDescription|IWorkflowAskableParam|IWorkflowAskableParams|IWorkflowBlock|IWorkflowBlockResult|IWorkflowEnabledMode|IWorkflowParam|IWorkflowPickParam|IWorkflowReferenceParam|IWorkState|IWorkTreeCustomNode|IWorkTreeJobNode|IWorkTreeTaskNode|IXMLEditorForm|SBCrypto)",
-end:"[ \\t]*=",excludeEnd:!0},A={className:"variable",keywords:C,begin:E,
-relevance:0,contains:[N,I]
-},e="[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]*\\("
-;return{name:"ISBL",case_insensitive:!0,keywords:C,
-illegal:"\\$|\\?|%|,|;$|~|#|@|</",contains:[{className:"function",begin:e,
-end:"\\)$",returnBegin:!0,keywords:C,illegal:"[\\[\\]\\|\\$\\?%,~#@]",
-contains:[{className:"title",keywords:{$pattern:E,
-built_in:"AddSubString AdjustLineBreaks AmountInWords Analysis ArrayDimCount ArrayHighBound ArrayLowBound ArrayOf ArrayReDim Assert Assigned BeginOfMonth BeginOfPeriod BuildProfilingOperationAnalysis CallProcedure CanReadFile CArrayElement CDataSetRequisite ChangeDate ChangeReferenceDataset Char CharPos CheckParam CheckParamValue CompareStrings ConstantExists ControlState ConvertDateStr Copy CopyFile CreateArray CreateCachedReference CreateConnection CreateDialog CreateDualListDialog CreateEditor CreateException CreateFile CreateFolderDialog CreateInputDialog CreateLinkFile CreateList CreateLock CreateMemoryDataSet CreateObject CreateOpenDialog CreateProgress CreateQuery CreateReference CreateReport CreateSaveDialog CreateScript CreateSQLPivotFunction CreateStringList CreateTreeListSelectDialog CSelectSQL CSQL CSubString CurrentUserID CurrentUserName CurrentVersion DataSetLocateEx DateDiff DateTimeDiff DateToStr DayOfWeek DeleteFile DirectoryExists DisableCheckAccessRights DisableCheckFullShowingRestriction DisableMassTaskSendingRestrictions DropTable DupeString EditText EnableCheckAccessRights EnableCheckFullShowingRestriction EnableMassTaskSendingRestrictions EndOfMonth EndOfPeriod ExceptionExists ExceptionsOff ExceptionsOn Execute ExecuteProcess Exit ExpandEnvironmentVariables ExtractFileDrive ExtractFileExt ExtractFileName ExtractFilePath ExtractParams FileExists FileSize FindFile FindSubString FirmContext ForceDirectories Format FormatDate FormatNumeric FormatSQLDate FormatString FreeException GetComponent GetComponentLaunchParam GetConstant GetLastException GetReferenceRecord GetRefTypeByRefID GetTableID GetTempFolder IfThen In IndexOf InputDialog InputDialogEx InteractiveMode IsFileLocked IsGraphicFile IsNumeric Length LoadString LoadStringFmt LocalTimeToUTC LowerCase Max MessageBox MessageBoxEx MimeDecodeBinary MimeDecodeString MimeEncodeBinary MimeEncodeString Min MoneyInWords MoveFile NewID Now OpenFile Ord Precision Raise ReadCertificateFromFile ReadFile ReferenceCodeByID ReferenceNumber ReferenceRequisiteMode ReferenceRequisiteValue RegionDateSettings RegionNumberSettings RegionTimeSettings RegRead RegWrite RenameFile Replace Round SelectServerCode SelectSQL ServerDateTime SetConstant SetManagedFolderFieldsState ShowConstantsInputDialog ShowMessage Sleep Split SQL SQL2XLSTAB SQLProfilingSendReport StrToDate SubString SubStringCount SystemSetting Time TimeDiff Today Transliterate Trim UpperCase UserStatus UTCToLocalTime ValidateXML VarIsClear VarIsEmpty VarIsNull WorkTimeDiff WriteFile WriteFileEx WriteObjectHistory \u0410\u043d\u0430\u043b\u0438\u0437 \u0411\u0430\u0437\u0430\u0414\u0430\u043d\u043d\u044b\u0445 \u0411\u043b\u043e\u043a\u0415\u0441\u0442\u044c \u0411\u043b\u043e\u043a\u0415\u0441\u0442\u044c\u0420\u0430\u0441\u0448 \u0411\u043b\u043e\u043a\u0418\u043d\u0444\u043e \u0411\u043b\u043e\u043a\u0421\u043d\u044f\u0442\u044c \u0411\u043b\u043e\u043a\u0421\u043d\u044f\u0442\u044c\u0420\u0430\u0441\u0448 \u0411\u043b\u043e\u043a\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0412\u0432\u043e\u0434 \u0412\u0432\u043e\u0434\u041c\u0435\u043d\u044e \u0412\u0435\u0434\u0421 \u0412\u0435\u0434\u0421\u043f\u0440 \u0412\u0435\u0440\u0445\u043d\u044f\u044f\u0413\u0440\u0430\u043d\u0438\u0446\u0430\u041c\u0430\u0441\u0441\u0438\u0432\u0430 \u0412\u043d\u0435\u0448\u041f\u0440\u043e\u0433\u0440 \u0412\u043e\u0441\u0441\u0442 \u0412\u0440\u0435\u043c\u0435\u043d\u043d\u0430\u044f\u041f\u0430\u043f\u043a\u0430 \u0412\u0440\u0435\u043c\u044f \u0412\u044b\u0431\u043e\u0440SQL \u0412\u044b\u0431\u0440\u0430\u0442\u044c\u0417\u0430\u043f\u0438\u0441\u044c \u0412\u044b\u0434\u0435\u043b\u0438\u0442\u044c\u0421\u0442\u0440 \u0412\u044b\u0437\u0432\u0430\u0442\u044c \u0412\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0412\u044b\u043f\u041f\u0440\u043e\u0433\u0440 \u0413\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0438\u0439\u0424\u0430\u0439\u043b \u0413\u0440\u0443\u043f\u043f\u0430\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u0414\u0430\u0442\u0430\u0412\u0440\u0435\u043c\u044f\u0421\u0435\u0440\u0432 \u0414\u0435\u043d\u044c\u041d\u0435\u0434\u0435\u043b\u0438 \u0414\u0438\u0430\u043b\u043e\u0433\u0414\u0430\u041d\u0435\u0442 \u0414\u043b\u0438\u043d\u0430\u0421\u0442\u0440 \u0414\u043e\u0431\u041f\u043e\u0434\u0441\u0442\u0440 \u0415\u041f\u0443\u0441\u0442\u043e \u0415\u0441\u043b\u0438\u0422\u043e \u0415\u0427\u0438\u0441\u043b\u043e \u0417\u0430\u043c\u041f\u043e\u0434\u0441\u0442\u0440 \u0417\u0430\u043f\u0438\u0441\u044c\u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u0417\u043d\u0430\u0447\u041f\u043e\u043b\u044f\u0421\u043f\u0440 \u0418\u0414\u0422\u0438\u043f\u0421\u043f\u0440 \u0418\u0437\u0432\u043b\u0435\u0447\u044c\u0414\u0438\u0441\u043a \u0418\u0437\u0432\u043b\u0435\u0447\u044c\u0418\u043c\u044f\u0424\u0430\u0439\u043b\u0430 \u0418\u0437\u0432\u043b\u0435\u0447\u044c\u041f\u0443\u0442\u044c \u0418\u0437\u0432\u043b\u0435\u0447\u044c\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0418\u0437\u043c\u0414\u0430\u0442 \u0418\u0437\u043c\u0435\u043d\u0438\u0442\u044c\u0420\u0430\u0437\u043c\u0435\u0440\u041c\u0430\u0441\u0441\u0438\u0432\u0430 \u0418\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u0439\u041c\u0430\u0441\u0441\u0438\u0432\u0430 \u0418\u043c\u044f\u041e\u0440\u0433 \u0418\u043c\u044f\u041f\u043e\u043b\u044f\u0421\u043f\u0440 \u0418\u043d\u0434\u0435\u043a\u0441 \u0418\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u0418\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0418\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u0428\u0430\u0433 \u0418\u043d\u0442\u0435\u0440\u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0439\u0420\u0435\u0436\u0438\u043c \u0418\u0442\u043e\u0433\u0422\u0431\u043b\u0421\u043f\u0440 \u041a\u043e\u0434\u0412\u0438\u0434\u0412\u0435\u0434\u0421\u043f\u0440 \u041a\u043e\u0434\u0412\u0438\u0434\u0421\u043f\u0440\u041f\u043e\u0418\u0414 \u041a\u043e\u0434\u041f\u043eAnalit \u041a\u043e\u0434\u0421\u0438\u043c\u0432\u043e\u043b\u0430 \u041a\u043e\u0434\u0421\u043f\u0440 \u041a\u043e\u043b\u041f\u043e\u0434\u0441\u0442\u0440 \u041a\u043e\u043b\u041f\u0440\u043e\u043f \u041a\u043e\u043d\u041c\u0435\u0441 \u041a\u043e\u043d\u0441\u0442 \u041a\u043e\u043d\u0441\u0442\u0415\u0441\u0442\u044c \u041a\u043e\u043d\u0441\u0442\u0417\u043d\u0430\u0447 \u041a\u043e\u043d\u0422\u0440\u0430\u043d \u041a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0424\u0430\u0439\u043b \u041a\u043e\u043f\u0438\u044f\u0421\u0442\u0440 \u041a\u041f\u0435\u0440\u0438\u043e\u0434 \u041a\u0421\u0442\u0440\u0422\u0431\u043b\u0421\u043f\u0440 \u041c\u0430\u043a\u0441 \u041c\u0430\u043a\u0441\u0421\u0442\u0440\u0422\u0431\u043b\u0421\u043f\u0440 \u041c\u0430\u0441\u0441\u0438\u0432 \u041c\u0435\u043d\u044e \u041c\u0435\u043d\u044e\u0420\u0430\u0441\u0448 \u041c\u0438\u043d \u041d\u0430\u0431\u043e\u0440\u0414\u0430\u043d\u043d\u044b\u0445\u041d\u0430\u0439\u0442\u0438\u0420\u0430\u0441\u0448 \u041d\u0430\u0438\u043c\u0412\u0438\u0434\u0421\u043f\u0440 \u041d\u0430\u0438\u043c\u041f\u043eAnalit \u041d\u0430\u0438\u043c\u0421\u043f\u0440 \u041d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c\u041f\u0435\u0440\u0435\u0432\u043e\u0434\u044b\u0421\u0442\u0440\u043e\u043a \u041d\u0430\u0447\u041c\u0435\u0441 \u041d\u0430\u0447\u0422\u0440\u0430\u043d \u041d\u0438\u0436\u043d\u044f\u044f\u0413\u0440\u0430\u043d\u0438\u0446\u0430\u041c\u0430\u0441\u0441\u0438\u0432\u0430 \u041d\u043e\u043c\u0435\u0440\u0421\u043f\u0440 \u041d\u041f\u0435\u0440\u0438\u043e\u0434 \u041e\u043a\u043d\u043e \u041e\u043a\u0440 \u041e\u043a\u0440\u0443\u0436\u0435\u043d\u0438\u0435 \u041e\u0442\u043b\u0418\u043d\u0444\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u041e\u0442\u043b\u0418\u043d\u0444\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u041e\u0442\u0447\u0435\u0442 \u041e\u0442\u0447\u0435\u0442\u0410\u043d\u0430\u043b \u041e\u0442\u0447\u0435\u0442\u0418\u043d\u0442 \u041f\u0430\u043f\u043a\u0430\u0421\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442 \u041f\u0430\u0443\u0437\u0430 \u041f\u0412\u044b\u0431\u043e\u0440SQL \u041f\u0435\u0440\u0435\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u0442\u044c\u0424\u0430\u0439\u043b \u041f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0435 \u041f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0424\u0430\u0439\u043b \u041f\u043e\u0434\u0441\u0442\u0440 \u041f\u043e\u0438\u0441\u043a\u041f\u043e\u0434\u0441\u0442\u0440 \u041f\u043e\u0438\u0441\u043a\u0421\u0442\u0440 \u041f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0418\u0414\u0422\u0430\u0431\u043b\u0438\u0446\u044b \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0418\u0414 \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0418\u043c\u044f \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0421\u0442\u0430\u0442\u0443\u0441 \u041f\u0440\u0435\u0440\u0432\u0430\u0442\u044c \u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440 \u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0417\u043d\u0430\u0447 \u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c\u0423\u0441\u043b\u043e\u0432\u0438\u0435 \u0420\u0430\u0437\u0431\u0421\u0442\u0440 \u0420\u0430\u0437\u043d\u0412\u0440\u0435\u043c\u044f \u0420\u0430\u0437\u043d\u0414\u0430\u0442 \u0420\u0430\u0437\u043d\u0414\u0430\u0442\u0430\u0412\u0440\u0435\u043c\u044f \u0420\u0430\u0437\u043d\u0420\u0430\u0431\u0412\u0440\u0435\u043c\u044f \u0420\u0435\u0433\u0423\u0441\u0442\u0412\u0440\u0435\u043c \u0420\u0435\u0433\u0423\u0441\u0442\u0414\u0430\u0442 \u0420\u0435\u0433\u0423\u0441\u0442\u0427\u0441\u043b \u0420\u0435\u0434\u0422\u0435\u043a\u0441\u0442 \u0420\u0435\u0435\u0441\u0442\u0440\u0417\u0430\u043f\u0438\u0441\u044c \u0420\u0435\u0435\u0441\u0442\u0440\u0421\u043f\u0438\u0441\u043e\u043a\u0418\u043c\u0435\u043d\u041f\u0430\u0440\u0430\u043c \u0420\u0435\u0435\u0441\u0442\u0440\u0427\u0442\u0435\u043d\u0438\u0435 \u0420\u0435\u043a\u0432\u0421\u043f\u0440 \u0420\u0435\u043a\u0432\u0421\u043f\u0440\u041f\u0440 \u0421\u0435\u0433\u043e\u0434\u043d\u044f \u0421\u0435\u0439\u0447\u0430\u0441 \u0421\u0435\u0440\u0432\u0435\u0440 \u0421\u0435\u0440\u0432\u0435\u0440\u041f\u0440\u043e\u0446\u0435\u0441\u0441\u0418\u0414 \u0421\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0424\u0430\u0439\u043b\u0421\u0447\u0438\u0442\u0430\u0442\u044c \u0421\u0436\u041f\u0440\u043e\u0431 \u0421\u0438\u043c\u0432\u043e\u043b \u0421\u0438\u0441\u0442\u0435\u043c\u0430\u0414\u0438\u0440\u0435\u043a\u0442\u0443\u043c\u041a\u043e\u0434 \u0421\u0438\u0441\u0442\u0435\u043c\u0430\u0418\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0421\u0438\u0441\u0442\u0435\u043c\u0430\u041a\u043e\u0434 \u0421\u043e\u0434\u0435\u0440\u0436\u0438\u0442 \u0421\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u0421\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433\u0412\u044b\u0431\u043e\u0440\u0430\u0418\u0437\u0414\u0432\u0443\u0445\u0421\u043f\u0438\u0441\u043a\u043e\u0432 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433\u0412\u044b\u0431\u043e\u0440\u0430\u041f\u0430\u043f\u043a\u0438 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433\u041e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u0424\u0430\u0439\u043b\u0430 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f\u0424\u0430\u0439\u043b\u0430 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0417\u0430\u043f\u0440\u043e\u0441 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0418\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0418\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041a\u044d\u0448\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041c\u0430\u0441\u0441\u0438\u0432 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041d\u0430\u0431\u043e\u0440\u0414\u0430\u043d\u043d\u044b\u0445 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041e\u0431\u044a\u0435\u043a\u0442 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041e\u0442\u0447\u0435\u0442 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041f\u0430\u043f\u043a\u0443 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0420\u0435\u0434\u0430\u043a\u0442\u043e\u0440 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u043f\u0438\u0441\u043e\u043a \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u043f\u0438\u0441\u043e\u043a\u0421\u0442\u0440\u043e\u043a \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u0446\u0435\u043d\u0430\u0440\u0438\u0439 \u0421\u043e\u0437\u0434\u0421\u043f\u0440 \u0421\u043e\u0441\u0442\u0421\u043f\u0440 \u0421\u043e\u0445\u0440 \u0421\u043e\u0445\u0440\u0421\u043f\u0440 \u0421\u043f\u0438\u0441\u043e\u043a\u0421\u0438\u0441\u0442\u0435\u043c \u0421\u043f\u0440 \u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a \u0421\u043f\u0440\u0411\u043b\u043e\u043a\u0415\u0441\u0442\u044c \u0421\u043f\u0440\u0411\u043b\u043e\u043a\u0421\u043d\u044f\u0442\u044c \u0421\u043f\u0440\u0411\u043b\u043e\u043a\u0421\u043d\u044f\u0442\u044c\u0420\u0430\u0441\u0448 \u0421\u043f\u0440\u0411\u043b\u043e\u043a\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0421\u043f\u0440\u0418\u0437\u043c\u041d\u0430\u0431\u0414\u0430\u043d \u0421\u043f\u0440\u041a\u043e\u0434 \u0421\u043f\u0440\u041d\u043e\u043c\u0435\u0440 \u0421\u043f\u0440\u041e\u0431\u043d\u043e\u0432\u0438\u0442\u044c \u0421\u043f\u0440\u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0421\u043f\u0440\u041e\u0442\u043c\u0435\u043d\u0438\u0442\u044c \u0421\u043f\u0440\u041f\u0430\u0440\u0430\u043c \u0421\u043f\u0440\u041f\u043e\u043b\u0435\u0417\u043d\u0430\u0447 \u0421\u043f\u0440\u041f\u043e\u043b\u0435\u0418\u043c\u044f \u0421\u043f\u0440\u0420\u0435\u043a\u0432 \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u0412\u0432\u0435\u0434\u0417\u043d \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u041d\u043e\u0432\u044b\u0435 \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u041f\u0440 \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u041f\u0440\u0435\u0434\u0417\u043d \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u0420\u0435\u0436\u0438\u043c \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u0422\u0438\u043f\u0422\u0435\u043a\u0441\u0442 \u0421\u043f\u0440\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0421\u043f\u0440\u0421\u043e\u0441\u0442 \u0421\u043f\u0440\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u0421\u043f\u0440\u0422\u0431\u043b\u0418\u0442\u043e\u0433 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u041a\u043e\u043b \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u041c\u0430\u043a\u0441 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u041c\u0438\u043d \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u041f\u0440\u0435\u0434 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u0421\u043b\u0435\u0434 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u0421\u043e\u0437\u0434 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u0423\u0434 \u0421\u043f\u0440\u0422\u0435\u043a\u041f\u0440\u0435\u0434\u0441\u0442 \u0421\u043f\u0440\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0421\u0440\u0430\u0432\u043d\u0438\u0442\u044c\u0421\u0442\u0440 \u0421\u0442\u0440\u0412\u0435\u0440\u0445\u0420\u0435\u0433\u0438\u0441\u0442\u0440 \u0421\u0442\u0440\u041d\u0438\u0436\u043d\u0420\u0435\u0433\u0438\u0441\u0442\u0440 \u0421\u0442\u0440\u0422\u0431\u043b\u0421\u043f\u0440 \u0421\u0443\u043c\u041f\u0440\u043e\u043f \u0421\u0446\u0435\u043d\u0430\u0440\u0438\u0439 \u0421\u0446\u0435\u043d\u0430\u0440\u0438\u0439\u041f\u0430\u0440\u0430\u043c \u0422\u0435\u043a\u0412\u0435\u0440\u0441\u0438\u044f \u0422\u0435\u043a\u041e\u0440\u0433 \u0422\u043e\u0447\u043d \u0422\u0440\u0430\u043d \u0422\u0440\u0430\u043d\u0441\u043b\u0438\u0442\u0435\u0440\u0430\u0446\u0438\u044f \u0423\u0434\u0430\u043b\u0438\u0442\u044c\u0422\u0430\u0431\u043b\u0438\u0446\u0443 \u0423\u0434\u0430\u043b\u0438\u0442\u044c\u0424\u0430\u0439\u043b \u0423\u0434\u0421\u043f\u0440 \u0423\u0434\u0421\u0442\u0440\u0422\u0431\u043b\u0421\u043f\u0440 \u0423\u0441\u0442 \u0423\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438\u041a\u043e\u043d\u0441\u0442\u0430\u043d\u0442 \u0424\u0430\u0439\u043b\u0410\u0442\u0440\u0438\u0431\u0443\u0442\u0421\u0447\u0438\u0442\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0410\u0442\u0440\u0438\u0431\u0443\u0442\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0424\u0430\u0439\u043b\u0412\u0440\u0435\u043c\u044f \u0424\u0430\u0439\u043b\u0412\u0440\u0435\u043c\u044f\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0424\u0430\u0439\u043b\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0417\u0430\u043d\u044f\u0442 \u0424\u0430\u0439\u043b\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0418\u0441\u043a\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041c\u043e\u0436\u043d\u043e\u0427\u0438\u0442\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0424\u0430\u0439\u043b\u041f\u0435\u0440\u0435\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041f\u0435\u0440\u0435\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u0442\u044c \u0424\u0430\u0439\u043b\u041f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u0424\u0430\u0439\u043b\u0420\u0430\u0437\u043c\u0435\u0440 \u0424\u0430\u0439\u043b\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0421\u0441\u044b\u043b\u043a\u0430\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0421\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442 \u0424\u0430\u0439\u043b\u0421\u0447\u0438\u0442\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0424\u043c\u0442SQL\u0414\u0430\u0442 \u0424\u043c\u0442\u0414\u0430\u0442 \u0424\u043c\u0442\u0421\u0442\u0440 \u0424\u043c\u0442\u0427\u0441\u043b \u0424\u043e\u0440\u043c\u0430\u0442 \u0426\u041c\u0430\u0441\u0441\u0438\u0432\u042d\u043b\u0435\u043c\u0435\u043d\u0442 \u0426\u041d\u0430\u0431\u043e\u0440\u0414\u0430\u043d\u043d\u044b\u0445\u0420\u0435\u043a\u0432\u0438\u0437\u0438\u0442 \u0426\u041f\u043e\u0434\u0441\u0442\u0440 "
-},begin:e,end:"\\(",returnBegin:!0,excludeEnd:!0},I,A,T,_,O]},N,I,A,T,_,O]}}
-})());
-hljs.registerLanguage("java",(()=>{"use strict"
-;var e="\\.([0-9](_*[0-9])*)",n="[0-9a-fA-F](_*[0-9a-fA-F])*",a={
-className:"number",variants:[{
-begin:`(\\b([0-9](_*[0-9])*)((${e})|\\.)?|(${e}))[eE][+-]?([0-9](_*[0-9])*)[fFdD]?\\b`
-},{begin:`\\b([0-9](_*[0-9])*)((${e})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{
-begin:`(${e})[fFdD]?\\b`},{begin:"\\b([0-9](_*[0-9])*)[fFdD]\\b"},{
-begin:`\\b0[xX]((${n})\\.?|(${n})?\\.(${n}))[pP][+-]?([0-9](_*[0-9])*)[fFdD]?\\b`
-},{begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${n})[lL]?\\b`},{
-begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}],
-relevance:0};return e=>{
-var n="false synchronized int abstract float private char boolean var static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",s={
-className:"meta",begin:"@[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",
-contains:[{begin:/\(/,end:/\)/,contains:["self"]}]};const r=a;return{
-name:"Java",aliases:["jsp"],keywords:n,illegal:/<\/|#/,
-contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,
-relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{
-begin:/import java\.[a-z]+\./,keywords:"import",relevance:2
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{
-className:"class",beginKeywords:"class interface enum",end:/[{;=]/,
-excludeEnd:!0,relevance:1,keywords:"class interface enum",illegal:/[:"\[\]]/,
-contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{
-beginKeywords:"new throw return else",relevance:0},{className:"class",
-begin:"record\\s+"+e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,excludeEnd:!0,
-end:/[{;=]/,keywords:n,contains:[{beginKeywords:"record"},{
-begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,
-contains:[e.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,
-keywords:n,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE]
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"function",
-begin:"([\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*(<[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*(\\s*,\\s*[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*)*>)?\\s+)+"+e.UNDERSCORE_IDENT_RE+"\\s*\\(",
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:n,contains:[{
-begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,
-contains:[e.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,
-keywords:n,relevance:0,
-contains:[s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,e.C_BLOCK_COMMENT_MODE]
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},r,s]}}})());
-hljs.registerLanguage("javascript",(()=>{"use strict"
-;const e="[A-Za-z$_][0-9A-Za-z$_]*",n=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],a=["true","false","null","undefined","NaN","Infinity"],s=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"])
-;function r(e){return t("(?=",e,")")}function t(...e){return e.map((e=>{
-return(n=e)?"string"==typeof n?n:n.source:null;var n})).join("")}return i=>{
-const c=e,o={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/,
-isTrulyOpeningTag:(e,n)=>{const a=e[0].length+e.index,s=e.input[a]
-;"<"!==s?">"===s&&(((e,{after:n})=>{const a="</"+e[0].slice(1)
-;return-1!==e.input.indexOf(a,n)})(e,{after:a
-})||n.ignoreMatch()):n.ignoreMatch()}},l={$pattern:e,keyword:n,literal:a,
-built_in:s},g="\\.([0-9](_?[0-9])*)",b="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",d={
-className:"number",variants:[{
-begin:`(\\b(${b})((${g})|\\.)?|(${g}))[eE][+-]?([0-9](_?[0-9])*)\\b`},{
-begin:`\\b(${b})\\b((${g})\\b|\\.)?|(${g})\\b`},{
-begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{
-begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{
-begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{
-begin:"\\b0[0-7]+n?\\b"}],relevance:0},E={className:"subst",begin:"\\$\\{",
-end:"\\}",keywords:l,contains:[]},u={begin:"html`",end:"",starts:{end:"`",
-returnEnd:!1,contains:[i.BACKSLASH_ESCAPE,E],subLanguage:"xml"}},_={
-begin:"css`",end:"",starts:{end:"`",returnEnd:!1,
-contains:[i.BACKSLASH_ESCAPE,E],subLanguage:"css"}},m={className:"string",
-begin:"`",end:"`",contains:[i.BACKSLASH_ESCAPE,E]},y={className:"comment",
-variants:[i.COMMENT(/\/\*\*(?!\/)/,"\\*/",{relevance:0,contains:[{
-className:"doctag",begin:"@[A-Za-z]+",contains:[{className:"type",begin:"\\{",
-end:"\\}",relevance:0},{className:"variable",begin:c+"(?=\\s*(-)|$)",
-endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}]
-}),i.C_BLOCK_COMMENT_MODE,i.C_LINE_COMMENT_MODE]
-},N=[i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,u,_,m,d,i.REGEXP_MODE]
-;E.contains=N.concat({begin:/\{/,end:/\}/,keywords:l,contains:["self"].concat(N)
-});const A=[].concat(y,E.contains),f=A.concat([{begin:/\(/,end:/\)/,keywords:l,
-contains:["self"].concat(A)}]),p={className:"params",begin:/\(/,end:/\)/,
-excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f};return{name:"Javascript",
-aliases:["js","jsx","mjs","cjs"],keywords:l,exports:{PARAMS_CONTAINS:f},
-illegal:/#(?![$_A-z])/,contains:[i.SHEBANG({label:"shebang",binary:"node",
-relevance:5}),{label:"use_strict",className:"meta",relevance:10,
-begin:/^\s*['"]use (strict|asm)['"]/
-},i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,u,_,m,y,d,{
-begin:t(/[{,\n]\s*/,r(t(/(((\/\/.*$)|(\/\*(\*[^/]|[^*])*\*\/))\s*)*/,c+"\\s*:"))),
-relevance:0,contains:[{className:"attr",begin:c+r("\\s*:"),relevance:0}]},{
-begin:"("+i.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",
-keywords:"return throw case",contains:[y,i.REGEXP_MODE,{className:"function",
-begin:"(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+i.UNDERSCORE_IDENT_RE+")\\s*=>",
-returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{
-begin:i.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0
-},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f}]}]
-},{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{
-variants:[{begin:"<>",end:"</>"},{begin:o.begin,"on:begin":o.isTrulyOpeningTag,
-end:o.end}],subLanguage:"xml",contains:[{begin:o.begin,end:o.end,skip:!0,
-contains:["self"]}]}],relevance:0},{className:"function",
-beginKeywords:"function",end:/[{;]/,excludeEnd:!0,keywords:l,
-contains:["self",i.inherit(i.TITLE_MODE,{begin:c}),p],illegal:/%/},{
-beginKeywords:"while if switch catch for"},{className:"function",
-begin:i.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",
-returnBegin:!0,contains:[p,i.inherit(i.TITLE_MODE,{begin:c})]},{variants:[{
-begin:"\\."+c},{begin:"\\$"+c}],relevance:0},{className:"class",
-beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"[\]]/,contains:[{
-beginKeywords:"extends"},i.UNDERSCORE_TITLE_MODE]},{begin:/\b(?=constructor)/,
-end:/[{;]/,excludeEnd:!0,contains:[i.inherit(i.TITLE_MODE,{begin:c}),"self",p]
-},{begin:"(get|set)\\s+(?="+c+"\\()",end:/\{/,keywords:"get set",
-contains:[i.inherit(i.TITLE_MODE,{begin:c}),{begin:/\(\)/},p]},{begin:/\$[(.]/}]
-}}})());
-hljs.registerLanguage("jboss-cli",(()=>{"use strict";return e=>({
-name:"JBoss CLI",aliases:["wildfly-cli"],keywords:{$pattern:"[a-z-]+",
-keyword:"alias batch cd clear command connect connection-factory connection-info data-source deploy deployment-info deployment-overlay echo echo-dmr help history if jdbc-driver-info jms-queue|20 jms-topic|20 ls patch pwd quit read-attribute read-operation reload rollout-plan run-batch set shutdown try unalias undeploy unset version xa-data-source",
-literal:"true false"},contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,{
-className:"params",begin:/--[\w\-=\/]+/},{className:"function",
-begin:/:[\w\-.]+/,relevance:0},{className:"string",begin:/\B([\/.])[\w\-.\/=]+/
-},{className:"params",begin:/\(/,end:/\)/,contains:[{begin:/[\w-]+ *=/,
-returnBegin:!0,relevance:0,contains:[{className:"attr",begin:/[\w-]+/}]}],
-relevance:0}]})})());
-hljs.registerLanguage("json",(()=>{"use strict";return n=>{const e={
-literal:"true false null"
-},i=[n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE],a=[n.QUOTE_STRING_MODE,n.C_NUMBER_MODE],l={
-end:",",endsWithParent:!0,excludeEnd:!0,contains:a,keywords:e},t={begin:/\{/,
-end:/\}/,contains:[{className:"attr",begin:/"/,end:/"/,
-contains:[n.BACKSLASH_ESCAPE],illegal:"\\n"},n.inherit(l,{begin:/:/
-})].concat(i),illegal:"\\S"},s={begin:"\\[",end:"\\]",contains:[n.inherit(l)],
-illegal:"\\S"};return a.push(t,s),i.forEach((n=>{a.push(n)})),{name:"JSON",
-contains:a,keywords:e,illegal:"\\S"}}})());
-hljs.registerLanguage("julia",(()=>{"use strict";return e=>{
-var r="[A-Za-z_\\u00A1-\\uFFFF][A-Za-z_0-9\\u00A1-\\uFFFF]*",t={$pattern:r,
-keyword:["baremodule","begin","break","catch","ccall","const","continue","do","else","elseif","end","export","false","finally","for","function","global","if","import","in","isa","let","local","macro","module","quote","return","true","try","using","where","while"],
-literal:["ARGS","C_NULL","DEPOT_PATH","ENDIAN_BOM","ENV","Inf","Inf16","Inf32","Inf64","InsertionSort","LOAD_PATH","MergeSort","NaN","NaN16","NaN32","NaN64","PROGRAM_FILE","QuickSort","RoundDown","RoundFromZero","RoundNearest","RoundNearestTiesAway","RoundNearestTiesUp","RoundToZero","RoundUp","VERSION|0","devnull","false","im","missing","nothing","pi","stderr","stdin","stdout","true","undef","\u03c0","\u212f"],
-built_in:["AbstractArray","AbstractChannel","AbstractChar","AbstractDict","AbstractDisplay","AbstractFloat","AbstractIrrational","AbstractMatrix","AbstractRange","AbstractSet","AbstractString","AbstractUnitRange","AbstractVecOrMat","AbstractVector","Any","ArgumentError","Array","AssertionError","BigFloat","BigInt","BitArray","BitMatrix","BitSet","BitVector","Bool","BoundsError","CapturedException","CartesianIndex","CartesianIndices","Cchar","Cdouble","Cfloat","Channel","Char","Cint","Cintmax_t","Clong","Clonglong","Cmd","Colon","Complex","ComplexF16","ComplexF32","ComplexF64","CompositeException","Condition","Cptrdiff_t","Cshort","Csize_t","Cssize_t","Cstring","Cuchar","Cuint","Cuintmax_t","Culong","Culonglong","Cushort","Cvoid","Cwchar_t","Cwstring","DataType","DenseArray","DenseMatrix","DenseVecOrMat","DenseVector","Dict","DimensionMismatch","Dims","DivideError","DomainError","EOFError","Enum","ErrorException","Exception","ExponentialBackOff","Expr","Float16","Float32","Float64","Function","GlobalRef","HTML","IO","IOBuffer","IOContext","IOStream","IdDict","IndexCartesian","IndexLinear","IndexStyle","InexactError","InitError","Int","Int128","Int16","Int32","Int64","Int8","Integer","InterruptException","InvalidStateException","Irrational","KeyError","LinRange","LineNumberNode","LinearIndices","LoadError","MIME","Matrix","Method","MethodError","Missing","MissingException","Module","NTuple","NamedTuple","Nothing","Number","OrdinalRange","OutOfMemoryError","OverflowError","Pair","PartialQuickSort","PermutedDimsArray","Pipe","ProcessFailedException","Ptr","QuoteNode","Rational","RawFD","ReadOnlyMemoryError","Real","ReentrantLock","Ref","Regex","RegexMatch","RoundingMode","SegmentationFault","Set","Signed","Some","StackOverflowError","StepRange","StepRangeLen","StridedArray","StridedMatrix","StridedVecOrMat","StridedVector","String","StringIndexError","SubArray","SubString","SubstitutionString","Symbol","SystemError","Task","TaskFailedException","Text","TextDisplay","Timer","Tuple","Type","TypeError","TypeVar","UInt","UInt128","UInt16","UInt32","UInt64","UInt8","UndefInitializer","UndefKeywordError","UndefRefError","UndefVarError","Union","UnionAll","UnitRange","Unsigned","Val","Vararg","VecElement","VecOrMat","Vector","VersionNumber","WeakKeyDict","WeakRef"]
-},n={keywords:t,illegal:/<\//},a={className:"subst",begin:/\$\(/,end:/\)/,
-keywords:t},i={className:"variable",begin:"\\$"+r},o={className:"string",
-contains:[e.BACKSLASH_ESCAPE,a,i],variants:[{begin:/\w*"""/,end:/"""\w*/,
-relevance:10},{begin:/\w*"/,end:/"\w*/}]},s={className:"string",
-contains:[e.BACKSLASH_ESCAPE,a,i],begin:"`",end:"`"},l={className:"meta",
-begin:"@"+r};return n.name="Julia",n.contains=[{className:"number",
-begin:/(\b0x[\d_]*(\.[\d_]*)?|0x\.\d[\d_]*)p[-+]?\d+|\b0[box][a-fA-F0-9][a-fA-F0-9_]*|(\b\d[\d_]*(\.[\d_]*)?|\.\d[\d_]*)([eEfF][-+]?\d+)?/,
-relevance:0},{className:"string",begin:/'(.|\\[xXuU][a-zA-Z0-9]+)'/},o,s,l,{
-className:"comment",variants:[{begin:"#=",end:"=#",relevance:10},{begin:"#",
-end:"$"}]},e.HASH_COMMENT_MODE,{className:"keyword",
-begin:"\\b(((abstract|primitive)\\s+)type|(mutable\\s+)?struct)\\b"},{begin:/<:/
-}],a.contains=n.contains,n}})());
-hljs.registerLanguage("julia-repl",(()=>{"use strict";return a=>({
-name:"Julia REPL",contains:[{className:"meta",begin:/^julia>/,relevance:10,
-starts:{end:/^(?![ ]{6})/,subLanguage:"julia"},aliases:["jldoctest"]}]})})());
-hljs.registerLanguage("kotlin",(()=>{"use strict"
-;var e="\\.([0-9](_*[0-9])*)",n="[0-9a-fA-F](_*[0-9a-fA-F])*",a={
-className:"number",variants:[{
-begin:`(\\b([0-9](_*[0-9])*)((${e})|\\.)?|(${e}))[eE][+-]?([0-9](_*[0-9])*)[fFdD]?\\b`
-},{begin:`\\b([0-9](_*[0-9])*)((${e})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{
-begin:`(${e})[fFdD]?\\b`},{begin:"\\b([0-9](_*[0-9])*)[fFdD]\\b"},{
-begin:`\\b0[xX]((${n})\\.?|(${n})?\\.(${n}))[pP][+-]?([0-9](_*[0-9])*)[fFdD]?\\b`
-},{begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${n})[lL]?\\b`},{
-begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}],
-relevance:0};return e=>{const n={
-keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual",
-built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing",
-literal:"true false null"},i={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@"
-},s={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},t={
-className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},r={className:"string",
-variants:[{begin:'"""',end:'"""(?=[^"])',contains:[t,s]},{begin:"'",end:"'",
-illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/,
-contains:[e.BACKSLASH_ESCAPE,t,s]}]};s.contains.push(r);const l={
-className:"meta",
-begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?"
-},c={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/,
-end:/\)/,contains:[e.inherit(r,{className:"meta-string"})]}]
-},o=a,b=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),E={
-variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/,
-contains:[]}]},d=E;return d.variants[1].contains=[E],E.variants[1].contains=[d],
-{name:"Kotlin",aliases:["kt","kts"],keywords:n,
-contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag",
-begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,b,{className:"keyword",
-begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol",
-begin:/@\w+/}]}},i,l,c,{className:"function",beginKeywords:"fun",end:"[(]|$",
-returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{
-begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,
-contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin:/</,end:/>/,
-keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/,
-endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/,
-endsWithParent:!0,contains:[E,e.C_LINE_COMMENT_MODE,b],relevance:0
-},e.C_LINE_COMMENT_MODE,b,l,c,r,e.C_NUMBER_MODE]},b]},{className:"class",
-beginKeywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0,
-illegal:"extends implements",contains:[{
-beginKeywords:"public protected internal private constructor"
-},e.UNDERSCORE_TITLE_MODE,{className:"type",begin:/</,end:/>/,excludeBegin:!0,
-excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,]|$/,
-excludeBegin:!0,returnEnd:!0},l,c]},r,{className:"meta",begin:"^#!/usr/bin/env",
-end:"$",illegal:"\n"},o]}}})());
-hljs.registerLanguage("lasso",(()=>{"use strict";return e=>{
-const a="<\\?(lasso(script)?|=)",n="\\]|\\?>",r={
-$pattern:"[a-zA-Z_][\\w.]*|&[lg]t;",
-literal:"true false none minimal full all void and or not bw nbw ew new cn ncn lt lte gt gte eq neq rx nrx ft",
-built_in:"array date decimal duration integer map pair string tag xml null boolean bytes keyword list locale queue set stack staticarray local var variable global data self inherited currentcapture givenblock",
-keyword:"cache database_names database_schemanames database_tablenames define_tag define_type email_batch encode_set html_comment handle handle_error header if inline iterate ljax_target link link_currentaction link_currentgroup link_currentrecord link_detail link_firstgroup link_firstrecord link_lastgroup link_lastrecord link_nextgroup link_nextrecord link_prevgroup link_prevrecord log loop namespace_using output_none portal private protect records referer referrer repeating resultset rows search_args search_arguments select sort_args sort_arguments thread_atomic value_list while abort case else fail_if fail_ifnot fail if_empty if_false if_null if_true loop_abort loop_continue loop_count params params_up return return_value run_children soap_definetag soap_lastrequest soap_lastresponse tag_name ascending average by define descending do equals frozen group handle_failure import in into join let match max min on order parent protected provide public require returnhome skip split_thread sum take thread to trait type where with yield yieldhome"
-},t=e.COMMENT("\x3c!--","--\x3e",{relevance:0}),s={className:"meta",
-begin:"\\[noprocess\\]",starts:{end:"\\[/noprocess\\]",returnEnd:!0,contains:[t]
-}},i={className:"meta",begin:"\\[/noprocess|"+a
-},l=[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.inherit(e.C_NUMBER_MODE,{
-begin:e.C_NUMBER_RE+"|(-?infinity|NaN)\\b"}),e.inherit(e.APOS_STRING_MODE,{
-illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{
-className:"string",begin:"`",end:"`"},{variants:[{begin:"[#$][a-zA-Z_][\\w.]*"
-},{begin:"#",end:"\\d+",illegal:"\\W"}]},{className:"type",begin:"::\\s*",
-end:"[a-zA-Z_][\\w.]*",illegal:"\\W"},{className:"params",variants:[{
-begin:"-(?!infinity)[a-zA-Z_][\\w.]*",relevance:0},{begin:"(\\.\\.\\.)"}]},{
-begin:/(->|\.)\s*/,relevance:0,contains:[{className:"symbol",
-begin:"'[a-zA-Z_][\\w.]*'"}]},{className:"class",beginKeywords:"define",
-returnEnd:!0,end:"\\(|=>",contains:[e.inherit(e.TITLE_MODE,{
-begin:"[a-zA-Z_][\\w.]*(=(?!>))?|[-+*/%](?!>)"})]}];return{name:"Lasso",
-aliases:["ls","lassoscript"],case_insensitive:!0,keywords:r,contains:[{
-className:"meta",begin:n,relevance:0,starts:{end:"\\[|"+a,returnEnd:!0,
-relevance:0,contains:[t]}},s,i,{className:"meta",begin:"\\[no_square_brackets",
-starts:{end:"\\[/no_square_brackets\\]",keywords:r,contains:[{className:"meta",
-begin:n,relevance:0,starts:{end:"\\[noprocess\\]|"+a,returnEnd:!0,contains:[t]}
-},s,i].concat(l)}},{className:"meta",begin:"\\[",relevance:0},{className:"meta",
-begin:"^#!",end:"lasso9$",relevance:10}].concat(l)}}})());
-hljs.registerLanguage("latex",(()=>{"use strict";return e=>{const n=[{
-begin:/\^{6}[0-9a-f]{6}/},{begin:/\^{5}[0-9a-f]{5}/},{begin:/\^{4}[0-9a-f]{4}/
-},{begin:/\^{3}[0-9a-f]{3}/},{begin:/\^{2}[0-9a-f]{2}/},{
-begin:/\^{2}[\u0000-\u007f]/}],a=[{className:"keyword",begin:/\\/,relevance:0,
-contains:[{endsParent:!0,begin:((...e)=>"("+e.map((e=>{
-return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("|")+")")(...["(?:NeedsTeXFormat|RequirePackage|GetIdInfo)","Provides(?:Expl)?(?:Package|Class|File)","(?:DeclareOption|ProcessOptions)","(?:documentclass|usepackage|input|include)","makeat(?:letter|other)","ExplSyntax(?:On|Off)","(?:new|renew|provide)?command","(?:re)newenvironment","(?:New|Renew|Provide|Declare)(?:Expandable)?DocumentCommand","(?:New|Renew|Provide|Declare)DocumentEnvironment","(?:(?:e|g|x)?def|let)","(?:begin|end)","(?:part|chapter|(?:sub){0,2}section|(?:sub)?paragraph)","caption","(?:label|(?:eq|page|name)?ref|(?:paren|foot|super)?cite)","(?:alpha|beta|[Gg]amma|[Dd]elta|(?:var)?epsilon|zeta|eta|[Tt]heta|vartheta)","(?:iota|(?:var)?kappa|[Ll]ambda|mu|nu|[Xx]i|[Pp]i|varpi|(?:var)rho)","(?:[Ss]igma|varsigma|tau|[Uu]psilon|[Pp]hi|varphi|chi|[Pp]si|[Oo]mega)","(?:frac|sum|prod|lim|infty|times|sqrt|leq|geq|left|right|middle|[bB]igg?)","(?:[lr]angle|q?quad|[lcvdi]?dots|d?dot|hat|tilde|bar)"].map((e=>e+"(?![a-zA-Z@:_])")))
-},{endsParent:!0,
-begin:RegExp(["(?:__)?[a-zA-Z]{2,}_[a-zA-Z](?:_?[a-zA-Z])+:[a-zA-Z]*","[lgc]__?[a-zA-Z](?:_?[a-zA-Z])*_[a-zA-Z]{2,}","[qs]__?[a-zA-Z](?:_?[a-zA-Z])+","use(?:_i)?:[a-zA-Z]*","(?:else|fi|or):","(?:if|cs|exp):w","(?:hbox|vbox):n","::[a-zA-Z]_unbraced","::[a-zA-Z:]"].map((e=>e+"(?![a-zA-Z:_])")).join("|"))
-},{endsParent:!0,variants:n},{endsParent:!0,relevance:0,variants:[{
-begin:/[a-zA-Z@]+/},{begin:/[^a-zA-Z@]?/}]}]},{className:"params",relevance:0,
-begin:/#+\d?/},{variants:n},{className:"built_in",relevance:0,begin:/[$&^_]/},{
-className:"meta",begin:"% !TeX",end:"$",relevance:10},e.COMMENT("%","$",{
-relevance:0})],i={begin:/\{/,end:/\}/,relevance:0,contains:["self",...a]
-},t=e.inherit(i,{relevance:0,endsParent:!0,contains:[i,...a]}),r={begin:/\[/,
-end:/\]/,endsParent:!0,relevance:0,contains:[i,...a]},s={begin:/\s+/,relevance:0
-},c=[t],l=[r],o=(e,n)=>({contains:[s],starts:{relevance:0,contains:e,starts:n}
-}),d=(e,n)=>({begin:"\\\\"+e+"(?![a-zA-Z@:_])",keywords:{$pattern:/\\[a-zA-Z]+/,
-keyword:"\\"+e},relevance:0,contains:[s],starts:n}),g=(n,a)=>e.inherit({
-begin:"\\\\begin(?=[ \t]*(\\r?\\n[ \t]*)?\\{"+n+"\\})",keywords:{
-$pattern:/\\[a-zA-Z]+/,keyword:"\\begin"},relevance:0
-},o(c,a)),m=(n="string")=>e.END_SAME_AS_BEGIN({className:n,begin:/(.|\r?\n)/,
-end:/(.|\r?\n)/,excludeBegin:!0,excludeEnd:!0,endsParent:!0}),b=e=>({
-className:"string",end:"(?=\\\\end\\{"+e+"\\})"}),p=(e="string")=>({relevance:0,
-begin:/\{/,starts:{endsParent:!0,contains:[{className:e,end:/(?=\})/,
-endsParent:!0,contains:[{begin:/\{/,end:/\}/,relevance:0,contains:["self"]}]}]}
-});return{name:"LaTeX",aliases:["tex"],
-contains:[...["verb","lstinline"].map((e=>d(e,{contains:[m()]}))),d("mint",o(c,{
-contains:[m()]})),d("mintinline",o(c,{contains:[p(),m()]})),d("url",{
-contains:[p("link"),p("link")]}),d("hyperref",{contains:[p("link")]
-}),d("href",o(l,{contains:[p("link")]
-})),...[].concat(...["","\\*"].map((e=>[g("verbatim"+e,b("verbatim"+e)),g("filecontents"+e,o(c,b("filecontents"+e))),...["","B","L"].map((n=>g(n+"Verbatim"+e,o(l,b(n+"Verbatim"+e)))))]))),g("minted",o(l,o(c,b("minted")))),...a]
-}}})());
-hljs.registerLanguage("ldif",(()=>{"use strict";return e=>({name:"LDIF",
-contains:[{className:"attribute",begin:"^dn",end:": ",excludeEnd:!0,starts:{
-end:"$",relevance:0},relevance:10},{className:"attribute",begin:"^\\w",end:": ",
-excludeEnd:!0,starts:{end:"$",relevance:0}},{className:"literal",begin:"^-",
-end:"$"},e.HASH_COMMENT_MODE]})})());
-hljs.registerLanguage("leaf",(()=>{"use strict";return e=>({name:"Leaf",
-contains:[{className:"function",begin:"#+[A-Za-z_0-9]*\\(",end:/ \{/,
-returnBegin:!0,excludeEnd:!0,contains:[{className:"keyword",begin:"#+"},{
-className:"title",begin:"[A-Za-z_][A-Za-z_0-9]*"},{className:"params",
-begin:"\\(",end:"\\)",endsParent:!0,contains:[{className:"string",begin:'"',
-end:'"'},{className:"variable",begin:"[A-Za-z_][A-Za-z_0-9]*"}]}]}]})})());
-hljs.registerLanguage("less",(()=>{"use strict"
-;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],o=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],n=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse(),r=i.concat(o)
-;return a=>{const s=(e=>({IMPORTANT:{className:"meta",begin:"!important"},
-HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"},
-ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/,
-illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}
-}))(a),l=r,d="([\\w-]+|@\\{[\\w-]+\\})",c=[],g=[],b=e=>({className:"string",
-begin:"~?"+e+".*?"+e}),m=(e,t,i)=>({className:e,begin:t,relevance:i}),u={
-$pattern:/[a-z-]+/,keyword:"and or not only",attribute:t.join(" ")},p={
-begin:"\\(",end:"\\)",contains:g,keywords:u,relevance:0}
-;g.push(a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b("'"),b('"'),a.CSS_NUMBER_MODE,{
-begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]",
-excludeEnd:!0}
-},s.HEXCOLOR,p,m("variable","@@?[\\w-]+",10),m("variable","@\\{[\\w-]+\\}"),m("built_in","~?`[^`]*?`"),{
-className:"attribute",begin:"[\\w-]+\\s*:",end:":",returnBegin:!0,excludeEnd:!0
-},s.IMPORTANT);const f=g.concat({begin:/\{/,end:/\}/,contains:c}),h={
-beginKeywords:"when",endsWithParent:!0,contains:[{beginKeywords:"and not"
-}].concat(g)},w={begin:d+"\\s*:",returnBegin:!0,end:/[;}]/,relevance:0,
-contains:[{begin:/-(webkit|moz|ms|o)-/},{className:"attribute",
-begin:"\\b("+n.join("|")+")\\b",end:/(?=:)/,starts:{endsWithParent:!0,
-illegal:"[<=$]",relevance:0,contains:g}}]},v={className:"keyword",
-begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b",
-starts:{end:"[;{}]",keywords:u,returnEnd:!0,contains:g,relevance:0}},y={
-className:"variable",variants:[{begin:"@[\\w-]+\\s*:",relevance:15},{
-begin:"@[\\w-]+"}],starts:{end:"[;}]",returnEnd:!0,contains:f}},k={variants:[{
-begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:d,end:/\{/}],returnBegin:!0,
-returnEnd:!0,illegal:"[<='$\"]",relevance:0,
-contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,h,m("keyword","all\\b"),m("variable","@\\{[\\w-]+\\}"),{
-begin:"\\b("+e.join("|")+")\\b",className:"selector-tag"
-},m("selector-tag",d+"%?",0),m("selector-id","#"+d),m("selector-class","\\."+d,0),m("selector-tag","&",0),s.ATTRIBUTE_SELECTOR_MODE,{
-className:"selector-pseudo",begin:":("+i.join("|")+")"},{
-className:"selector-pseudo",begin:"::("+o.join("|")+")"},{begin:"\\(",end:"\\)",
-contains:f},{begin:"!important"}]},E={begin:`[\\w-]+:(:)?(${l.join("|")})`,
-returnBegin:!0,contains:[k]}
-;return c.push(a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,v,y,E,w,k),{
-name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:c}}})());
-hljs.registerLanguage("lisp",(()=>{"use strict";return e=>{
-var n="[a-zA-Z_\\-+\\*\\/<=>&#][a-zA-Z0-9_\\-+*\\/<=>&#!]*",a="\\|[^]*?\\|",i="(-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|-)?\\d+)?",s={
-className:"literal",begin:"\\b(t{1}|nil)\\b"},l={className:"number",variants:[{
-begin:i,relevance:0},{begin:"#(b|B)[0-1]+(/[0-1]+)?"},{
-begin:"#(o|O)[0-7]+(/[0-7]+)?"},{begin:"#(x|X)[0-9a-fA-F]+(/[0-9a-fA-F]+)?"},{
-begin:"#(c|C)\\("+i+" +"+i,end:"\\)"}]},b=e.inherit(e.QUOTE_STRING_MODE,{
-illegal:null}),g=e.COMMENT(";","$",{relevance:0}),r={begin:"\\*",end:"\\*"},t={
-className:"symbol",begin:"[:&]"+n},c={begin:n,relevance:0},d={begin:a},o={
-contains:[l,b,r,t,{begin:"\\(",end:"\\)",contains:["self",s,b,l,c]},c],
-variants:[{begin:"['`]\\(",end:"\\)"},{begin:"\\(quote ",end:"\\)",keywords:{
-name:"quote"}},{begin:"'"+a}]},v={variants:[{begin:"'"+n},{
-begin:"#'"+n+"(::"+n+")*"}]},m={begin:"\\(\\s*",end:"\\)"},u={endsWithParent:!0,
-relevance:0};return m.contains=[{className:"name",variants:[{begin:n,relevance:0
-},{begin:a}]},u],u.contains=[o,v,m,s,l,b,g,r,t,d,c],{name:"Lisp",illegal:/\S/,
-contains:[l,e.SHEBANG(),s,b,g,o,v,m,c]}}})());
-hljs.registerLanguage("livecodeserver",(()=>{"use strict";return e=>{const r={
-className:"variable",variants:[{
-begin:"\\b([gtps][A-Z]{1}[a-zA-Z0-9]*)(\\[.+\\])?(?:\\s*?)"},{begin:"\\$_[A-Z]+"
-}],relevance:0
-},t=[e.C_BLOCK_COMMENT_MODE,e.HASH_COMMENT_MODE,e.COMMENT("--","$"),e.COMMENT("[^:]//","$")],a=e.inherit(e.TITLE_MODE,{
-variants:[{begin:"\\b_*rig[A-Z][A-Za-z0-9_\\-]*"},{begin:"\\b_[a-z0-9\\-]+"}]
-}),o=e.inherit(e.TITLE_MODE,{begin:"\\b([A-Za-z0-9_\\-]+)\\b"});return{
-name:"LiveCode",case_insensitive:!1,keywords:{
-keyword:"$_COOKIE $_FILES $_GET $_GET_BINARY $_GET_RAW $_POST $_POST_BINARY $_POST_RAW $_SESSION $_SERVER codepoint codepoints segment segments codeunit codeunits sentence sentences trueWord trueWords paragraph after byte bytes english the until http forever descending using line real8 with seventh for stdout finally element word words fourth before black ninth sixth characters chars stderr uInt1 uInt1s uInt2 uInt2s stdin string lines relative rel any fifth items from middle mid at else of catch then third it file milliseconds seconds second secs sec int1 int1s int4 int4s internet int2 int2s normal text item last long detailed effective uInt4 uInt4s repeat end repeat URL in try into switch to words https token binfile each tenth as ticks tick system real4 by dateItems without char character ascending eighth whole dateTime numeric short first ftp integer abbreviated abbr abbrev private case while if div mod wrap and or bitAnd bitNot bitOr bitXor among not in a an within contains ends with begins the keys of keys",
-literal:"SIX TEN FORMFEED NINE ZERO NONE SPACE FOUR FALSE COLON CRLF PI COMMA ENDOFFILE EOF EIGHT FIVE QUOTE EMPTY ONE TRUE RETURN CR LINEFEED RIGHT BACKSLASH NULL SEVEN TAB THREE TWO six ten formfeed nine zero none space four false colon crlf pi comma endoffile eof eight five quote empty one true return cr linefeed right backslash null seven tab three two RIVERSION RISTATE FILE_READ_MODE FILE_WRITE_MODE FILE_WRITE_MODE DIR_WRITE_MODE FILE_READ_UMASK FILE_WRITE_UMASK DIR_READ_UMASK DIR_WRITE_UMASK",
-built_in:"put abs acos aliasReference annuity arrayDecode arrayEncode asin atan atan2 average avg avgDev base64Decode base64Encode baseConvert binaryDecode binaryEncode byteOffset byteToNum cachedURL cachedURLs charToNum cipherNames codepointOffset codepointProperty codepointToNum codeunitOffset commandNames compound compress constantNames cos date dateFormat decompress difference directories diskSpace DNSServers exp exp1 exp2 exp10 extents files flushEvents folders format functionNames geometricMean global globals hasMemory harmonicMean hostAddress hostAddressToName hostName hostNameToAddress isNumber ISOToMac itemOffset keys len length libURLErrorData libUrlFormData libURLftpCommand libURLLastHTTPHeaders libURLLastRHHeaders libUrlMultipartFormAddPart libUrlMultipartFormData libURLVersion lineOffset ln ln1 localNames log log2 log10 longFilePath lower macToISO matchChunk matchText matrixMultiply max md5Digest median merge messageAuthenticationCode messageDigest millisec millisecs millisecond milliseconds min monthNames nativeCharToNum normalizeText num number numToByte numToChar numToCodepoint numToNativeChar offset open openfiles openProcesses openProcessIDs openSockets paragraphOffset paramCount param params peerAddress pendingMessages platform popStdDev populationStandardDeviation populationVariance popVariance processID random randomBytes replaceText result revCreateXMLTree revCreateXMLTreeFromFile revCurrentRecord revCurrentRecordIsFirst revCurrentRecordIsLast revDatabaseColumnCount revDatabaseColumnIsNull revDatabaseColumnLengths revDatabaseColumnNames revDatabaseColumnNamed revDatabaseColumnNumbered revDatabaseColumnTypes revDatabaseConnectResult revDatabaseCursors revDatabaseID revDatabaseTableNames revDatabaseType revDataFromQuery revdb_closeCursor revdb_columnbynumber revdb_columncount revdb_columnisnull revdb_columnlengths revdb_columnnames revdb_columntypes revdb_commit revdb_connect revdb_connections revdb_connectionerr revdb_currentrecord revdb_cursorconnection revdb_cursorerr revdb_cursors revdb_dbtype revdb_disconnect revdb_execute revdb_iseof revdb_isbof revdb_movefirst revdb_movelast revdb_movenext revdb_moveprev revdb_query revdb_querylist revdb_recordcount revdb_rollback revdb_tablenames revGetDatabaseDriverPath revNumberOfRecords revOpenDatabase revOpenDatabases revQueryDatabase revQueryDatabaseBlob revQueryResult revQueryIsAtStart revQueryIsAtEnd revUnixFromMacPath revXMLAttribute revXMLAttributes revXMLAttributeValues revXMLChildContents revXMLChildNames revXMLCreateTreeFromFileWithNamespaces revXMLCreateTreeWithNamespaces revXMLDataFromXPathQuery revXMLEvaluateXPath revXMLFirstChild revXMLMatchingNode revXMLNextSibling revXMLNodeContents revXMLNumberOfChildren revXMLParent revXMLPreviousSibling revXMLRootNode revXMLRPC_CreateRequest revXMLRPC_Documents revXMLRPC_Error revXMLRPC_GetHost revXMLRPC_GetMethod revXMLRPC_GetParam revXMLText revXMLRPC_Execute revXMLRPC_GetParamCount revXMLRPC_GetParamNode revXMLRPC_GetParamType revXMLRPC_GetPath revXMLRPC_GetPort revXMLRPC_GetProtocol revXMLRPC_GetRequest revXMLRPC_GetResponse revXMLRPC_GetSocket revXMLTree revXMLTrees revXMLValidateDTD revZipDescribeItem revZipEnumerateItems revZipOpenArchives round sampVariance sec secs seconds sentenceOffset sha1Digest shell shortFilePath sin specialFolderPath sqrt standardDeviation statRound stdDev sum sysError systemVersion tan tempName textDecode textEncode tick ticks time to tokenOffset toLower toUpper transpose truewordOffset trunc uniDecode uniEncode upper URLDecode URLEncode URLStatus uuid value variableNames variance version waitDepth weekdayNames wordOffset xsltApplyStylesheet xsltApplyStylesheetFromFile xsltLoadStylesheet xsltLoadStylesheetFromFile add breakpoint cancel clear local variable file word line folder directory URL close socket process combine constant convert create new alias folder directory decrypt delete variable word line folder directory URL dispatch divide do encrypt filter get include intersect kill libURLDownloadToFile libURLFollowHttpRedirects libURLftpUpload libURLftpUploadFile libURLresetAll libUrlSetAuthCallback libURLSetDriver libURLSetCustomHTTPHeaders libUrlSetExpect100 libURLSetFTPListCommand libURLSetFTPMode libURLSetFTPStopTime libURLSetStatusCallback load extension loadedExtensions multiply socket prepare process post seek rel relative read from process rename replace require resetAll resolve revAddXMLNode revAppendXML revCloseCursor revCloseDatabase revCommitDatabase revCopyFile revCopyFolder revCopyXMLNode revDeleteFolder revDeleteXMLNode revDeleteAllXMLTrees revDeleteXMLTree revExecuteSQL revGoURL revInsertXMLNode revMoveFolder revMoveToFirstRecord revMoveToLastRecord revMoveToNextRecord revMoveToPreviousRecord revMoveToRecord revMoveXMLNode revPutIntoXMLNode revRollBackDatabase revSetDatabaseDriverPath revSetXMLAttribute revXMLRPC_AddParam revXMLRPC_DeleteAllDocuments revXMLAddDTD revXMLRPC_Free revXMLRPC_FreeAll revXMLRPC_DeleteDocument revXMLRPC_DeleteParam revXMLRPC_SetHost revXMLRPC_SetMethod revXMLRPC_SetPort revXMLRPC_SetProtocol revXMLRPC_SetSocket revZipAddItemWithData revZipAddItemWithFile revZipAddUncompressedItemWithData revZipAddUncompressedItemWithFile revZipCancel revZipCloseArchive revZipDeleteItem revZipExtractItemToFile revZipExtractItemToVariable revZipSetProgressCallback revZipRenameItem revZipReplaceItemWithData revZipReplaceItemWithFile revZipOpenArchive send set sort split start stop subtract symmetric union unload vectorDotProduct wait write"
-},contains:[r,{className:"keyword",begin:"\\bend\\sif\\b"},{
-className:"function",beginKeywords:"function",end:"$",
-contains:[r,o,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE,a]
-},{className:"function",begin:"\\bend\\s+",end:"$",keywords:"end",
-contains:[o,a],relevance:0},{beginKeywords:"command on",end:"$",
-contains:[r,o,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE,a]
-},{className:"meta",variants:[{begin:"<\\?(rev|lc|livecode)",relevance:10},{
-begin:"<\\?"},{begin:"\\?>"}]
-},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE,a].concat(t),
-illegal:";$|^\\[|^=|&|\\{"}}})());
-hljs.registerLanguage("livescript",(()=>{"use strict"
-;const e=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],n=["true","false","null","undefined","NaN","Infinity"],a=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"])
-;return t=>{const r={
-keyword:e.concat(["then","unless","until","loop","of","by","when","and","or","is","isnt","not","it","that","otherwise","from","to","til","fallthrough","case","enum","native","list","map","__hasProp","__extends","__slice","__bind","__indexOf"]),
-literal:n.concat(["yes","no","on","off","it","that","void"]),
-built_in:a.concat(["npm","print"])
-},i="[A-Za-z$_](?:-[0-9A-Za-z$_]|[0-9A-Za-z$_])*",s=t.inherit(t.TITLE_MODE,{
-begin:i}),o={className:"subst",begin:/#\{/,end:/\}/,keywords:r},c={
-className:"subst",begin:/#[A-Za-z$_]/,end:/(?:-[0-9A-Za-z$_]|[0-9A-Za-z$_])*/,
-keywords:r},l=[t.BINARY_NUMBER_MODE,{className:"number",
-begin:"(\\b0[xX][a-fA-F0-9_]+)|(\\b\\d(\\d|_\\d)*(\\.(\\d(\\d|_\\d)*)?)?(_*[eE]([-+]\\d(_\\d|\\d)*)?)?[_a-z]*)",
-relevance:0,starts:{end:"(\\s*/)?",relevance:0}},{className:"string",variants:[{
-begin:/'''/,end:/'''/,contains:[t.BACKSLASH_ESCAPE]},{begin:/'/,end:/'/,
-contains:[t.BACKSLASH_ESCAPE]},{begin:/"""/,end:/"""/,
-contains:[t.BACKSLASH_ESCAPE,o,c]},{begin:/"/,end:/"/,
-contains:[t.BACKSLASH_ESCAPE,o,c]},{begin:/\\/,end:/(\s|$)/,excludeEnd:!0}]},{
-className:"regexp",variants:[{begin:"//",end:"//[gim]*",
-contains:[o,t.HASH_COMMENT_MODE]},{
-begin:/\/(?![ *])(\\.|[^\\\n])*?\/[gim]*(?=\W)/}]},{begin:"@"+i},{begin:"``",
-end:"``",excludeBegin:!0,excludeEnd:!0,subLanguage:"javascript"}];o.contains=l
-;const d={className:"params",begin:"\\(",returnBegin:!0,contains:[{begin:/\(/,
-end:/\)/,keywords:r,contains:["self"].concat(l)}]};return{name:"LiveScript",
-aliases:["ls"],keywords:r,illegal:/\/\*/,
-contains:l.concat([t.COMMENT("\\/\\*","\\*\\/"),t.HASH_COMMENT_MODE,{
-begin:"(#=>|=>|\\|>>|-?->|!->)"},{className:"function",contains:[s,d],
-returnBegin:!0,variants:[{
-begin:"("+i+"\\s*(?:=|:=)\\s*)?(\\(.*\\)\\s*)?\\B->\\*?",end:"->\\*?"},{
-begin:"("+i+"\\s*(?:=|:=)\\s*)?!?(\\(.*\\)\\s*)?\\B[-~]{1,2}>\\*?",
-end:"[-~]{1,2}>\\*?"},{
-begin:"("+i+"\\s*(?:=|:=)\\s*)?(\\(.*\\)\\s*)?\\B!?[-~]{1,2}>\\*?",
-end:"!?[-~]{1,2}>\\*?"}]},{className:"class",beginKeywords:"class",end:"$",
-illegal:/[:="\[\]]/,contains:[{beginKeywords:"extends",endsWithParent:!0,
-illegal:/[:="\[\]]/,contains:[s]},s]},{begin:i+":",end:":",returnBegin:!0,
-returnEnd:!0,relevance:0}])}}})());
-hljs.registerLanguage("llvm",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a=/([-a-zA-Z$._][\w$.-]*)/,t={
-className:"variable",variants:[{begin:e(/%/,a)},{begin:/%\d+/},{begin:/#\d+/}]
-},i={className:"title",variants:[{begin:e(/@/,a)},{begin:/@\d+/},{begin:e(/!/,a)
-},{begin:e(/!\d+/,a)},{begin:/!\d+/}]};return{name:"LLVM IR",
-keywords:"begin end true false declare define global constant private linker_private internal available_externally linkonce linkonce_odr weak weak_odr appending dllimport dllexport common default hidden protected extern_weak external thread_local zeroinitializer undef null to tail target triple datalayout volatile nuw nsw nnan ninf nsz arcp fast exact inbounds align addrspace section alias module asm sideeffect gc dbg linker_private_weak attributes blockaddress initialexec localdynamic localexec prefix unnamed_addr ccc fastcc coldcc x86_stdcallcc x86_fastcallcc arm_apcscc arm_aapcscc arm_aapcs_vfpcc ptx_device ptx_kernel intel_ocl_bicc msp430_intrcc spir_func spir_kernel x86_64_sysvcc x86_64_win64cc x86_thiscallcc cc c signext zeroext inreg sret nounwind noreturn noalias nocapture byval nest readnone readonly inlinehint noinline alwaysinline optsize ssp sspreq noredzone noimplicitfloat naked builtin cold nobuiltin noduplicate nonlazybind optnone returns_twice sanitize_address sanitize_memory sanitize_thread sspstrong uwtable returned type opaque eq ne slt sgt sle sge ult ugt ule uge oeq one olt ogt ole oge ord uno ueq une x acq_rel acquire alignstack atomic catch cleanup filter inteldialect max min monotonic nand personality release seq_cst singlethread umax umin unordered xchg add fadd sub fsub mul fmul udiv sdiv fdiv urem srem frem shl lshr ashr and or xor icmp fcmp phi call trunc zext sext fptrunc fpext uitofp sitofp fptoui fptosi inttoptr ptrtoint bitcast addrspacecast select va_arg ret br switch invoke unwind unreachable indirectbr landingpad resume malloc alloca free load store getelementptr extractelement insertelement shufflevector getresult extractvalue insertvalue atomicrmw cmpxchg fence argmemonly double",
-contains:[{className:"type",begin:/\bi\d+(?=\s|\b)/},n.COMMENT(/;\s*$/,null,{
-relevance:0}),n.COMMENT(/;/,/$/),n.QUOTE_STRING_MODE,{className:"string",
-variants:[{begin:/"/,end:/[^\\]"/}]},i,{className:"punctuation",relevance:0,
-begin:/,/},{className:"operator",relevance:0,begin:/=/},t,{className:"symbol",
-variants:[{begin:/^\s*[a-z]+:/}],relevance:0},{className:"number",variants:[{
-begin:/0[xX][a-fA-F0-9]+/},{begin:/-?\d+(?:[.]\d+)?(?:[eE][-+]?\d+(?:[.]\d+)?)?/
-}],relevance:0}]}}})());
-hljs.registerLanguage("lsl",(()=>{"use strict";return E=>{var T={
-className:"number",relevance:0,begin:E.C_NUMBER_RE};return{
-name:"LSL (Linden Scripting Language)",illegal:":",contains:[{
-className:"string",begin:'"',end:'"',contains:[{className:"subst",
-begin:/\\[tn"\\]/}]},{className:"comment",
-variants:[E.COMMENT("//","$"),E.COMMENT("/\\*","\\*/")],relevance:0},T,{
-className:"section",variants:[{begin:"\\b(state|default)\\b"},{
-begin:"\\b(state_(entry|exit)|touch(_(start|end))?|(land_)?collision(_(start|end))?|timer|listen|(no_)?sensor|control|(not_)?at_(rot_)?target|money|email|experience_permissions(_denied)?|run_time_permissions|changed|attach|dataserver|moving_(start|end)|link_message|(on|object)_rez|remote_data|http_re(sponse|quest)|path_update|transaction_result)\\b"
-}]},{className:"built_in",
-begin:"\\b(ll(AgentInExperience|(Create|DataSize|Delete|KeyCount|Keys|Read|Update)KeyValue|GetExperience(Details|ErrorMessage)|ReturnObjectsBy(ID|Owner)|Json(2List|[GS]etValue|ValueType)|Sin|Cos|Tan|Atan2|Sqrt|Pow|Abs|Fabs|Frand|Floor|Ceil|Round|Vec(Mag|Norm|Dist)|Rot(Between|2(Euler|Fwd|Left|Up))|(Euler|Axes)2Rot|Whisper|(Region|Owner)?Say|Shout|Listen(Control|Remove)?|Sensor(Repeat|Remove)?|Detected(Name|Key|Owner|Type|Pos|Vel|Grab|Rot|Group|LinkNumber)|Die|Ground|Wind|([GS]et)(AnimationOverride|MemoryLimit|PrimMediaParams|ParcelMusicURL|Object(Desc|Name)|PhysicsMaterial|Status|Scale|Color|Alpha|Texture|Pos|Rot|Force|Torque)|ResetAnimationOverride|(Scale|Offset|Rotate)Texture|(Rot)?Target(Remove)?|(Stop)?MoveToTarget|Apply(Rotational)?Impulse|Set(KeyframedMotion|ContentType|RegionPos|(Angular)?Velocity|Buoyancy|HoverHeight|ForceAndTorque|TimerEvent|ScriptState|Damage|TextureAnim|Sound(Queueing|Radius)|Vehicle(Type|(Float|Vector|Rotation)Param)|(Touch|Sit)?Text|Camera(Eye|At)Offset|PrimitiveParams|ClickAction|Link(Alpha|Color|PrimitiveParams(Fast)?|Texture(Anim)?|Camera|Media)|RemoteScriptAccessPin|PayPrice|LocalRot)|ScaleByFactor|Get((Max|Min)ScaleFactor|ClosestNavPoint|StaticPath|SimStats|Env|PrimitiveParams|Link(PrimitiveParams|Number(OfSides)?|Key|Name|Media)|HTTPHeader|FreeURLs|Object(Details|PermMask|PrimCount)|Parcel(MaxPrims|Details|Prim(Count|Owners))|Attached(List)?|(SPMax|Free|Used)Memory|Region(Name|TimeDilation|FPS|Corner|AgentCount)|Root(Position|Rotation)|UnixTime|(Parcel|Region)Flags|(Wall|GMT)clock|SimulatorHostname|BoundingBox|GeometricCenter|Creator|NumberOf(Prims|NotecardLines|Sides)|Animation(List)?|(Camera|Local)(Pos|Rot)|Vel|Accel|Omega|Time(stamp|OfDay)|(Object|CenterOf)?Mass|MassMKS|Energy|Owner|(Owner)?Key|SunDirection|Texture(Offset|Scale|Rot)|Inventory(Number|Name|Key|Type|Creator|PermMask)|Permissions(Key)?|StartParameter|List(Length|EntryType)|Date|Agent(Size|Info|Language|List)|LandOwnerAt|NotecardLine|Script(Name|State))|(Get|Reset|GetAndReset)Time|PlaySound(Slave)?|LoopSound(Master|Slave)?|(Trigger|Stop|Preload)Sound|((Get|Delete)Sub|Insert)String|To(Upper|Lower)|Give(InventoryList|Money)|RezObject|(Stop)?LookAt|Sleep|CollisionFilter|(Take|Release)Controls|DetachFromAvatar|AttachToAvatar(Temp)?|InstantMessage|(GetNext)?Email|StopHover|MinEventDelay|RotLookAt|String(Length|Trim)|(Start|Stop)Animation|TargetOmega|Request(Experience)?Permissions|(Create|Break)Link|BreakAllLinks|(Give|Remove)Inventory|Water|PassTouches|Request(Agent|Inventory)Data|TeleportAgent(Home|GlobalCoords)?|ModifyLand|CollisionSound|ResetScript|MessageLinked|PushObject|PassCollisions|AxisAngle2Rot|Rot2(Axis|Angle)|A(cos|sin)|AngleBetween|AllowInventoryDrop|SubStringIndex|List2(CSV|Integer|Json|Float|String|Key|Vector|Rot|List(Strided)?)|DeleteSubList|List(Statistics|Sort|Randomize|(Insert|Find|Replace)List)|EdgeOfWorld|AdjustSoundVolume|Key2Name|TriggerSoundLimited|EjectFromLand|(CSV|ParseString)2List|OverMyLand|SameGroup|UnSit|Ground(Slope|Normal|Contour)|GroundRepel|(Set|Remove)VehicleFlags|SitOnLink|(AvatarOn)?(Link)?SitTarget|Script(Danger|Profiler)|Dialog|VolumeDetect|ResetOtherScript|RemoteLoadScriptPin|(Open|Close)RemoteDataChannel|SendRemoteData|RemoteDataReply|(Integer|String)ToBase64|XorBase64|Log(10)?|Base64To(String|Integer)|ParseStringKeepNulls|RezAtRoot|RequestSimulatorData|ForceMouselook|(Load|Release|(E|Une)scape)URL|ParcelMedia(CommandList|Query)|ModPow|MapDestination|(RemoveFrom|AddTo|Reset)Land(Pass|Ban)List|(Set|Clear)CameraParams|HTTP(Request|Response)|TextBox|DetectedTouch(UV|Face|Pos|(N|Bin)ormal|ST)|(MD5|SHA1|DumpList2)String|Request(Secure)?URL|Clear(Prim|Link)Media|(Link)?ParticleSystem|(Get|Request)(Username|DisplayName)|RegionSayTo|CastRay|GenerateKey|TransferLindenDollars|ManageEstateAccess|(Create|Delete)Character|ExecCharacterCmd|Evade|FleeFrom|NavigateTo|PatrolPoints|Pursue|UpdateCharacter|WanderWithin))\\b"
-},{className:"literal",variants:[{
-begin:"\\b(PI|TWO_PI|PI_BY_TWO|DEG_TO_RAD|RAD_TO_DEG|SQRT2)\\b"},{
-begin:"\\b(XP_ERROR_(EXPERIENCES_DISABLED|EXPERIENCE_(DISABLED|SUSPENDED)|INVALID_(EXPERIENCE|PARAMETERS)|KEY_NOT_FOUND|MATURITY_EXCEEDED|NONE|NOT_(FOUND|PERMITTED(_LAND)?)|NO_EXPERIENCE|QUOTA_EXCEEDED|RETRY_UPDATE|STORAGE_EXCEPTION|STORE_DISABLED|THROTTLED|UNKNOWN_ERROR)|JSON_APPEND|STATUS_(PHYSICS|ROTATE_[XYZ]|PHANTOM|SANDBOX|BLOCK_GRAB(_OBJECT)?|(DIE|RETURN)_AT_EDGE|CAST_SHADOWS|OK|MALFORMED_PARAMS|TYPE_MISMATCH|BOUNDS_ERROR|NOT_(FOUND|SUPPORTED)|INTERNAL_ERROR|WHITELIST_FAILED)|AGENT(_(BY_(LEGACY_|USER)NAME|FLYING|ATTACHMENTS|SCRIPTED|MOUSELOOK|SITTING|ON_OBJECT|AWAY|WALKING|IN_AIR|TYPING|CROUCHING|BUSY|ALWAYS_RUN|AUTOPILOT|LIST_(PARCEL(_OWNER)?|REGION)))?|CAMERA_(PITCH|DISTANCE|BEHINDNESS_(ANGLE|LAG)|(FOCUS|POSITION)(_(THRESHOLD|LOCKED|LAG))?|FOCUS_OFFSET|ACTIVE)|ANIM_ON|LOOP|REVERSE|PING_PONG|SMOOTH|ROTATE|SCALE|ALL_SIDES|LINK_(ROOT|SET|ALL_(OTHERS|CHILDREN)|THIS)|ACTIVE|PASS(IVE|_(ALWAYS|IF_NOT_HANDLED|NEVER))|SCRIPTED|CONTROL_(FWD|BACK|(ROT_)?(LEFT|RIGHT)|UP|DOWN|(ML_)?LBUTTON)|PERMISSION_(RETURN_OBJECTS|DEBIT|OVERRIDE_ANIMATIONS|SILENT_ESTATE_MANAGEMENT|TAKE_CONTROLS|TRIGGER_ANIMATION|ATTACH|CHANGE_LINKS|(CONTROL|TRACK)_CAMERA|TELEPORT)|INVENTORY_(TEXTURE|SOUND|OBJECT|SCRIPT|LANDMARK|CLOTHING|NOTECARD|BODYPART|ANIMATION|GESTURE|ALL|NONE)|CHANGED_(INVENTORY|COLOR|SHAPE|SCALE|TEXTURE|LINK|ALLOWED_DROP|OWNER|REGION(_START)?|TELEPORT|MEDIA)|OBJECT_(CLICK_ACTION|HOVER_HEIGHT|LAST_OWNER_ID|(PHYSICS|SERVER|STREAMING)_COST|UNKNOWN_DETAIL|CHARACTER_TIME|PHANTOM|PHYSICS|TEMP_(ATTACHED|ON_REZ)|NAME|DESC|POS|PRIM_(COUNT|EQUIVALENCE)|RETURN_(PARCEL(_OWNER)?|REGION)|REZZER_KEY|ROO?T|VELOCITY|OMEGA|OWNER|GROUP(_TAG)?|CREATOR|ATTACHED_(POINT|SLOTS_AVAILABLE)|RENDER_WEIGHT|(BODY_SHAPE|PATHFINDING)_TYPE|(RUNNING|TOTAL)_SCRIPT_COUNT|TOTAL_INVENTORY_COUNT|SCRIPT_(MEMORY|TIME))|TYPE_(INTEGER|FLOAT|STRING|KEY|VECTOR|ROTATION|INVALID)|(DEBUG|PUBLIC)_CHANNEL|ATTACH_(AVATAR_CENTER|CHEST|HEAD|BACK|PELVIS|MOUTH|CHIN|NECK|NOSE|BELLY|[LR](SHOULDER|HAND|FOOT|EAR|EYE|[UL](ARM|LEG)|HIP)|(LEFT|RIGHT)_PEC|HUD_(CENTER_[12]|TOP_(RIGHT|CENTER|LEFT)|BOTTOM(_(RIGHT|LEFT))?)|[LR]HAND_RING1|TAIL_(BASE|TIP)|[LR]WING|FACE_(JAW|[LR]EAR|[LR]EYE|TOUNGE)|GROIN|HIND_[LR]FOOT)|LAND_(LEVEL|RAISE|LOWER|SMOOTH|NOISE|REVERT)|DATA_(ONLINE|NAME|BORN|SIM_(POS|STATUS|RATING)|PAYINFO)|PAYMENT_INFO_(ON_FILE|USED)|REMOTE_DATA_(CHANNEL|REQUEST|REPLY)|PSYS_(PART_(BF_(ZERO|ONE(_MINUS_(DEST_COLOR|SOURCE_(ALPHA|COLOR)))?|DEST_COLOR|SOURCE_(ALPHA|COLOR))|BLEND_FUNC_(DEST|SOURCE)|FLAGS|(START|END)_(COLOR|ALPHA|SCALE|GLOW)|MAX_AGE|(RIBBON|WIND|INTERP_(COLOR|SCALE)|BOUNCE|FOLLOW_(SRC|VELOCITY)|TARGET_(POS|LINEAR)|EMISSIVE)_MASK)|SRC_(MAX_AGE|PATTERN|ANGLE_(BEGIN|END)|BURST_(RATE|PART_COUNT|RADIUS|SPEED_(MIN|MAX))|ACCEL|TEXTURE|TARGET_KEY|OMEGA|PATTERN_(DROP|EXPLODE|ANGLE(_CONE(_EMPTY)?)?)))|VEHICLE_(REFERENCE_FRAME|TYPE_(NONE|SLED|CAR|BOAT|AIRPLANE|BALLOON)|(LINEAR|ANGULAR)_(FRICTION_TIMESCALE|MOTOR_DIRECTION)|LINEAR_MOTOR_OFFSET|HOVER_(HEIGHT|EFFICIENCY|TIMESCALE)|BUOYANCY|(LINEAR|ANGULAR)_(DEFLECTION_(EFFICIENCY|TIMESCALE)|MOTOR_(DECAY_)?TIMESCALE)|VERTICAL_ATTRACTION_(EFFICIENCY|TIMESCALE)|BANKING_(EFFICIENCY|MIX|TIMESCALE)|FLAG_(NO_DEFLECTION_UP|LIMIT_(ROLL_ONLY|MOTOR_UP)|HOVER_((WATER|TERRAIN|UP)_ONLY|GLOBAL_HEIGHT)|MOUSELOOK_(STEER|BANK)|CAMERA_DECOUPLED))|PRIM_(ALLOW_UNSIT|ALPHA_MODE(_(BLEND|EMISSIVE|MASK|NONE))?|NORMAL|SPECULAR|TYPE(_(BOX|CYLINDER|PRISM|SPHERE|TORUS|TUBE|RING|SCULPT))?|HOLE_(DEFAULT|CIRCLE|SQUARE|TRIANGLE)|MATERIAL(_(STONE|METAL|GLASS|WOOD|FLESH|PLASTIC|RUBBER))?|SHINY_(NONE|LOW|MEDIUM|HIGH)|BUMP_(NONE|BRIGHT|DARK|WOOD|BARK|BRICKS|CHECKER|CONCRETE|TILE|STONE|DISKS|GRAVEL|BLOBS|SIDING|LARGETILE|STUCCO|SUCTION|WEAVE)|TEXGEN_(DEFAULT|PLANAR)|SCRIPTED_SIT_ONLY|SCULPT_(TYPE_(SPHERE|TORUS|PLANE|CYLINDER|MASK)|FLAG_(MIRROR|INVERT))|PHYSICS(_(SHAPE_(CONVEX|NONE|PRIM|TYPE)))?|(POS|ROT)_LOCAL|SLICE|TEXT|FLEXIBLE|POINT_LIGHT|TEMP_ON_REZ|PHANTOM|POSITION|SIT_TARGET|SIZE|ROTATION|TEXTURE|NAME|OMEGA|DESC|LINK_TARGET|COLOR|BUMP_SHINY|FULLBRIGHT|TEXGEN|GLOW|MEDIA_(ALT_IMAGE_ENABLE|CONTROLS|(CURRENT|HOME)_URL|AUTO_(LOOP|PLAY|SCALE|ZOOM)|FIRST_CLICK_INTERACT|(WIDTH|HEIGHT)_PIXELS|WHITELIST(_ENABLE)?|PERMS_(INTERACT|CONTROL)|PARAM_MAX|CONTROLS_(STANDARD|MINI)|PERM_(NONE|OWNER|GROUP|ANYONE)|MAX_(URL_LENGTH|WHITELIST_(SIZE|COUNT)|(WIDTH|HEIGHT)_PIXELS)))|MASK_(BASE|OWNER|GROUP|EVERYONE|NEXT)|PERM_(TRANSFER|MODIFY|COPY|MOVE|ALL)|PARCEL_(MEDIA_COMMAND_(STOP|PAUSE|PLAY|LOOP|TEXTURE|URL|TIME|AGENT|UNLOAD|AUTO_ALIGN|TYPE|SIZE|DESC|LOOP_SET)|FLAG_(ALLOW_(FLY|(GROUP_)?SCRIPTS|LANDMARK|TERRAFORM|DAMAGE|CREATE_(GROUP_)?OBJECTS)|USE_(ACCESS_(GROUP|LIST)|BAN_LIST|LAND_PASS_LIST)|LOCAL_SOUND_ONLY|RESTRICT_PUSHOBJECT|ALLOW_(GROUP|ALL)_OBJECT_ENTRY)|COUNT_(TOTAL|OWNER|GROUP|OTHER|SELECTED|TEMP)|DETAILS_(NAME|DESC|OWNER|GROUP|AREA|ID|SEE_AVATARS))|LIST_STAT_(MAX|MIN|MEAN|MEDIAN|STD_DEV|SUM(_SQUARES)?|NUM_COUNT|GEOMETRIC_MEAN|RANGE)|PAY_(HIDE|DEFAULT)|REGION_FLAG_(ALLOW_DAMAGE|FIXED_SUN|BLOCK_TERRAFORM|SANDBOX|DISABLE_(COLLISIONS|PHYSICS)|BLOCK_FLY|ALLOW_DIRECT_TELEPORT|RESTRICT_PUSHOBJECT)|HTTP_(METHOD|MIMETYPE|BODY_(MAXLENGTH|TRUNCATED)|CUSTOM_HEADER|PRAGMA_NO_CACHE|VERBOSE_THROTTLE|VERIFY_CERT)|SIT_(INVALID_(AGENT|LINK_OBJECT)|NO(T_EXPERIENCE|_(ACCESS|EXPERIENCE_PERMISSION|SIT_TARGET)))|STRING_(TRIM(_(HEAD|TAIL))?)|CLICK_ACTION_(NONE|TOUCH|SIT|BUY|PAY|OPEN(_MEDIA)?|PLAY|ZOOM)|TOUCH_INVALID_FACE|PROFILE_(NONE|SCRIPT_MEMORY)|RC_(DATA_FLAGS|DETECT_PHANTOM|GET_(LINK_NUM|NORMAL|ROOT_KEY)|MAX_HITS|REJECT_(TYPES|AGENTS|(NON)?PHYSICAL|LAND))|RCERR_(CAST_TIME_EXCEEDED|SIM_PERF_LOW|UNKNOWN)|ESTATE_ACCESS_(ALLOWED_(AGENT|GROUP)_(ADD|REMOVE)|BANNED_AGENT_(ADD|REMOVE))|DENSITY|FRICTION|RESTITUTION|GRAVITY_MULTIPLIER|KFM_(COMMAND|CMD_(PLAY|STOP|PAUSE)|MODE|FORWARD|LOOP|PING_PONG|REVERSE|DATA|ROTATION|TRANSLATION)|ERR_(GENERIC|PARCEL_PERMISSIONS|MALFORMED_PARAMS|RUNTIME_PERMISSIONS|THROTTLED)|CHARACTER_(CMD_((SMOOTH_)?STOP|JUMP)|DESIRED_(TURN_)?SPEED|RADIUS|STAY_WITHIN_PARCEL|LENGTH|ORIENTATION|ACCOUNT_FOR_SKIPPED_FRAMES|AVOIDANCE_MODE|TYPE(_([ABCD]|NONE))?|MAX_(DECEL|TURN_RADIUS|(ACCEL|SPEED)))|PURSUIT_(OFFSET|FUZZ_FACTOR|GOAL_TOLERANCE|INTERCEPT)|REQUIRE_LINE_OF_SIGHT|FORCE_DIRECT_PATH|VERTICAL|HORIZONTAL|AVOID_(CHARACTERS|DYNAMIC_OBSTACLES|NONE)|PU_(EVADE_(HIDDEN|SPOTTED)|FAILURE_(DYNAMIC_PATHFINDING_DISABLED|INVALID_(GOAL|START)|NO_(NAVMESH|VALID_DESTINATION)|OTHER|TARGET_GONE|(PARCEL_)?UNREACHABLE)|(GOAL|SLOWDOWN_DISTANCE)_REACHED)|TRAVERSAL_TYPE(_(FAST|NONE|SLOW))?|CONTENT_TYPE_(ATOM|FORM|HTML|JSON|LLSD|RSS|TEXT|XHTML|XML)|GCNP_(RADIUS|STATIC)|(PATROL|WANDER)_PAUSE_AT_WAYPOINTS|OPT_(AVATAR|CHARACTER|EXCLUSION_VOLUME|LEGACY_LINKSET|MATERIAL_VOLUME|OTHER|STATIC_OBSTACLE|WALKABLE)|SIM_STAT_PCT_CHARS_STEPPED)\\b"
-},{begin:"\\b(FALSE|TRUE)\\b"},{begin:"\\b(ZERO_ROTATION)\\b"},{
-begin:"\\b(EOF|JSON_(ARRAY|DELETE|FALSE|INVALID|NULL|NUMBER|OBJECT|STRING|TRUE)|NULL_KEY|TEXTURE_(BLANK|DEFAULT|MEDIA|PLYWOOD|TRANSPARENT)|URL_REQUEST_(GRANTED|DENIED))\\b"
-},{begin:"\\b(ZERO_VECTOR|TOUCH_INVALID_(TEXCOORD|VECTOR))\\b"}]},{
-className:"type",
-begin:"\\b(integer|float|string|key|vector|quaternion|rotation|list)\\b"}]}}
-})());
-hljs.registerLanguage("lua",(()=>{"use strict";return e=>{
-const t="\\[=*\\[",a="\\]=*\\]",n={begin:t,end:a,contains:["self"]
-},o=[e.COMMENT("--(?!\\[=*\\[)","$"),e.COMMENT("--\\[=*\\[",a,{contains:[n],
-relevance:10})];return{name:"Lua",keywords:{$pattern:e.UNDERSCORE_IDENT_RE,
-literal:"true false nil",
-keyword:"and break do else elseif end for goto if in local not or repeat return then until while",
-built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove"
-},contains:o.concat([{className:"function",beginKeywords:"function",end:"\\)",
-contains:[e.inherit(e.TITLE_MODE,{
-begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params",
-begin:"\\(",endsWithParent:!0,contains:o}].concat(o)
-},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string",
-begin:t,end:a,contains:[n],relevance:5}])}}})());
-hljs.registerLanguage("makefile",(()=>{"use strict";return e=>{const i={
-className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)",
-contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%<?\^\+\*]/}]},a={className:"string",
-begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,i]},n={className:"variable",
-begin:/\$\([\w-]+\s/,end:/\)/,keywords:{
-built_in:"subst patsubst strip findstring filter filter-out sort word wordlist firstword lastword dir notdir suffix basename addsuffix addprefix join wildcard realpath abspath error warning shell origin flavor foreach if or and call eval file value"
-},contains:[i]},s={begin:"^"+e.UNDERSCORE_IDENT_RE+"\\s*(?=[:+?]?=)"},r={
-className:"section",begin:/^[^\s]+:/,end:/$/,contains:[i]};return{
-name:"Makefile",aliases:["mk","mak","make"],keywords:{$pattern:/[\w-]+/,
-keyword:"define endef undefine ifdef ifndef ifeq ifneq else endif include -include sinclude override export unexport private vpath"
-},contains:[e.HASH_COMMENT_MODE,i,a,n,s,{className:"meta",begin:/^\.PHONY:/,
-end:/$/,keywords:{$pattern:/[\.\w]+/,"meta-keyword":".PHONY"}},r]}}})());
-hljs.registerLanguage("mathematica",(()=>{"use strict"
-;const e=["AASTriangle","AbelianGroup","Abort","AbortKernels","AbortProtect","AbortScheduledTask","Above","Abs","AbsArg","AbsArgPlot","Absolute","AbsoluteCorrelation","AbsoluteCorrelationFunction","AbsoluteCurrentValue","AbsoluteDashing","AbsoluteFileName","AbsoluteOptions","AbsolutePointSize","AbsoluteThickness","AbsoluteTime","AbsoluteTiming","AcceptanceThreshold","AccountingForm","Accumulate","Accuracy","AccuracyGoal","ActionDelay","ActionMenu","ActionMenuBox","ActionMenuBoxOptions","Activate","Active","ActiveClassification","ActiveClassificationObject","ActiveItem","ActivePrediction","ActivePredictionObject","ActiveStyle","AcyclicGraphQ","AddOnHelpPath","AddSides","AddTo","AddToSearchIndex","AddUsers","AdjacencyGraph","AdjacencyList","AdjacencyMatrix","AdjacentMeshCells","AdjustmentBox","AdjustmentBoxOptions","AdjustTimeSeriesForecast","AdministrativeDivisionData","AffineHalfSpace","AffineSpace","AffineStateSpaceModel","AffineTransform","After","AggregatedEntityClass","AggregationLayer","AircraftData","AirportData","AirPressureData","AirTemperatureData","AiryAi","AiryAiPrime","AiryAiZero","AiryBi","AiryBiPrime","AiryBiZero","AlgebraicIntegerQ","AlgebraicNumber","AlgebraicNumberDenominator","AlgebraicNumberNorm","AlgebraicNumberPolynomial","AlgebraicNumberTrace","AlgebraicRules","AlgebraicRulesData","Algebraics","AlgebraicUnitQ","Alignment","AlignmentMarker","AlignmentPoint","All","AllowAdultContent","AllowedCloudExtraParameters","AllowedCloudParameterExtensions","AllowedDimensions","AllowedFrequencyRange","AllowedHeads","AllowGroupClose","AllowIncomplete","AllowInlineCells","AllowKernelInitialization","AllowLooseGrammar","AllowReverseGroupClose","AllowScriptLevelChange","AllowVersionUpdate","AllTrue","Alphabet","AlphabeticOrder","AlphabeticSort","AlphaChannel","AlternateImage","AlternatingFactorial","AlternatingGroup","AlternativeHypothesis","Alternatives","AltitudeMethod","AmbientLight","AmbiguityFunction","AmbiguityList","Analytic","AnatomyData","AnatomyForm","AnatomyPlot3D","AnatomySkinStyle","AnatomyStyling","AnchoredSearch","And","AndersonDarlingTest","AngerJ","AngleBisector","AngleBracket","AnglePath","AnglePath3D","AngleVector","AngularGauge","Animate","AnimationCycleOffset","AnimationCycleRepetitions","AnimationDirection","AnimationDisplayTime","AnimationRate","AnimationRepetitions","AnimationRunning","AnimationRunTime","AnimationTimeIndex","Animator","AnimatorBox","AnimatorBoxOptions","AnimatorElements","Annotate","Annotation","AnnotationDelete","AnnotationKeys","AnnotationRules","AnnotationValue","Annuity","AnnuityDue","Annulus","AnomalyDetection","AnomalyDetector","AnomalyDetectorFunction","Anonymous","Antialiasing","AntihermitianMatrixQ","Antisymmetric","AntisymmetricMatrixQ","Antonyms","AnyOrder","AnySubset","AnyTrue","Apart","ApartSquareFree","APIFunction","Appearance","AppearanceElements","AppearanceRules","AppellF1","Append","AppendCheck","AppendLayer","AppendTo","Apply","ApplySides","ArcCos","ArcCosh","ArcCot","ArcCoth","ArcCsc","ArcCsch","ArcCurvature","ARCHProcess","ArcLength","ArcSec","ArcSech","ArcSin","ArcSinDistribution","ArcSinh","ArcTan","ArcTanh","Area","Arg","ArgMax","ArgMin","ArgumentCountQ","ARIMAProcess","ArithmeticGeometricMean","ARMAProcess","Around","AroundReplace","ARProcess","Array","ArrayComponents","ArrayDepth","ArrayFilter","ArrayFlatten","ArrayMesh","ArrayPad","ArrayPlot","ArrayQ","ArrayResample","ArrayReshape","ArrayRules","Arrays","Arrow","Arrow3DBox","ArrowBox","Arrowheads","ASATriangle","Ask","AskAppend","AskConfirm","AskDisplay","AskedQ","AskedValue","AskFunction","AskState","AskTemplateDisplay","AspectRatio","AspectRatioFixed","Assert","AssociateTo","Association","AssociationFormat","AssociationMap","AssociationQ","AssociationThread","AssumeDeterministic","Assuming","Assumptions","AstronomicalData","Asymptotic","AsymptoticDSolveValue","AsymptoticEqual","AsymptoticEquivalent","AsymptoticGreater","AsymptoticGreaterEqual","AsymptoticIntegrate","AsymptoticLess","AsymptoticLessEqual","AsymptoticOutputTracker","AsymptoticProduct","AsymptoticRSolveValue","AsymptoticSolve","AsymptoticSum","Asynchronous","AsynchronousTaskObject","AsynchronousTasks","Atom","AtomCoordinates","AtomCount","AtomDiagramCoordinates","AtomList","AtomQ","AttentionLayer","Attributes","Audio","AudioAmplify","AudioAnnotate","AudioAnnotationLookup","AudioBlockMap","AudioCapture","AudioChannelAssignment","AudioChannelCombine","AudioChannelMix","AudioChannels","AudioChannelSeparate","AudioData","AudioDelay","AudioDelete","AudioDevice","AudioDistance","AudioEncoding","AudioFade","AudioFrequencyShift","AudioGenerator","AudioIdentify","AudioInputDevice","AudioInsert","AudioInstanceQ","AudioIntervals","AudioJoin","AudioLabel","AudioLength","AudioLocalMeasurements","AudioLooping","AudioLoudness","AudioMeasurements","AudioNormalize","AudioOutputDevice","AudioOverlay","AudioPad","AudioPan","AudioPartition","AudioPause","AudioPitchShift","AudioPlay","AudioPlot","AudioQ","AudioRecord","AudioReplace","AudioResample","AudioReverb","AudioReverse","AudioSampleRate","AudioSpectralMap","AudioSpectralTransformation","AudioSplit","AudioStop","AudioStream","AudioStreams","AudioTimeStretch","AudioTracks","AudioTrim","AudioType","AugmentedPolyhedron","AugmentedSymmetricPolynomial","Authenticate","Authentication","AuthenticationDialog","AutoAction","Autocomplete","AutocompletionFunction","AutoCopy","AutocorrelationTest","AutoDelete","AutoEvaluateEvents","AutoGeneratedPackage","AutoIndent","AutoIndentSpacings","AutoItalicWords","AutoloadPath","AutoMatch","Automatic","AutomaticImageSize","AutoMultiplicationSymbol","AutoNumberFormatting","AutoOpenNotebooks","AutoOpenPalettes","AutoQuoteCharacters","AutoRefreshed","AutoRemove","AutorunSequencing","AutoScaling","AutoScroll","AutoSpacing","AutoStyleOptions","AutoStyleWords","AutoSubmitting","Axes","AxesEdge","AxesLabel","AxesOrigin","AxesStyle","AxiomaticTheory","Axis","BabyMonsterGroupB","Back","Background","BackgroundAppearance","BackgroundTasksSettings","Backslash","Backsubstitution","Backward","Ball","Band","BandpassFilter","BandstopFilter","BarabasiAlbertGraphDistribution","BarChart","BarChart3D","BarcodeImage","BarcodeRecognize","BaringhausHenzeTest","BarLegend","BarlowProschanImportance","BarnesG","BarOrigin","BarSpacing","BartlettHannWindow","BartlettWindow","BaseDecode","BaseEncode","BaseForm","Baseline","BaselinePosition","BaseStyle","BasicRecurrentLayer","BatchNormalizationLayer","BatchSize","BatesDistribution","BattleLemarieWavelet","BayesianMaximization","BayesianMaximizationObject","BayesianMinimization","BayesianMinimizationObject","Because","BeckmannDistribution","Beep","Before","Begin","BeginDialogPacket","BeginFrontEndInteractionPacket","BeginPackage","BellB","BellY","Below","BenfordDistribution","BeniniDistribution","BenktanderGibratDistribution","BenktanderWeibullDistribution","BernoulliB","BernoulliDistribution","BernoulliGraphDistribution","BernoulliProcess","BernsteinBasis","BesselFilterModel","BesselI","BesselJ","BesselJZero","BesselK","BesselY","BesselYZero","Beta","BetaBinomialDistribution","BetaDistribution","BetaNegativeBinomialDistribution","BetaPrimeDistribution","BetaRegularized","Between","BetweennessCentrality","BeveledPolyhedron","BezierCurve","BezierCurve3DBox","BezierCurve3DBoxOptions","BezierCurveBox","BezierCurveBoxOptions","BezierFunction","BilateralFilter","Binarize","BinaryDeserialize","BinaryDistance","BinaryFormat","BinaryImageQ","BinaryRead","BinaryReadList","BinarySerialize","BinaryWrite","BinCounts","BinLists","Binomial","BinomialDistribution","BinomialProcess","BinormalDistribution","BiorthogonalSplineWavelet","BipartiteGraphQ","BiquadraticFilterModel","BirnbaumImportance","BirnbaumSaundersDistribution","BitAnd","BitClear","BitGet","BitLength","BitNot","BitOr","BitSet","BitShiftLeft","BitShiftRight","BitXor","BiweightLocation","BiweightMidvariance","Black","BlackmanHarrisWindow","BlackmanNuttallWindow","BlackmanWindow","Blank","BlankForm","BlankNullSequence","BlankSequence","Blend","Block","BlockchainAddressData","BlockchainBase","BlockchainBlockData","BlockchainContractValue","BlockchainData","BlockchainGet","BlockchainKeyEncode","BlockchainPut","BlockchainTokenData","BlockchainTransaction","BlockchainTransactionData","BlockchainTransactionSign","BlockchainTransactionSubmit","BlockMap","BlockRandom","BlomqvistBeta","BlomqvistBetaTest","Blue","Blur","BodePlot","BohmanWindow","Bold","Bond","BondCount","BondList","BondQ","Bookmarks","Boole","BooleanConsecutiveFunction","BooleanConvert","BooleanCountingFunction","BooleanFunction","BooleanGraph","BooleanMaxterms","BooleanMinimize","BooleanMinterms","BooleanQ","BooleanRegion","Booleans","BooleanStrings","BooleanTable","BooleanVariables","BorderDimensions","BorelTannerDistribution","Bottom","BottomHatTransform","BoundaryDiscretizeGraphics","BoundaryDiscretizeRegion","BoundaryMesh","BoundaryMeshRegion","BoundaryMeshRegionQ","BoundaryStyle","BoundedRegionQ","BoundingRegion","Bounds","Box","BoxBaselineShift","BoxData","BoxDimensions","Boxed","Boxes","BoxForm","BoxFormFormatTypes","BoxFrame","BoxID","BoxMargins","BoxMatrix","BoxObject","BoxRatios","BoxRotation","BoxRotationPoint","BoxStyle","BoxWhiskerChart","Bra","BracketingBar","BraKet","BrayCurtisDistance","BreadthFirstScan","Break","BridgeData","BrightnessEqualize","BroadcastStationData","Brown","BrownForsytheTest","BrownianBridgeProcess","BrowserCategory","BSplineBasis","BSplineCurve","BSplineCurve3DBox","BSplineCurve3DBoxOptions","BSplineCurveBox","BSplineCurveBoxOptions","BSplineFunction","BSplineSurface","BSplineSurface3DBox","BSplineSurface3DBoxOptions","BubbleChart","BubbleChart3D","BubbleScale","BubbleSizes","BuildingData","BulletGauge","BusinessDayQ","ButterflyGraph","ButterworthFilterModel","Button","ButtonBar","ButtonBox","ButtonBoxOptions","ButtonCell","ButtonContents","ButtonData","ButtonEvaluator","ButtonExpandable","ButtonFrame","ButtonFunction","ButtonMargins","ButtonMinHeight","ButtonNote","ButtonNotebook","ButtonSource","ButtonStyle","ButtonStyleMenuListing","Byte","ByteArray","ByteArrayFormat","ByteArrayQ","ByteArrayToString","ByteCount","ByteOrdering","C","CachedValue","CacheGraphics","CachePersistence","CalendarConvert","CalendarData","CalendarType","Callout","CalloutMarker","CalloutStyle","CallPacket","CanberraDistance","Cancel","CancelButton","CandlestickChart","CanonicalGraph","CanonicalizePolygon","CanonicalizePolyhedron","CanonicalName","CanonicalWarpingCorrespondence","CanonicalWarpingDistance","CantorMesh","CantorStaircase","Cap","CapForm","CapitalDifferentialD","Capitalize","CapsuleShape","CaptureRunning","CardinalBSplineBasis","CarlemanLinearize","CarmichaelLambda","CaseOrdering","Cases","CaseSensitive","Cashflow","Casoratian","Catalan","CatalanNumber","Catch","CategoricalDistribution","Catenate","CatenateLayer","CauchyDistribution","CauchyWindow","CayleyGraph","CDF","CDFDeploy","CDFInformation","CDFWavelet","Ceiling","CelestialSystem","Cell","CellAutoOverwrite","CellBaseline","CellBoundingBox","CellBracketOptions","CellChangeTimes","CellContents","CellContext","CellDingbat","CellDynamicExpression","CellEditDuplicate","CellElementsBoundingBox","CellElementSpacings","CellEpilog","CellEvaluationDuplicate","CellEvaluationFunction","CellEvaluationLanguage","CellEventActions","CellFrame","CellFrameColor","CellFrameLabelMargins","CellFrameLabels","CellFrameMargins","CellGroup","CellGroupData","CellGrouping","CellGroupingRules","CellHorizontalScrolling","CellID","CellLabel","CellLabelAutoDelete","CellLabelMargins","CellLabelPositioning","CellLabelStyle","CellLabelTemplate","CellMargins","CellObject","CellOpen","CellPrint","CellProlog","Cells","CellSize","CellStyle","CellTags","CellularAutomaton","CensoredDistribution","Censoring","Center","CenterArray","CenterDot","CentralFeature","CentralMoment","CentralMomentGeneratingFunction","Cepstrogram","CepstrogramArray","CepstrumArray","CForm","ChampernowneNumber","ChangeOptions","ChannelBase","ChannelBrokerAction","ChannelDatabin","ChannelHistoryLength","ChannelListen","ChannelListener","ChannelListeners","ChannelListenerWait","ChannelObject","ChannelPreSendFunction","ChannelReceiverFunction","ChannelSend","ChannelSubscribers","ChanVeseBinarize","Character","CharacterCounts","CharacterEncoding","CharacterEncodingsPath","CharacteristicFunction","CharacteristicPolynomial","CharacterName","CharacterNormalize","CharacterRange","Characters","ChartBaseStyle","ChartElementData","ChartElementDataFunction","ChartElementFunction","ChartElements","ChartLabels","ChartLayout","ChartLegends","ChartStyle","Chebyshev1FilterModel","Chebyshev2FilterModel","ChebyshevDistance","ChebyshevT","ChebyshevU","Check","CheckAbort","CheckAll","Checkbox","CheckboxBar","CheckboxBox","CheckboxBoxOptions","ChemicalData","ChessboardDistance","ChiDistribution","ChineseRemainder","ChiSquareDistribution","ChoiceButtons","ChoiceDialog","CholeskyDecomposition","Chop","ChromaticityPlot","ChromaticityPlot3D","ChromaticPolynomial","Circle","CircleBox","CircleDot","CircleMinus","CirclePlus","CirclePoints","CircleThrough","CircleTimes","CirculantGraph","CircularOrthogonalMatrixDistribution","CircularQuaternionMatrixDistribution","CircularRealMatrixDistribution","CircularSymplecticMatrixDistribution","CircularUnitaryMatrixDistribution","Circumsphere","CityData","ClassifierFunction","ClassifierInformation","ClassifierMeasurements","ClassifierMeasurementsObject","Classify","ClassPriors","Clear","ClearAll","ClearAttributes","ClearCookies","ClearPermissions","ClearSystemCache","ClebschGordan","ClickPane","Clip","ClipboardNotebook","ClipFill","ClippingStyle","ClipPlanes","ClipPlanesStyle","ClipRange","Clock","ClockGauge","ClockwiseContourIntegral","Close","Closed","CloseKernels","ClosenessCentrality","Closing","ClosingAutoSave","ClosingEvent","ClosingSaveDialog","CloudAccountData","CloudBase","CloudConnect","CloudConnections","CloudDeploy","CloudDirectory","CloudDisconnect","CloudEvaluate","CloudExport","CloudExpression","CloudExpressions","CloudFunction","CloudGet","CloudImport","CloudLoggingData","CloudObject","CloudObjectInformation","CloudObjectInformationData","CloudObjectNameFormat","CloudObjects","CloudObjectURLType","CloudPublish","CloudPut","CloudRenderingMethod","CloudSave","CloudShare","CloudSubmit","CloudSymbol","CloudUnshare","CloudUserID","ClusterClassify","ClusterDissimilarityFunction","ClusteringComponents","ClusteringTree","CMYKColor","Coarse","CodeAssistOptions","Coefficient","CoefficientArrays","CoefficientDomain","CoefficientList","CoefficientRules","CoifletWavelet","Collect","Colon","ColonForm","ColorBalance","ColorCombine","ColorConvert","ColorCoverage","ColorData","ColorDataFunction","ColorDetect","ColorDistance","ColorFunction","ColorFunctionScaling","Colorize","ColorNegate","ColorOutput","ColorProfileData","ColorQ","ColorQuantize","ColorReplace","ColorRules","ColorSelectorSettings","ColorSeparate","ColorSetter","ColorSetterBox","ColorSetterBoxOptions","ColorSlider","ColorsNear","ColorSpace","ColorToneMapping","Column","ColumnAlignments","ColumnBackgrounds","ColumnForm","ColumnLines","ColumnsEqual","ColumnSpacings","ColumnWidths","CombinedEntityClass","CombinerFunction","CometData","CommonDefaultFormatTypes","Commonest","CommonestFilter","CommonName","CommonUnits","CommunityBoundaryStyle","CommunityGraphPlot","CommunityLabels","CommunityRegionStyle","CompanyData","CompatibleUnitQ","CompilationOptions","CompilationTarget","Compile","Compiled","CompiledCodeFunction","CompiledFunction","CompilerOptions","Complement","ComplementedEntityClass","CompleteGraph","CompleteGraphQ","CompleteKaryTree","CompletionsListPacket","Complex","ComplexContourPlot","Complexes","ComplexExpand","ComplexInfinity","ComplexityFunction","ComplexListPlot","ComplexPlot","ComplexPlot3D","ComplexRegionPlot","ComplexStreamPlot","ComplexVectorPlot","ComponentMeasurements","ComponentwiseContextMenu","Compose","ComposeList","ComposeSeries","CompositeQ","Composition","CompoundElement","CompoundExpression","CompoundPoissonDistribution","CompoundPoissonProcess","CompoundRenewalProcess","Compress","CompressedData","CompressionLevel","ComputeUncertainty","Condition","ConditionalExpression","Conditioned","Cone","ConeBox","ConfidenceLevel","ConfidenceRange","ConfidenceTransform","ConfigurationPath","ConformAudio","ConformImages","Congruent","ConicHullRegion","ConicHullRegion3DBox","ConicHullRegionBox","ConicOptimization","Conjugate","ConjugateTranspose","Conjunction","Connect","ConnectedComponents","ConnectedGraphComponents","ConnectedGraphQ","ConnectedMeshComponents","ConnectedMoleculeComponents","ConnectedMoleculeQ","ConnectionSettings","ConnectLibraryCallbackFunction","ConnectSystemModelComponents","ConnesWindow","ConoverTest","ConsoleMessage","ConsoleMessagePacket","Constant","ConstantArray","ConstantArrayLayer","ConstantImage","ConstantPlusLayer","ConstantRegionQ","Constants","ConstantTimesLayer","ConstellationData","ConstrainedMax","ConstrainedMin","Construct","Containing","ContainsAll","ContainsAny","ContainsExactly","ContainsNone","ContainsOnly","ContentFieldOptions","ContentLocationFunction","ContentObject","ContentPadding","ContentsBoundingBox","ContentSelectable","ContentSize","Context","ContextMenu","Contexts","ContextToFileName","Continuation","Continue","ContinuedFraction","ContinuedFractionK","ContinuousAction","ContinuousMarkovProcess","ContinuousTask","ContinuousTimeModelQ","ContinuousWaveletData","ContinuousWaveletTransform","ContourDetect","ContourGraphics","ContourIntegral","ContourLabels","ContourLines","ContourPlot","ContourPlot3D","Contours","ContourShading","ContourSmoothing","ContourStyle","ContraharmonicMean","ContrastiveLossLayer","Control","ControlActive","ControlAlignment","ControlGroupContentsBox","ControllabilityGramian","ControllabilityMatrix","ControllableDecomposition","ControllableModelQ","ControllerDuration","ControllerInformation","ControllerInformationData","ControllerLinking","ControllerManipulate","ControllerMethod","ControllerPath","ControllerState","ControlPlacement","ControlsRendering","ControlType","Convergents","ConversionOptions","ConversionRules","ConvertToBitmapPacket","ConvertToPostScript","ConvertToPostScriptPacket","ConvexHullMesh","ConvexPolygonQ","ConvexPolyhedronQ","ConvolutionLayer","Convolve","ConwayGroupCo1","ConwayGroupCo2","ConwayGroupCo3","CookieFunction","Cookies","CoordinateBoundingBox","CoordinateBoundingBoxArray","CoordinateBounds","CoordinateBoundsArray","CoordinateChartData","CoordinatesToolOptions","CoordinateTransform","CoordinateTransformData","CoprimeQ","Coproduct","CopulaDistribution","Copyable","CopyDatabin","CopyDirectory","CopyFile","CopyTag","CopyToClipboard","CornerFilter","CornerNeighbors","Correlation","CorrelationDistance","CorrelationFunction","CorrelationTest","Cos","Cosh","CoshIntegral","CosineDistance","CosineWindow","CosIntegral","Cot","Coth","Count","CountDistinct","CountDistinctBy","CounterAssignments","CounterBox","CounterBoxOptions","CounterClockwiseContourIntegral","CounterEvaluator","CounterFunction","CounterIncrements","CounterStyle","CounterStyleMenuListing","CountRoots","CountryData","Counts","CountsBy","Covariance","CovarianceEstimatorFunction","CovarianceFunction","CoxianDistribution","CoxIngersollRossProcess","CoxModel","CoxModelFit","CramerVonMisesTest","CreateArchive","CreateCellID","CreateChannel","CreateCloudExpression","CreateDatabin","CreateDataStructure","CreateDataSystemModel","CreateDialog","CreateDirectory","CreateDocument","CreateFile","CreateIntermediateDirectories","CreateManagedLibraryExpression","CreateNotebook","CreatePacletArchive","CreatePalette","CreatePalettePacket","CreatePermissionsGroup","CreateScheduledTask","CreateSearchIndex","CreateSystemModel","CreateTemporary","CreateUUID","CreateWindow","CriterionFunction","CriticalityFailureImportance","CriticalitySuccessImportance","CriticalSection","Cross","CrossEntropyLossLayer","CrossingCount","CrossingDetect","CrossingPolygon","CrossMatrix","Csc","Csch","CTCLossLayer","Cube","CubeRoot","Cubics","Cuboid","CuboidBox","Cumulant","CumulantGeneratingFunction","Cup","CupCap","Curl","CurlyDoubleQuote","CurlyQuote","CurrencyConvert","CurrentDate","CurrentImage","CurrentlySpeakingPacket","CurrentNotebookImage","CurrentScreenImage","CurrentValue","Curry","CurryApplied","CurvatureFlowFilter","CurveClosed","Cyan","CycleGraph","CycleIndexPolynomial","Cycles","CyclicGroup","Cyclotomic","Cylinder","CylinderBox","CylindricalDecomposition","D","DagumDistribution","DamData","DamerauLevenshteinDistance","DampingFactor","Darker","Dashed","Dashing","DatabaseConnect","DatabaseDisconnect","DatabaseReference","Databin","DatabinAdd","DatabinRemove","Databins","DatabinUpload","DataCompression","DataDistribution","DataRange","DataReversed","Dataset","DatasetDisplayPanel","DataStructure","DataStructureQ","Date","DateBounds","Dated","DateDelimiters","DateDifference","DatedUnit","DateFormat","DateFunction","DateHistogram","DateInterval","DateList","DateListLogPlot","DateListPlot","DateListStepPlot","DateObject","DateObjectQ","DateOverlapsQ","DatePattern","DatePlus","DateRange","DateReduction","DateString","DateTicksFormat","DateValue","DateWithinQ","DaubechiesWavelet","DavisDistribution","DawsonF","DayCount","DayCountConvention","DayHemisphere","DaylightQ","DayMatchQ","DayName","DayNightTerminator","DayPlus","DayRange","DayRound","DeBruijnGraph","DeBruijnSequence","Debug","DebugTag","Decapitalize","Decimal","DecimalForm","DeclareKnownSymbols","DeclarePackage","Decompose","DeconvolutionLayer","Decrement","Decrypt","DecryptFile","DedekindEta","DeepSpaceProbeData","Default","DefaultAxesStyle","DefaultBaseStyle","DefaultBoxStyle","DefaultButton","DefaultColor","DefaultControlPlacement","DefaultDuplicateCellStyle","DefaultDuration","DefaultElement","DefaultFaceGridsStyle","DefaultFieldHintStyle","DefaultFont","DefaultFontProperties","DefaultFormatType","DefaultFormatTypeForStyle","DefaultFrameStyle","DefaultFrameTicksStyle","DefaultGridLinesStyle","DefaultInlineFormatType","DefaultInputFormatType","DefaultLabelStyle","DefaultMenuStyle","DefaultNaturalLanguage","DefaultNewCellStyle","DefaultNewInlineCellStyle","DefaultNotebook","DefaultOptions","DefaultOutputFormatType","DefaultPrintPrecision","DefaultStyle","DefaultStyleDefinitions","DefaultTextFormatType","DefaultTextInlineFormatType","DefaultTicksStyle","DefaultTooltipStyle","DefaultValue","DefaultValues","Defer","DefineExternal","DefineInputStreamMethod","DefineOutputStreamMethod","DefineResourceFunction","Definition","Degree","DegreeCentrality","DegreeGraphDistribution","DegreeLexicographic","DegreeReverseLexicographic","DEigensystem","DEigenvalues","Deinitialization","Del","DelaunayMesh","Delayed","Deletable","Delete","DeleteAnomalies","DeleteBorderComponents","DeleteCases","DeleteChannel","DeleteCloudExpression","DeleteContents","DeleteDirectory","DeleteDuplicates","DeleteDuplicatesBy","DeleteFile","DeleteMissing","DeleteObject","DeletePermissionsKey","DeleteSearchIndex","DeleteSmallComponents","DeleteStopwords","DeleteWithContents","DeletionWarning","DelimitedArray","DelimitedSequence","Delimiter","DelimiterFlashTime","DelimiterMatching","Delimiters","DeliveryFunction","Dendrogram","Denominator","DensityGraphics","DensityHistogram","DensityPlot","DensityPlot3D","DependentVariables","Deploy","Deployed","Depth","DepthFirstScan","Derivative","DerivativeFilter","DerivedKey","DescriptorStateSpace","DesignMatrix","DestroyAfterEvaluation","Det","DeviceClose","DeviceConfigure","DeviceExecute","DeviceExecuteAsynchronous","DeviceObject","DeviceOpen","DeviceOpenQ","DeviceRead","DeviceReadBuffer","DeviceReadLatest","DeviceReadList","DeviceReadTimeSeries","Devices","DeviceStreams","DeviceWrite","DeviceWriteBuffer","DGaussianWavelet","DiacriticalPositioning","Diagonal","DiagonalizableMatrixQ","DiagonalMatrix","DiagonalMatrixQ","Dialog","DialogIndent","DialogInput","DialogLevel","DialogNotebook","DialogProlog","DialogReturn","DialogSymbols","Diamond","DiamondMatrix","DiceDissimilarity","DictionaryLookup","DictionaryWordQ","DifferenceDelta","DifferenceOrder","DifferenceQuotient","DifferenceRoot","DifferenceRootReduce","Differences","DifferentialD","DifferentialRoot","DifferentialRootReduce","DifferentiatorFilter","DigitalSignature","DigitBlock","DigitBlockMinimum","DigitCharacter","DigitCount","DigitQ","DihedralAngle","DihedralGroup","Dilation","DimensionalCombinations","DimensionalMeshComponents","DimensionReduce","DimensionReducerFunction","DimensionReduction","Dimensions","DiracComb","DiracDelta","DirectedEdge","DirectedEdges","DirectedGraph","DirectedGraphQ","DirectedInfinity","Direction","Directive","Directory","DirectoryName","DirectoryQ","DirectoryStack","DirichletBeta","DirichletCharacter","DirichletCondition","DirichletConvolve","DirichletDistribution","DirichletEta","DirichletL","DirichletLambda","DirichletTransform","DirichletWindow","DisableConsolePrintPacket","DisableFormatting","DiscreteAsymptotic","DiscreteChirpZTransform","DiscreteConvolve","DiscreteDelta","DiscreteHadamardTransform","DiscreteIndicator","DiscreteLimit","DiscreteLQEstimatorGains","DiscreteLQRegulatorGains","DiscreteLyapunovSolve","DiscreteMarkovProcess","DiscreteMaxLimit","DiscreteMinLimit","DiscretePlot","DiscretePlot3D","DiscreteRatio","DiscreteRiccatiSolve","DiscreteShift","DiscreteTimeModelQ","DiscreteUniformDistribution","DiscreteVariables","DiscreteWaveletData","DiscreteWaveletPacketTransform","DiscreteWaveletTransform","DiscretizeGraphics","DiscretizeRegion","Discriminant","DisjointQ","Disjunction","Disk","DiskBox","DiskMatrix","DiskSegment","Dispatch","DispatchQ","DispersionEstimatorFunction","Display","DisplayAllSteps","DisplayEndPacket","DisplayFlushImagePacket","DisplayForm","DisplayFunction","DisplayPacket","DisplayRules","DisplaySetSizePacket","DisplayString","DisplayTemporary","DisplayWith","DisplayWithRef","DisplayWithVariable","DistanceFunction","DistanceMatrix","DistanceTransform","Distribute","Distributed","DistributedContexts","DistributeDefinitions","DistributionChart","DistributionDomain","DistributionFitTest","DistributionParameterAssumptions","DistributionParameterQ","Dithering","Div","Divergence","Divide","DivideBy","Dividers","DivideSides","Divisible","Divisors","DivisorSigma","DivisorSum","DMSList","DMSString","Do","DockedCells","DocumentGenerator","DocumentGeneratorInformation","DocumentGeneratorInformationData","DocumentGenerators","DocumentNotebook","DocumentWeightingRules","Dodecahedron","DomainRegistrationInformation","DominantColors","DOSTextFormat","Dot","DotDashed","DotEqual","DotLayer","DotPlusLayer","Dotted","DoubleBracketingBar","DoubleContourIntegral","DoubleDownArrow","DoubleLeftArrow","DoubleLeftRightArrow","DoubleLeftTee","DoubleLongLeftArrow","DoubleLongLeftRightArrow","DoubleLongRightArrow","DoubleRightArrow","DoubleRightTee","DoubleUpArrow","DoubleUpDownArrow","DoubleVerticalBar","DoublyInfinite","Down","DownArrow","DownArrowBar","DownArrowUpArrow","DownLeftRightVector","DownLeftTeeVector","DownLeftVector","DownLeftVectorBar","DownRightTeeVector","DownRightVector","DownRightVectorBar","Downsample","DownTee","DownTeeArrow","DownValues","DragAndDrop","DrawEdges","DrawFrontFaces","DrawHighlighted","Drop","DropoutLayer","DSolve","DSolveValue","Dt","DualLinearProgramming","DualPolyhedron","DualSystemsModel","DumpGet","DumpSave","DuplicateFreeQ","Duration","Dynamic","DynamicBox","DynamicBoxOptions","DynamicEvaluationTimeout","DynamicGeoGraphics","DynamicImage","DynamicLocation","DynamicModule","DynamicModuleBox","DynamicModuleBoxOptions","DynamicModuleParent","DynamicModuleValues","DynamicName","DynamicNamespace","DynamicReference","DynamicSetting","DynamicUpdating","DynamicWrapper","DynamicWrapperBox","DynamicWrapperBoxOptions","E","EarthImpactData","EarthquakeData","EccentricityCentrality","Echo","EchoFunction","EclipseType","EdgeAdd","EdgeBetweennessCentrality","EdgeCapacity","EdgeCapForm","EdgeColor","EdgeConnectivity","EdgeContract","EdgeCost","EdgeCount","EdgeCoverQ","EdgeCycleMatrix","EdgeDashing","EdgeDelete","EdgeDetect","EdgeForm","EdgeIndex","EdgeJoinForm","EdgeLabeling","EdgeLabels","EdgeLabelStyle","EdgeList","EdgeOpacity","EdgeQ","EdgeRenderingFunction","EdgeRules","EdgeShapeFunction","EdgeStyle","EdgeTaggedGraph","EdgeTaggedGraphQ","EdgeTags","EdgeThickness","EdgeWeight","EdgeWeightedGraphQ","Editable","EditButtonSettings","EditCellTagsSettings","EditDistance","EffectiveInterest","Eigensystem","Eigenvalues","EigenvectorCentrality","Eigenvectors","Element","ElementData","ElementwiseLayer","ElidedForms","Eliminate","EliminationOrder","Ellipsoid","EllipticE","EllipticExp","EllipticExpPrime","EllipticF","EllipticFilterModel","EllipticK","EllipticLog","EllipticNomeQ","EllipticPi","EllipticReducedHalfPeriods","EllipticTheta","EllipticThetaPrime","EmbedCode","EmbeddedHTML","EmbeddedService","EmbeddingLayer","EmbeddingObject","EmitSound","EmphasizeSyntaxErrors","EmpiricalDistribution","Empty","EmptyGraphQ","EmptyRegion","EnableConsolePrintPacket","Enabled","Encode","Encrypt","EncryptedObject","EncryptFile","End","EndAdd","EndDialogPacket","EndFrontEndInteractionPacket","EndOfBuffer","EndOfFile","EndOfLine","EndOfString","EndPackage","EngineEnvironment","EngineeringForm","Enter","EnterExpressionPacket","EnterTextPacket","Entity","EntityClass","EntityClassList","EntityCopies","EntityFunction","EntityGroup","EntityInstance","EntityList","EntityPrefetch","EntityProperties","EntityProperty","EntityPropertyClass","EntityRegister","EntityStore","EntityStores","EntityTypeName","EntityUnregister","EntityValue","Entropy","EntropyFilter","Environment","Epilog","EpilogFunction","Equal","EqualColumns","EqualRows","EqualTilde","EqualTo","EquatedTo","Equilibrium","EquirippleFilterKernel","Equivalent","Erf","Erfc","Erfi","ErlangB","ErlangC","ErlangDistribution","Erosion","ErrorBox","ErrorBoxOptions","ErrorNorm","ErrorPacket","ErrorsDialogSettings","EscapeRadius","EstimatedBackground","EstimatedDistribution","EstimatedProcess","EstimatorGains","EstimatorRegulator","EuclideanDistance","EulerAngles","EulerCharacteristic","EulerE","EulerGamma","EulerianGraphQ","EulerMatrix","EulerPhi","Evaluatable","Evaluate","Evaluated","EvaluatePacket","EvaluateScheduledTask","EvaluationBox","EvaluationCell","EvaluationCompletionAction","EvaluationData","EvaluationElements","EvaluationEnvironment","EvaluationMode","EvaluationMonitor","EvaluationNotebook","EvaluationObject","EvaluationOrder","Evaluator","EvaluatorNames","EvenQ","EventData","EventEvaluator","EventHandler","EventHandlerTag","EventLabels","EventSeries","ExactBlackmanWindow","ExactNumberQ","ExactRootIsolation","ExampleData","Except","ExcludedForms","ExcludedLines","ExcludedPhysicalQuantities","ExcludePods","Exclusions","ExclusionsStyle","Exists","Exit","ExitDialog","ExoplanetData","Exp","Expand","ExpandAll","ExpandDenominator","ExpandFileName","ExpandNumerator","Expectation","ExpectationE","ExpectedValue","ExpGammaDistribution","ExpIntegralE","ExpIntegralEi","ExpirationDate","Exponent","ExponentFunction","ExponentialDistribution","ExponentialFamily","ExponentialGeneratingFunction","ExponentialMovingAverage","ExponentialPowerDistribution","ExponentPosition","ExponentStep","Export","ExportAutoReplacements","ExportByteArray","ExportForm","ExportPacket","ExportString","Expression","ExpressionCell","ExpressionGraph","ExpressionPacket","ExpressionUUID","ExpToTrig","ExtendedEntityClass","ExtendedGCD","Extension","ExtentElementFunction","ExtentMarkers","ExtentSize","ExternalBundle","ExternalCall","ExternalDataCharacterEncoding","ExternalEvaluate","ExternalFunction","ExternalFunctionName","ExternalIdentifier","ExternalObject","ExternalOptions","ExternalSessionObject","ExternalSessions","ExternalStorageBase","ExternalStorageDownload","ExternalStorageGet","ExternalStorageObject","ExternalStoragePut","ExternalStorageUpload","ExternalTypeSignature","ExternalValue","Extract","ExtractArchive","ExtractLayer","ExtractPacletArchive","ExtremeValueDistribution","FaceAlign","FaceForm","FaceGrids","FaceGridsStyle","FacialFeatures","Factor","FactorComplete","Factorial","Factorial2","FactorialMoment","FactorialMomentGeneratingFunction","FactorialPower","FactorInteger","FactorList","FactorSquareFree","FactorSquareFreeList","FactorTerms","FactorTermsList","Fail","Failure","FailureAction","FailureDistribution","FailureQ","False","FareySequence","FARIMAProcess","FeatureDistance","FeatureExtract","FeatureExtraction","FeatureExtractor","FeatureExtractorFunction","FeatureNames","FeatureNearest","FeatureSpacePlot","FeatureSpacePlot3D","FeatureTypes","FEDisableConsolePrintPacket","FeedbackLinearize","FeedbackSector","FeedbackSectorStyle","FeedbackType","FEEnableConsolePrintPacket","FetalGrowthData","Fibonacci","Fibonorial","FieldCompletionFunction","FieldHint","FieldHintStyle","FieldMasked","FieldSize","File","FileBaseName","FileByteCount","FileConvert","FileDate","FileExistsQ","FileExtension","FileFormat","FileHandler","FileHash","FileInformation","FileName","FileNameDepth","FileNameDialogSettings","FileNameDrop","FileNameForms","FileNameJoin","FileNames","FileNameSetter","FileNameSplit","FileNameTake","FilePrint","FileSize","FileSystemMap","FileSystemScan","FileTemplate","FileTemplateApply","FileType","FilledCurve","FilledCurveBox","FilledCurveBoxOptions","Filling","FillingStyle","FillingTransform","FilteredEntityClass","FilterRules","FinancialBond","FinancialData","FinancialDerivative","FinancialIndicator","Find","FindAnomalies","FindArgMax","FindArgMin","FindChannels","FindClique","FindClusters","FindCookies","FindCurvePath","FindCycle","FindDevices","FindDistribution","FindDistributionParameters","FindDivisions","FindEdgeCover","FindEdgeCut","FindEdgeIndependentPaths","FindEquationalProof","FindEulerianCycle","FindExternalEvaluators","FindFaces","FindFile","FindFit","FindFormula","FindFundamentalCycles","FindGeneratingFunction","FindGeoLocation","FindGeometricConjectures","FindGeometricTransform","FindGraphCommunities","FindGraphIsomorphism","FindGraphPartition","FindHamiltonianCycle","FindHamiltonianPath","FindHiddenMarkovStates","FindImageText","FindIndependentEdgeSet","FindIndependentVertexSet","FindInstance","FindIntegerNullVector","FindKClan","FindKClique","FindKClub","FindKPlex","FindLibrary","FindLinearRecurrence","FindList","FindMatchingColor","FindMaximum","FindMaximumCut","FindMaximumFlow","FindMaxValue","FindMeshDefects","FindMinimum","FindMinimumCostFlow","FindMinimumCut","FindMinValue","FindMoleculeSubstructure","FindPath","FindPeaks","FindPermutation","FindPostmanTour","FindProcessParameters","FindRepeat","FindRoot","FindSequenceFunction","FindSettings","FindShortestPath","FindShortestTour","FindSpanningTree","FindSystemModelEquilibrium","FindTextualAnswer","FindThreshold","FindTransientRepeat","FindVertexCover","FindVertexCut","FindVertexIndependentPaths","Fine","FinishDynamic","FiniteAbelianGroupCount","FiniteGroupCount","FiniteGroupData","First","FirstCase","FirstPassageTimeDistribution","FirstPosition","FischerGroupFi22","FischerGroupFi23","FischerGroupFi24Prime","FisherHypergeometricDistribution","FisherRatioTest","FisherZDistribution","Fit","FitAll","FitRegularization","FittedModel","FixedOrder","FixedPoint","FixedPointList","FlashSelection","Flat","Flatten","FlattenAt","FlattenLayer","FlatTopWindow","FlipView","Floor","FlowPolynomial","FlushPrintOutputPacket","Fold","FoldList","FoldPair","FoldPairList","FollowRedirects","Font","FontColor","FontFamily","FontForm","FontName","FontOpacity","FontPostScriptName","FontProperties","FontReencoding","FontSize","FontSlant","FontSubstitutions","FontTracking","FontVariations","FontWeight","For","ForAll","ForceVersionInstall","Format","FormatRules","FormatType","FormatTypeAutoConvert","FormatValues","FormBox","FormBoxOptions","FormControl","FormFunction","FormLayoutFunction","FormObject","FormPage","FormTheme","FormulaData","FormulaLookup","FortranForm","Forward","ForwardBackward","Fourier","FourierCoefficient","FourierCosCoefficient","FourierCosSeries","FourierCosTransform","FourierDCT","FourierDCTFilter","FourierDCTMatrix","FourierDST","FourierDSTMatrix","FourierMatrix","FourierParameters","FourierSequenceTransform","FourierSeries","FourierSinCoefficient","FourierSinSeries","FourierSinTransform","FourierTransform","FourierTrigSeries","FractionalBrownianMotionProcess","FractionalGaussianNoiseProcess","FractionalPart","FractionBox","FractionBoxOptions","FractionLine","Frame","FrameBox","FrameBoxOptions","Framed","FrameInset","FrameLabel","Frameless","FrameMargins","FrameRate","FrameStyle","FrameTicks","FrameTicksStyle","FRatioDistribution","FrechetDistribution","FreeQ","FrenetSerretSystem","FrequencySamplingFilterKernel","FresnelC","FresnelF","FresnelG","FresnelS","Friday","FrobeniusNumber","FrobeniusSolve","FromAbsoluteTime","FromCharacterCode","FromCoefficientRules","FromContinuedFraction","FromDate","FromDigits","FromDMS","FromEntity","FromJulianDate","FromLetterNumber","FromPolarCoordinates","FromRomanNumeral","FromSphericalCoordinates","FromUnixTime","Front","FrontEndDynamicExpression","FrontEndEventActions","FrontEndExecute","FrontEndObject","FrontEndResource","FrontEndResourceString","FrontEndStackSize","FrontEndToken","FrontEndTokenExecute","FrontEndValueCache","FrontEndVersion","FrontFaceColor","FrontFaceOpacity","Full","FullAxes","FullDefinition","FullForm","FullGraphics","FullInformationOutputRegulator","FullOptions","FullRegion","FullSimplify","Function","FunctionCompile","FunctionCompileExport","FunctionCompileExportByteArray","FunctionCompileExportLibrary","FunctionCompileExportString","FunctionDomain","FunctionExpand","FunctionInterpolation","FunctionPeriod","FunctionRange","FunctionSpace","FussellVeselyImportance","GaborFilter","GaborMatrix","GaborWavelet","GainMargins","GainPhaseMargins","GalaxyData","GalleryView","Gamma","GammaDistribution","GammaRegularized","GapPenalty","GARCHProcess","GatedRecurrentLayer","Gather","GatherBy","GaugeFaceElementFunction","GaugeFaceStyle","GaugeFrameElementFunction","GaugeFrameSize","GaugeFrameStyle","GaugeLabels","GaugeMarkers","GaugeStyle","GaussianFilter","GaussianIntegers","GaussianMatrix","GaussianOrthogonalMatrixDistribution","GaussianSymplecticMatrixDistribution","GaussianUnitaryMatrixDistribution","GaussianWindow","GCD","GegenbauerC","General","GeneralizedLinearModelFit","GenerateAsymmetricKeyPair","GenerateConditions","GeneratedCell","GeneratedDocumentBinding","GenerateDerivedKey","GenerateDigitalSignature","GenerateDocument","GeneratedParameters","GeneratedQuantityMagnitudes","GenerateFileSignature","GenerateHTTPResponse","GenerateSecuredAuthenticationKey","GenerateSymmetricKey","GeneratingFunction","GeneratorDescription","GeneratorHistoryLength","GeneratorOutputType","Generic","GenericCylindricalDecomposition","GenomeData","GenomeLookup","GeoAntipode","GeoArea","GeoArraySize","GeoBackground","GeoBoundingBox","GeoBounds","GeoBoundsRegion","GeoBubbleChart","GeoCenter","GeoCircle","GeoContourPlot","GeoDensityPlot","GeodesicClosing","GeodesicDilation","GeodesicErosion","GeodesicOpening","GeoDestination","GeodesyData","GeoDirection","GeoDisk","GeoDisplacement","GeoDistance","GeoDistanceList","GeoElevationData","GeoEntities","GeoGraphics","GeogravityModelData","GeoGridDirectionDifference","GeoGridLines","GeoGridLinesStyle","GeoGridPosition","GeoGridRange","GeoGridRangePadding","GeoGridUnitArea","GeoGridUnitDistance","GeoGridVector","GeoGroup","GeoHemisphere","GeoHemisphereBoundary","GeoHistogram","GeoIdentify","GeoImage","GeoLabels","GeoLength","GeoListPlot","GeoLocation","GeologicalPeriodData","GeomagneticModelData","GeoMarker","GeometricAssertion","GeometricBrownianMotionProcess","GeometricDistribution","GeometricMean","GeometricMeanFilter","GeometricOptimization","GeometricScene","GeometricTransformation","GeometricTransformation3DBox","GeometricTransformation3DBoxOptions","GeometricTransformationBox","GeometricTransformationBoxOptions","GeoModel","GeoNearest","GeoPath","GeoPosition","GeoPositionENU","GeoPositionXYZ","GeoProjection","GeoProjectionData","GeoRange","GeoRangePadding","GeoRegionValuePlot","GeoResolution","GeoScaleBar","GeoServer","GeoSmoothHistogram","GeoStreamPlot","GeoStyling","GeoStylingImageFunction","GeoVariant","GeoVector","GeoVectorENU","GeoVectorPlot","GeoVectorXYZ","GeoVisibleRegion","GeoVisibleRegionBoundary","GeoWithinQ","GeoZoomLevel","GestureHandler","GestureHandlerTag","Get","GetBoundingBoxSizePacket","GetContext","GetEnvironment","GetFileName","GetFrontEndOptionsDataPacket","GetLinebreakInformationPacket","GetMenusPacket","GetPageBreakInformationPacket","Glaisher","GlobalClusteringCoefficient","GlobalPreferences","GlobalSession","Glow","GoldenAngle","GoldenRatio","GompertzMakehamDistribution","GoochShading","GoodmanKruskalGamma","GoodmanKruskalGammaTest","Goto","Grad","Gradient","GradientFilter","GradientOrientationFilter","GrammarApply","GrammarRules","GrammarToken","Graph","Graph3D","GraphAssortativity","GraphAutomorphismGroup","GraphCenter","GraphComplement","GraphData","GraphDensity","GraphDiameter","GraphDifference","GraphDisjointUnion","GraphDistance","GraphDistanceMatrix","GraphElementData","GraphEmbedding","GraphHighlight","GraphHighlightStyle","GraphHub","Graphics","Graphics3D","Graphics3DBox","Graphics3DBoxOptions","GraphicsArray","GraphicsBaseline","GraphicsBox","GraphicsBoxOptions","GraphicsColor","GraphicsColumn","GraphicsComplex","GraphicsComplex3DBox","GraphicsComplex3DBoxOptions","GraphicsComplexBox","GraphicsComplexBoxOptions","GraphicsContents","GraphicsData","GraphicsGrid","GraphicsGridBox","GraphicsGroup","GraphicsGroup3DBox","GraphicsGroup3DBoxOptions","GraphicsGroupBox","GraphicsGroupBoxOptions","GraphicsGrouping","GraphicsHighlightColor","GraphicsRow","GraphicsSpacing","GraphicsStyle","GraphIntersection","GraphLayout","GraphLinkEfficiency","GraphPeriphery","GraphPlot","GraphPlot3D","GraphPower","GraphPropertyDistribution","GraphQ","GraphRadius","GraphReciprocity","GraphRoot","GraphStyle","GraphUnion","Gray","GrayLevel","Greater","GreaterEqual","GreaterEqualLess","GreaterEqualThan","GreaterFullEqual","GreaterGreater","GreaterLess","GreaterSlantEqual","GreaterThan","GreaterTilde","Green","GreenFunction","Grid","GridBaseline","GridBox","GridBoxAlignment","GridBoxBackground","GridBoxDividers","GridBoxFrame","GridBoxItemSize","GridBoxItemStyle","GridBoxOptions","GridBoxSpacings","GridCreationSettings","GridDefaultElement","GridElementStyleOptions","GridFrame","GridFrameMargins","GridGraph","GridLines","GridLinesStyle","GroebnerBasis","GroupActionBase","GroupBy","GroupCentralizer","GroupElementFromWord","GroupElementPosition","GroupElementQ","GroupElements","GroupElementToWord","GroupGenerators","Groupings","GroupMultiplicationTable","GroupOrbits","GroupOrder","GroupPageBreakWithin","GroupSetwiseStabilizer","GroupStabilizer","GroupStabilizerChain","GroupTogetherGrouping","GroupTogetherNestedGrouping","GrowCutComponents","Gudermannian","GuidedFilter","GumbelDistribution","HaarWavelet","HadamardMatrix","HalfLine","HalfNormalDistribution","HalfPlane","HalfSpace","HalftoneShading","HamiltonianGraphQ","HammingDistance","HammingWindow","HandlerFunctions","HandlerFunctionsKeys","HankelH1","HankelH2","HankelMatrix","HankelTransform","HannPoissonWindow","HannWindow","HaradaNortonGroupHN","HararyGraph","HarmonicMean","HarmonicMeanFilter","HarmonicNumber","Hash","HatchFilling","HatchShading","Haversine","HazardFunction","Head","HeadCompose","HeaderAlignment","HeaderBackground","HeaderDisplayFunction","HeaderLines","HeaderSize","HeaderStyle","Heads","HeavisideLambda","HeavisidePi","HeavisideTheta","HeldGroupHe","HeldPart","HelpBrowserLookup","HelpBrowserNotebook","HelpBrowserSettings","Here","HermiteDecomposition","HermiteH","HermitianMatrixQ","HessenbergDecomposition","Hessian","HeunB","HeunBPrime","HeunC","HeunCPrime","HeunD","HeunDPrime","HeunG","HeunGPrime","HeunT","HeunTPrime","HexadecimalCharacter","Hexahedron","HexahedronBox","HexahedronBoxOptions","HiddenItems","HiddenMarkovProcess","HiddenSurface","Highlighted","HighlightGraph","HighlightImage","HighlightMesh","HighpassFilter","HigmanSimsGroupHS","HilbertCurve","HilbertFilter","HilbertMatrix","Histogram","Histogram3D","HistogramDistribution","HistogramList","HistogramTransform","HistogramTransformInterpolation","HistoricalPeriodData","HitMissTransform","HITSCentrality","HjorthDistribution","HodgeDual","HoeffdingD","HoeffdingDTest","Hold","HoldAll","HoldAllComplete","HoldComplete","HoldFirst","HoldForm","HoldPattern","HoldRest","HolidayCalendar","HomeDirectory","HomePage","Horizontal","HorizontalForm","HorizontalGauge","HorizontalScrollPosition","HornerForm","HostLookup","HotellingTSquareDistribution","HoytDistribution","HTMLSave","HTTPErrorResponse","HTTPRedirect","HTTPRequest","HTTPRequestData","HTTPResponse","Hue","HumanGrowthData","HumpDownHump","HumpEqual","HurwitzLerchPhi","HurwitzZeta","HyperbolicDistribution","HypercubeGraph","HyperexponentialDistribution","Hyperfactorial","Hypergeometric0F1","Hypergeometric0F1Regularized","Hypergeometric1F1","Hypergeometric1F1Regularized","Hypergeometric2F1","Hypergeometric2F1Regularized","HypergeometricDistribution","HypergeometricPFQ","HypergeometricPFQRegularized","HypergeometricU","Hyperlink","HyperlinkAction","HyperlinkCreationSettings","Hyperplane","Hyphenation","HyphenationOptions","HypoexponentialDistribution","HypothesisTestData","I","IconData","Iconize","IconizedObject","IconRules","Icosahedron","Identity","IdentityMatrix","If","IgnoreCase","IgnoreDiacritics","IgnorePunctuation","IgnoreSpellCheck","IgnoringInactive","Im","Image","Image3D","Image3DProjection","Image3DSlices","ImageAccumulate","ImageAdd","ImageAdjust","ImageAlign","ImageApply","ImageApplyIndexed","ImageAspectRatio","ImageAssemble","ImageAugmentationLayer","ImageBoundingBoxes","ImageCache","ImageCacheValid","ImageCapture","ImageCaptureFunction","ImageCases","ImageChannels","ImageClip","ImageCollage","ImageColorSpace","ImageCompose","ImageContainsQ","ImageContents","ImageConvolve","ImageCooccurrence","ImageCorners","ImageCorrelate","ImageCorrespondingPoints","ImageCrop","ImageData","ImageDeconvolve","ImageDemosaic","ImageDifference","ImageDimensions","ImageDisplacements","ImageDistance","ImageEffect","ImageExposureCombine","ImageFeatureTrack","ImageFileApply","ImageFileFilter","ImageFileScan","ImageFilter","ImageFocusCombine","ImageForestingComponents","ImageFormattingWidth","ImageForwardTransformation","ImageGraphics","ImageHistogram","ImageIdentify","ImageInstanceQ","ImageKeypoints","ImageLabels","ImageLegends","ImageLevels","ImageLines","ImageMargins","ImageMarker","ImageMarkers","ImageMeasurements","ImageMesh","ImageMultiply","ImageOffset","ImagePad","ImagePadding","ImagePartition","ImagePeriodogram","ImagePerspectiveTransformation","ImagePosition","ImagePreviewFunction","ImagePyramid","ImagePyramidApply","ImageQ","ImageRangeCache","ImageRecolor","ImageReflect","ImageRegion","ImageResize","ImageResolution","ImageRestyle","ImageRotate","ImageRotated","ImageSaliencyFilter","ImageScaled","ImageScan","ImageSize","ImageSizeAction","ImageSizeCache","ImageSizeMultipliers","ImageSizeRaw","ImageSubtract","ImageTake","ImageTransformation","ImageTrim","ImageType","ImageValue","ImageValuePositions","ImagingDevice","ImplicitRegion","Implies","Import","ImportAutoReplacements","ImportByteArray","ImportOptions","ImportString","ImprovementImportance","In","Inactivate","Inactive","IncidenceGraph","IncidenceList","IncidenceMatrix","IncludeAromaticBonds","IncludeConstantBasis","IncludeDefinitions","IncludeDirectories","IncludeFileExtension","IncludeGeneratorTasks","IncludeHydrogens","IncludeInflections","IncludeMetaInformation","IncludePods","IncludeQuantities","IncludeRelatedTables","IncludeSingularTerm","IncludeWindowTimes","Increment","IndefiniteMatrixQ","Indent","IndentingNewlineSpacings","IndentMaxFraction","IndependenceTest","IndependentEdgeSetQ","IndependentPhysicalQuantity","IndependentUnit","IndependentUnitDimension","IndependentVertexSetQ","Indeterminate","IndeterminateThreshold","IndexCreationOptions","Indexed","IndexEdgeTaggedGraph","IndexGraph","IndexTag","Inequality","InexactNumberQ","InexactNumbers","InfiniteFuture","InfiniteLine","InfinitePast","InfinitePlane","Infinity","Infix","InflationAdjust","InflationMethod","Information","InformationData","InformationDataGrid","Inherited","InheritScope","InhomogeneousPoissonProcess","InitialEvaluationHistory","Initialization","InitializationCell","InitializationCellEvaluation","InitializationCellWarning","InitializationObjects","InitializationValue","Initialize","InitialSeeding","InlineCounterAssignments","InlineCounterIncrements","InlineRules","Inner","InnerPolygon","InnerPolyhedron","Inpaint","Input","InputAliases","InputAssumptions","InputAutoReplacements","InputField","InputFieldBox","InputFieldBoxOptions","InputForm","InputGrouping","InputNamePacket","InputNotebook","InputPacket","InputSettings","InputStream","InputString","InputStringPacket","InputToBoxFormPacket","Insert","InsertionFunction","InsertionPointObject","InsertLinebreaks","InsertResults","Inset","Inset3DBox","Inset3DBoxOptions","InsetBox","InsetBoxOptions","Insphere","Install","InstallService","InstanceNormalizationLayer","InString","Integer","IntegerDigits","IntegerExponent","IntegerLength","IntegerName","IntegerPart","IntegerPartitions","IntegerQ","IntegerReverse","Integers","IntegerString","Integral","Integrate","Interactive","InteractiveTradingChart","Interlaced","Interleaving","InternallyBalancedDecomposition","InterpolatingFunction","InterpolatingPolynomial","Interpolation","InterpolationOrder","InterpolationPoints","InterpolationPrecision","Interpretation","InterpretationBox","InterpretationBoxOptions","InterpretationFunction","Interpreter","InterpretTemplate","InterquartileRange","Interrupt","InterruptSettings","IntersectedEntityClass","IntersectingQ","Intersection","Interval","IntervalIntersection","IntervalMarkers","IntervalMarkersStyle","IntervalMemberQ","IntervalSlider","IntervalUnion","Into","Inverse","InverseBetaRegularized","InverseCDF","InverseChiSquareDistribution","InverseContinuousWaveletTransform","InverseDistanceTransform","InverseEllipticNomeQ","InverseErf","InverseErfc","InverseFourier","InverseFourierCosTransform","InverseFourierSequenceTransform","InverseFourierSinTransform","InverseFourierTransform","InverseFunction","InverseFunctions","InverseGammaDistribution","InverseGammaRegularized","InverseGaussianDistribution","InverseGudermannian","InverseHankelTransform","InverseHaversine","InverseImagePyramid","InverseJacobiCD","InverseJacobiCN","InverseJacobiCS","InverseJacobiDC","InverseJacobiDN","InverseJacobiDS","InverseJacobiNC","InverseJacobiND","InverseJacobiNS","InverseJacobiSC","InverseJacobiSD","InverseJacobiSN","InverseLaplaceTransform","InverseMellinTransform","InversePermutation","InverseRadon","InverseRadonTransform","InverseSeries","InverseShortTimeFourier","InverseSpectrogram","InverseSurvivalFunction","InverseTransformedRegion","InverseWaveletTransform","InverseWeierstrassP","InverseWishartMatrixDistribution","InverseZTransform","Invisible","InvisibleApplication","InvisibleTimes","IPAddress","IrreduciblePolynomialQ","IslandData","IsolatingInterval","IsomorphicGraphQ","IsotopeData","Italic","Item","ItemAspectRatio","ItemBox","ItemBoxOptions","ItemDisplayFunction","ItemSize","ItemStyle","ItoProcess","JaccardDissimilarity","JacobiAmplitude","Jacobian","JacobiCD","JacobiCN","JacobiCS","JacobiDC","JacobiDN","JacobiDS","JacobiNC","JacobiND","JacobiNS","JacobiP","JacobiSC","JacobiSD","JacobiSN","JacobiSymbol","JacobiZeta","JankoGroupJ1","JankoGroupJ2","JankoGroupJ3","JankoGroupJ4","JarqueBeraALMTest","JohnsonDistribution","Join","JoinAcross","Joined","JoinedCurve","JoinedCurveBox","JoinedCurveBoxOptions","JoinForm","JordanDecomposition","JordanModelDecomposition","JulianDate","JuliaSetBoettcher","JuliaSetIterationCount","JuliaSetPlot","JuliaSetPoints","K","KagiChart","KaiserBesselWindow","KaiserWindow","KalmanEstimator","KalmanFilter","KarhunenLoeveDecomposition","KaryTree","KatzCentrality","KCoreComponents","KDistribution","KEdgeConnectedComponents","KEdgeConnectedGraphQ","KeepExistingVersion","KelvinBei","KelvinBer","KelvinKei","KelvinKer","KendallTau","KendallTauTest","KernelExecute","KernelFunction","KernelMixtureDistribution","KernelObject","Kernels","Ket","Key","KeyCollisionFunction","KeyComplement","KeyDrop","KeyDropFrom","KeyExistsQ","KeyFreeQ","KeyIntersection","KeyMap","KeyMemberQ","KeypointStrength","Keys","KeySelect","KeySort","KeySortBy","KeyTake","KeyUnion","KeyValueMap","KeyValuePattern","Khinchin","KillProcess","KirchhoffGraph","KirchhoffMatrix","KleinInvariantJ","KnapsackSolve","KnightTourGraph","KnotData","KnownUnitQ","KochCurve","KolmogorovSmirnovTest","KroneckerDelta","KroneckerModelDecomposition","KroneckerProduct","KroneckerSymbol","KuiperTest","KumaraswamyDistribution","Kurtosis","KuwaharaFilter","KVertexConnectedComponents","KVertexConnectedGraphQ","LABColor","Label","Labeled","LabeledSlider","LabelingFunction","LabelingSize","LabelStyle","LabelVisibility","LaguerreL","LakeData","LambdaComponents","LambertW","LaminaData","LanczosWindow","LandauDistribution","Language","LanguageCategory","LanguageData","LanguageIdentify","LanguageOptions","LaplaceDistribution","LaplaceTransform","Laplacian","LaplacianFilter","LaplacianGaussianFilter","Large","Larger","Last","Latitude","LatitudeLongitude","LatticeData","LatticeReduce","Launch","LaunchKernels","LayeredGraphPlot","LayerSizeFunction","LayoutInformation","LCHColor","LCM","LeaderSize","LeafCount","LeapYearQ","LearnDistribution","LearnedDistribution","LearningRate","LearningRateMultipliers","LeastSquares","LeastSquaresFilterKernel","Left","LeftArrow","LeftArrowBar","LeftArrowRightArrow","LeftDownTeeVector","LeftDownVector","LeftDownVectorBar","LeftRightArrow","LeftRightVector","LeftTee","LeftTeeArrow","LeftTeeVector","LeftTriangle","LeftTriangleBar","LeftTriangleEqual","LeftUpDownVector","LeftUpTeeVector","LeftUpVector","LeftUpVectorBar","LeftVector","LeftVectorBar","LegendAppearance","Legended","LegendFunction","LegendLabel","LegendLayout","LegendMargins","LegendMarkers","LegendMarkerSize","LegendreP","LegendreQ","LegendreType","Length","LengthWhile","LerchPhi","Less","LessEqual","LessEqualGreater","LessEqualThan","LessFullEqual","LessGreater","LessLess","LessSlantEqual","LessThan","LessTilde","LetterCharacter","LetterCounts","LetterNumber","LetterQ","Level","LeveneTest","LeviCivitaTensor","LevyDistribution","Lexicographic","LibraryDataType","LibraryFunction","LibraryFunctionError","LibraryFunctionInformation","LibraryFunctionLoad","LibraryFunctionUnload","LibraryLoad","LibraryUnload","LicenseID","LiftingFilterData","LiftingWaveletTransform","LightBlue","LightBrown","LightCyan","Lighter","LightGray","LightGreen","Lighting","LightingAngle","LightMagenta","LightOrange","LightPink","LightPurple","LightRed","LightSources","LightYellow","Likelihood","Limit","LimitsPositioning","LimitsPositioningTokens","LindleyDistribution","Line","Line3DBox","Line3DBoxOptions","LinearFilter","LinearFractionalOptimization","LinearFractionalTransform","LinearGradientImage","LinearizingTransformationData","LinearLayer","LinearModelFit","LinearOffsetFunction","LinearOptimization","LinearProgramming","LinearRecurrence","LinearSolve","LinearSolveFunction","LineBox","LineBoxOptions","LineBreak","LinebreakAdjustments","LineBreakChart","LinebreakSemicolonWeighting","LineBreakWithin","LineColor","LineGraph","LineIndent","LineIndentMaxFraction","LineIntegralConvolutionPlot","LineIntegralConvolutionScale","LineLegend","LineOpacity","LineSpacing","LineWrapParts","LinkActivate","LinkClose","LinkConnect","LinkConnectedQ","LinkCreate","LinkError","LinkFlush","LinkFunction","LinkHost","LinkInterrupt","LinkLaunch","LinkMode","LinkObject","LinkOpen","LinkOptions","LinkPatterns","LinkProtocol","LinkRankCentrality","LinkRead","LinkReadHeld","LinkReadyQ","Links","LinkService","LinkWrite","LinkWriteHeld","LiouvilleLambda","List","Listable","ListAnimate","ListContourPlot","ListContourPlot3D","ListConvolve","ListCorrelate","ListCurvePathPlot","ListDeconvolve","ListDensityPlot","ListDensityPlot3D","Listen","ListFormat","ListFourierSequenceTransform","ListInterpolation","ListLineIntegralConvolutionPlot","ListLinePlot","ListLogLinearPlot","ListLogLogPlot","ListLogPlot","ListPicker","ListPickerBox","ListPickerBoxBackground","ListPickerBoxOptions","ListPlay","ListPlot","ListPlot3D","ListPointPlot3D","ListPolarPlot","ListQ","ListSliceContourPlot3D","ListSliceDensityPlot3D","ListSliceVectorPlot3D","ListStepPlot","ListStreamDensityPlot","ListStreamPlot","ListSurfacePlot3D","ListVectorDensityPlot","ListVectorPlot","ListVectorPlot3D","ListZTransform","Literal","LiteralSearch","LocalAdaptiveBinarize","LocalCache","LocalClusteringCoefficient","LocalizeDefinitions","LocalizeVariables","LocalObject","LocalObjects","LocalResponseNormalizationLayer","LocalSubmit","LocalSymbol","LocalTime","LocalTimeZone","LocationEquivalenceTest","LocationTest","Locator","LocatorAutoCreate","LocatorBox","LocatorBoxOptions","LocatorCentering","LocatorPane","LocatorPaneBox","LocatorPaneBoxOptions","LocatorRegion","Locked","Log","Log10","Log2","LogBarnesG","LogGamma","LogGammaDistribution","LogicalExpand","LogIntegral","LogisticDistribution","LogisticSigmoid","LogitModelFit","LogLikelihood","LogLinearPlot","LogLogisticDistribution","LogLogPlot","LogMultinormalDistribution","LogNormalDistribution","LogPlot","LogRankTest","LogSeriesDistribution","LongEqual","Longest","LongestCommonSequence","LongestCommonSequencePositions","LongestCommonSubsequence","LongestCommonSubsequencePositions","LongestMatch","LongestOrderedSequence","LongForm","Longitude","LongLeftArrow","LongLeftRightArrow","LongRightArrow","LongShortTermMemoryLayer","Lookup","Loopback","LoopFreeGraphQ","Looping","LossFunction","LowerCaseQ","LowerLeftArrow","LowerRightArrow","LowerTriangularize","LowerTriangularMatrixQ","LowpassFilter","LQEstimatorGains","LQGRegulator","LQOutputRegulatorGains","LQRegulatorGains","LUBackSubstitution","LucasL","LuccioSamiComponents","LUDecomposition","LunarEclipse","LUVColor","LyapunovSolve","LyonsGroupLy","MachineID","MachineName","MachineNumberQ","MachinePrecision","MacintoshSystemPageSetup","Magenta","Magnification","Magnify","MailAddressValidation","MailExecute","MailFolder","MailItem","MailReceiverFunction","MailResponseFunction","MailSearch","MailServerConnect","MailServerConnection","MailSettings","MainSolve","MaintainDynamicCaches","Majority","MakeBoxes","MakeExpression","MakeRules","ManagedLibraryExpressionID","ManagedLibraryExpressionQ","MandelbrotSetBoettcher","MandelbrotSetDistance","MandelbrotSetIterationCount","MandelbrotSetMemberQ","MandelbrotSetPlot","MangoldtLambda","ManhattanDistance","Manipulate","Manipulator","MannedSpaceMissionData","MannWhitneyTest","MantissaExponent","Manual","Map","MapAll","MapAt","MapIndexed","MAProcess","MapThread","MarchenkoPasturDistribution","MarcumQ","MardiaCombinedTest","MardiaKurtosisTest","MardiaSkewnessTest","MarginalDistribution","MarkovProcessProperties","Masking","MatchingDissimilarity","MatchLocalNameQ","MatchLocalNames","MatchQ","Material","MathematicalFunctionData","MathematicaNotation","MathieuC","MathieuCharacteristicA","MathieuCharacteristicB","MathieuCharacteristicExponent","MathieuCPrime","MathieuGroupM11","MathieuGroupM12","MathieuGroupM22","MathieuGroupM23","MathieuGroupM24","MathieuS","MathieuSPrime","MathMLForm","MathMLText","Matrices","MatrixExp","MatrixForm","MatrixFunction","MatrixLog","MatrixNormalDistribution","MatrixPlot","MatrixPower","MatrixPropertyDistribution","MatrixQ","MatrixRank","MatrixTDistribution","Max","MaxBend","MaxCellMeasure","MaxColorDistance","MaxDate","MaxDetect","MaxDuration","MaxExtraBandwidths","MaxExtraConditions","MaxFeatureDisplacement","MaxFeatures","MaxFilter","MaximalBy","Maximize","MaxItems","MaxIterations","MaxLimit","MaxMemoryUsed","MaxMixtureKernels","MaxOverlapFraction","MaxPlotPoints","MaxPoints","MaxRecursion","MaxStableDistribution","MaxStepFraction","MaxSteps","MaxStepSize","MaxTrainingRounds","MaxValue","MaxwellDistribution","MaxWordGap","McLaughlinGroupMcL","Mean","MeanAbsoluteLossLayer","MeanAround","MeanClusteringCoefficient","MeanDegreeConnectivity","MeanDeviation","MeanFilter","MeanGraphDistance","MeanNeighborDegree","MeanShift","MeanShiftFilter","MeanSquaredLossLayer","Median","MedianDeviation","MedianFilter","MedicalTestData","Medium","MeijerG","MeijerGReduce","MeixnerDistribution","MellinConvolve","MellinTransform","MemberQ","MemoryAvailable","MemoryConstrained","MemoryConstraint","MemoryInUse","MengerMesh","Menu","MenuAppearance","MenuCommandKey","MenuEvaluator","MenuItem","MenuList","MenuPacket","MenuSortingValue","MenuStyle","MenuView","Merge","MergeDifferences","MergingFunction","MersennePrimeExponent","MersennePrimeExponentQ","Mesh","MeshCellCentroid","MeshCellCount","MeshCellHighlight","MeshCellIndex","MeshCellLabel","MeshCellMarker","MeshCellMeasure","MeshCellQuality","MeshCells","MeshCellShapeFunction","MeshCellStyle","MeshConnectivityGraph","MeshCoordinates","MeshFunctions","MeshPrimitives","MeshQualityGoal","MeshRange","MeshRefinementFunction","MeshRegion","MeshRegionQ","MeshShading","MeshStyle","Message","MessageDialog","MessageList","MessageName","MessageObject","MessageOptions","MessagePacket","Messages","MessagesNotebook","MetaCharacters","MetaInformation","MeteorShowerData","Method","MethodOptions","MexicanHatWavelet","MeyerWavelet","Midpoint","Min","MinColorDistance","MinDate","MinDetect","MineralData","MinFilter","MinimalBy","MinimalPolynomial","MinimalStateSpaceModel","Minimize","MinimumTimeIncrement","MinIntervalSize","MinkowskiQuestionMark","MinLimit","MinMax","MinorPlanetData","Minors","MinRecursion","MinSize","MinStableDistribution","Minus","MinusPlus","MinValue","Missing","MissingBehavior","MissingDataMethod","MissingDataRules","MissingQ","MissingString","MissingStyle","MissingValuePattern","MittagLefflerE","MixedFractionParts","MixedGraphQ","MixedMagnitude","MixedRadix","MixedRadixQuantity","MixedUnit","MixtureDistribution","Mod","Modal","Mode","Modular","ModularInverse","ModularLambda","Module","Modulus","MoebiusMu","Molecule","MoleculeContainsQ","MoleculeEquivalentQ","MoleculeGraph","MoleculeModify","MoleculePattern","MoleculePlot","MoleculePlot3D","MoleculeProperty","MoleculeQ","MoleculeRecognize","MoleculeValue","Moment","Momentary","MomentConvert","MomentEvaluate","MomentGeneratingFunction","MomentOfInertia","Monday","Monitor","MonomialList","MonomialOrder","MonsterGroupM","MoonPhase","MoonPosition","MorletWavelet","MorphologicalBinarize","MorphologicalBranchPoints","MorphologicalComponents","MorphologicalEulerNumber","MorphologicalGraph","MorphologicalPerimeter","MorphologicalTransform","MortalityData","Most","MountainData","MouseAnnotation","MouseAppearance","MouseAppearanceTag","MouseButtons","Mouseover","MousePointerNote","MousePosition","MovieData","MovingAverage","MovingMap","MovingMedian","MoyalDistribution","Multicolumn","MultiedgeStyle","MultigraphQ","MultilaunchWarning","MultiLetterItalics","MultiLetterStyle","MultilineFunction","Multinomial","MultinomialDistribution","MultinormalDistribution","MultiplicativeOrder","Multiplicity","MultiplySides","Multiselection","MultivariateHypergeometricDistribution","MultivariatePoissonDistribution","MultivariateTDistribution","N","NakagamiDistribution","NameQ","Names","NamespaceBox","NamespaceBoxOptions","Nand","NArgMax","NArgMin","NBernoulliB","NBodySimulation","NBodySimulationData","NCache","NDEigensystem","NDEigenvalues","NDSolve","NDSolveValue","Nearest","NearestFunction","NearestMeshCells","NearestNeighborGraph","NearestTo","NebulaData","NeedCurrentFrontEndPackagePacket","NeedCurrentFrontEndSymbolsPacket","NeedlemanWunschSimilarity","Needs","Negative","NegativeBinomialDistribution","NegativeDefiniteMatrixQ","NegativeIntegers","NegativeMultinomialDistribution","NegativeRationals","NegativeReals","NegativeSemidefiniteMatrixQ","NeighborhoodData","NeighborhoodGraph","Nest","NestedGreaterGreater","NestedLessLess","NestedScriptRules","NestGraph","NestList","NestWhile","NestWhileList","NetAppend","NetBidirectionalOperator","NetChain","NetDecoder","NetDelete","NetDrop","NetEncoder","NetEvaluationMode","NetExtract","NetFlatten","NetFoldOperator","NetGANOperator","NetGraph","NetInformation","NetInitialize","NetInsert","NetInsertSharedArrays","NetJoin","NetMapOperator","NetMapThreadOperator","NetMeasurements","NetModel","NetNestOperator","NetPairEmbeddingOperator","NetPort","NetPortGradient","NetPrepend","NetRename","NetReplace","NetReplacePart","NetSharedArray","NetStateObject","NetTake","NetTrain","NetTrainResultsObject","NetworkPacketCapture","NetworkPacketRecording","NetworkPacketRecordingDuring","NetworkPacketTrace","NeumannValue","NevilleThetaC","NevilleThetaD","NevilleThetaN","NevilleThetaS","NewPrimitiveStyle","NExpectation","Next","NextCell","NextDate","NextPrime","NextScheduledTaskTime","NHoldAll","NHoldFirst","NHoldRest","NicholsGridLines","NicholsPlot","NightHemisphere","NIntegrate","NMaximize","NMaxValue","NMinimize","NMinValue","NominalVariables","NonAssociative","NoncentralBetaDistribution","NoncentralChiSquareDistribution","NoncentralFRatioDistribution","NoncentralStudentTDistribution","NonCommutativeMultiply","NonConstants","NondimensionalizationTransform","None","NoneTrue","NonlinearModelFit","NonlinearStateSpaceModel","NonlocalMeansFilter","NonNegative","NonNegativeIntegers","NonNegativeRationals","NonNegativeReals","NonPositive","NonPositiveIntegers","NonPositiveRationals","NonPositiveReals","Nor","NorlundB","Norm","Normal","NormalDistribution","NormalGrouping","NormalizationLayer","Normalize","Normalized","NormalizedSquaredEuclideanDistance","NormalMatrixQ","NormalsFunction","NormFunction","Not","NotCongruent","NotCupCap","NotDoubleVerticalBar","Notebook","NotebookApply","NotebookAutoSave","NotebookClose","NotebookConvertSettings","NotebookCreate","NotebookCreateReturnObject","NotebookDefault","NotebookDelete","NotebookDirectory","NotebookDynamicExpression","NotebookEvaluate","NotebookEventActions","NotebookFileName","NotebookFind","NotebookFindReturnObject","NotebookGet","NotebookGetLayoutInformationPacket","NotebookGetMisspellingsPacket","NotebookImport","NotebookInformation","NotebookInterfaceObject","NotebookLocate","NotebookObject","NotebookOpen","NotebookOpenReturnObject","NotebookPath","NotebookPrint","NotebookPut","NotebookPutReturnObject","NotebookRead","NotebookResetGeneratedCells","Notebooks","NotebookSave","NotebookSaveAs","NotebookSelection","NotebookSetupLayoutInformationPacket","NotebooksMenu","NotebookTemplate","NotebookWrite","NotElement","NotEqualTilde","NotExists","NotGreater","NotGreaterEqual","NotGreaterFullEqual","NotGreaterGreater","NotGreaterLess","NotGreaterSlantEqual","NotGreaterTilde","Nothing","NotHumpDownHump","NotHumpEqual","NotificationFunction","NotLeftTriangle","NotLeftTriangleBar","NotLeftTriangleEqual","NotLess","NotLessEqual","NotLessFullEqual","NotLessGreater","NotLessLess","NotLessSlantEqual","NotLessTilde","NotNestedGreaterGreater","NotNestedLessLess","NotPrecedes","NotPrecedesEqual","NotPrecedesSlantEqual","NotPrecedesTilde","NotReverseElement","NotRightTriangle","NotRightTriangleBar","NotRightTriangleEqual","NotSquareSubset","NotSquareSubsetEqual","NotSquareSuperset","NotSquareSupersetEqual","NotSubset","NotSubsetEqual","NotSucceeds","NotSucceedsEqual","NotSucceedsSlantEqual","NotSucceedsTilde","NotSuperset","NotSupersetEqual","NotTilde","NotTildeEqual","NotTildeFullEqual","NotTildeTilde","NotVerticalBar","Now","NoWhitespace","NProbability","NProduct","NProductFactors","NRoots","NSolve","NSum","NSumTerms","NuclearExplosionData","NuclearReactorData","Null","NullRecords","NullSpace","NullWords","Number","NumberCompose","NumberDecompose","NumberExpand","NumberFieldClassNumber","NumberFieldDiscriminant","NumberFieldFundamentalUnits","NumberFieldIntegralBasis","NumberFieldNormRepresentatives","NumberFieldRegulator","NumberFieldRootsOfUnity","NumberFieldSignature","NumberForm","NumberFormat","NumberLinePlot","NumberMarks","NumberMultiplier","NumberPadding","NumberPoint","NumberQ","NumberSeparator","NumberSigns","NumberString","Numerator","NumeratorDenominator","NumericalOrder","NumericalSort","NumericArray","NumericArrayQ","NumericArrayType","NumericFunction","NumericQ","NuttallWindow","NValues","NyquistGridLines","NyquistPlot","O","ObservabilityGramian","ObservabilityMatrix","ObservableDecomposition","ObservableModelQ","OceanData","Octahedron","OddQ","Off","Offset","OLEData","On","ONanGroupON","Once","OneIdentity","Opacity","OpacityFunction","OpacityFunctionScaling","Open","OpenAppend","Opener","OpenerBox","OpenerBoxOptions","OpenerView","OpenFunctionInspectorPacket","Opening","OpenRead","OpenSpecialOptions","OpenTemporary","OpenWrite","Operate","OperatingSystem","OperatorApplied","OptimumFlowData","Optional","OptionalElement","OptionInspectorSettings","OptionQ","Options","OptionsPacket","OptionsPattern","OptionValue","OptionValueBox","OptionValueBoxOptions","Or","Orange","Order","OrderDistribution","OrderedQ","Ordering","OrderingBy","OrderingLayer","Orderless","OrderlessPatternSequence","OrnsteinUhlenbeckProcess","Orthogonalize","OrthogonalMatrixQ","Out","Outer","OuterPolygon","OuterPolyhedron","OutputAutoOverwrite","OutputControllabilityMatrix","OutputControllableModelQ","OutputForm","OutputFormData","OutputGrouping","OutputMathEditExpression","OutputNamePacket","OutputResponse","OutputSizeLimit","OutputStream","Over","OverBar","OverDot","Overflow","OverHat","Overlaps","Overlay","OverlayBox","OverlayBoxOptions","Overscript","OverscriptBox","OverscriptBoxOptions","OverTilde","OverVector","OverwriteTarget","OwenT","OwnValues","Package","PackingMethod","PackPaclet","PacletDataRebuild","PacletDirectoryAdd","PacletDirectoryLoad","PacletDirectoryRemove","PacletDirectoryUnload","PacletDisable","PacletEnable","PacletFind","PacletFindRemote","PacletInformation","PacletInstall","PacletInstallSubmit","PacletNewerQ","PacletObject","PacletObjectQ","PacletSite","PacletSiteObject","PacletSiteRegister","PacletSites","PacletSiteUnregister","PacletSiteUpdate","PacletUninstall","PacletUpdate","PaddedForm","Padding","PaddingLayer","PaddingSize","PadeApproximant","PadLeft","PadRight","PageBreakAbove","PageBreakBelow","PageBreakWithin","PageFooterLines","PageFooters","PageHeaderLines","PageHeaders","PageHeight","PageRankCentrality","PageTheme","PageWidth","Pagination","PairedBarChart","PairedHistogram","PairedSmoothHistogram","PairedTTest","PairedZTest","PaletteNotebook","PalettePath","PalindromeQ","Pane","PaneBox","PaneBoxOptions","Panel","PanelBox","PanelBoxOptions","Paneled","PaneSelector","PaneSelectorBox","PaneSelectorBoxOptions","PaperWidth","ParabolicCylinderD","ParagraphIndent","ParagraphSpacing","ParallelArray","ParallelCombine","ParallelDo","Parallelepiped","ParallelEvaluate","Parallelization","Parallelize","ParallelMap","ParallelNeeds","Parallelogram","ParallelProduct","ParallelSubmit","ParallelSum","ParallelTable","ParallelTry","Parameter","ParameterEstimator","ParameterMixtureDistribution","ParameterVariables","ParametricFunction","ParametricNDSolve","ParametricNDSolveValue","ParametricPlot","ParametricPlot3D","ParametricRampLayer","ParametricRegion","ParentBox","ParentCell","ParentConnect","ParentDirectory","ParentForm","Parenthesize","ParentList","ParentNotebook","ParetoDistribution","ParetoPickandsDistribution","ParkData","Part","PartBehavior","PartialCorrelationFunction","PartialD","ParticleAcceleratorData","ParticleData","Partition","PartitionGranularity","PartitionsP","PartitionsQ","PartLayer","PartOfSpeech","PartProtection","ParzenWindow","PascalDistribution","PassEventsDown","PassEventsUp","Paste","PasteAutoQuoteCharacters","PasteBoxFormInlineCells","PasteButton","Path","PathGraph","PathGraphQ","Pattern","PatternFilling","PatternSequence","PatternTest","PauliMatrix","PaulWavelet","Pause","PausedTime","PDF","PeakDetect","PeanoCurve","PearsonChiSquareTest","PearsonCorrelationTest","PearsonDistribution","PercentForm","PerfectNumber","PerfectNumberQ","PerformanceGoal","Perimeter","PeriodicBoundaryCondition","PeriodicInterpolation","Periodogram","PeriodogramArray","Permanent","Permissions","PermissionsGroup","PermissionsGroupMemberQ","PermissionsGroups","PermissionsKey","PermissionsKeys","PermutationCycles","PermutationCyclesQ","PermutationGroup","PermutationLength","PermutationList","PermutationListQ","PermutationMax","PermutationMin","PermutationOrder","PermutationPower","PermutationProduct","PermutationReplace","Permutations","PermutationSupport","Permute","PeronaMalikFilter","Perpendicular","PerpendicularBisector","PersistenceLocation","PersistenceTime","PersistentObject","PersistentObjects","PersistentValue","PersonData","PERTDistribution","PetersenGraph","PhaseMargins","PhaseRange","PhysicalSystemData","Pi","Pick","PIDData","PIDDerivativeFilter","PIDFeedforward","PIDTune","Piecewise","PiecewiseExpand","PieChart","PieChart3D","PillaiTrace","PillaiTraceTest","PingTime","Pink","PitchRecognize","Pivoting","PixelConstrained","PixelValue","PixelValuePositions","Placed","Placeholder","PlaceholderReplace","Plain","PlanarAngle","PlanarGraph","PlanarGraphQ","PlanckRadiationLaw","PlaneCurveData","PlanetaryMoonData","PlanetData","PlantData","Play","PlayRange","Plot","Plot3D","Plot3Matrix","PlotDivision","PlotJoined","PlotLabel","PlotLabels","PlotLayout","PlotLegends","PlotMarkers","PlotPoints","PlotRange","PlotRangeClipping","PlotRangeClipPlanesStyle","PlotRangePadding","PlotRegion","PlotStyle","PlotTheme","Pluralize","Plus","PlusMinus","Pochhammer","PodStates","PodWidth","Point","Point3DBox","Point3DBoxOptions","PointBox","PointBoxOptions","PointFigureChart","PointLegend","PointSize","PoissonConsulDistribution","PoissonDistribution","PoissonProcess","PoissonWindow","PolarAxes","PolarAxesOrigin","PolarGridLines","PolarPlot","PolarTicks","PoleZeroMarkers","PolyaAeppliDistribution","PolyGamma","Polygon","Polygon3DBox","Polygon3DBoxOptions","PolygonalNumber","PolygonAngle","PolygonBox","PolygonBoxOptions","PolygonCoordinates","PolygonDecomposition","PolygonHoleScale","PolygonIntersections","PolygonScale","Polyhedron","PolyhedronAngle","PolyhedronCoordinates","PolyhedronData","PolyhedronDecomposition","PolyhedronGenus","PolyLog","PolynomialExtendedGCD","PolynomialForm","PolynomialGCD","PolynomialLCM","PolynomialMod","PolynomialQ","PolynomialQuotient","PolynomialQuotientRemainder","PolynomialReduce","PolynomialRemainder","Polynomials","PoolingLayer","PopupMenu","PopupMenuBox","PopupMenuBoxOptions","PopupView","PopupWindow","Position","PositionIndex","Positive","PositiveDefiniteMatrixQ","PositiveIntegers","PositiveRationals","PositiveReals","PositiveSemidefiniteMatrixQ","PossibleZeroQ","Postfix","PostScript","Power","PowerDistribution","PowerExpand","PowerMod","PowerModList","PowerRange","PowerSpectralDensity","PowersRepresentations","PowerSymmetricPolynomial","Precedence","PrecedenceForm","Precedes","PrecedesEqual","PrecedesSlantEqual","PrecedesTilde","Precision","PrecisionGoal","PreDecrement","Predict","PredictionRoot","PredictorFunction","PredictorInformation","PredictorMeasurements","PredictorMeasurementsObject","PreemptProtect","PreferencesPath","Prefix","PreIncrement","Prepend","PrependLayer","PrependTo","PreprocessingRules","PreserveColor","PreserveImageOptions","Previous","PreviousCell","PreviousDate","PriceGraphDistribution","PrimaryPlaceholder","Prime","PrimeNu","PrimeOmega","PrimePi","PrimePowerQ","PrimeQ","Primes","PrimeZetaP","PrimitivePolynomialQ","PrimitiveRoot","PrimitiveRootList","PrincipalComponents","PrincipalValue","Print","PrintableASCIIQ","PrintAction","PrintForm","PrintingCopies","PrintingOptions","PrintingPageRange","PrintingStartingPageNumber","PrintingStyleEnvironment","Printout3D","Printout3DPreviewer","PrintPrecision","PrintTemporary","Prism","PrismBox","PrismBoxOptions","PrivateCellOptions","PrivateEvaluationOptions","PrivateFontOptions","PrivateFrontEndOptions","PrivateKey","PrivateNotebookOptions","PrivatePaths","Probability","ProbabilityDistribution","ProbabilityPlot","ProbabilityPr","ProbabilityScalePlot","ProbitModelFit","ProcessConnection","ProcessDirectory","ProcessEnvironment","Processes","ProcessEstimator","ProcessInformation","ProcessObject","ProcessParameterAssumptions","ProcessParameterQ","ProcessStateDomain","ProcessStatus","ProcessTimeDomain","Product","ProductDistribution","ProductLog","ProgressIndicator","ProgressIndicatorBox","ProgressIndicatorBoxOptions","Projection","Prolog","PromptForm","ProofObject","Properties","Property","PropertyList","PropertyValue","Proportion","Proportional","Protect","Protected","ProteinData","Pruning","PseudoInverse","PsychrometricPropertyData","PublicKey","PublisherID","PulsarData","PunctuationCharacter","Purple","Put","PutAppend","Pyramid","PyramidBox","PyramidBoxOptions","QBinomial","QFactorial","QGamma","QHypergeometricPFQ","QnDispersion","QPochhammer","QPolyGamma","QRDecomposition","QuadraticIrrationalQ","QuadraticOptimization","Quantile","QuantilePlot","Quantity","QuantityArray","QuantityDistribution","QuantityForm","QuantityMagnitude","QuantityQ","QuantityUnit","QuantityVariable","QuantityVariableCanonicalUnit","QuantityVariableDimensions","QuantityVariableIdentifier","QuantityVariablePhysicalQuantity","Quartics","QuartileDeviation","Quartiles","QuartileSkewness","Query","QueueingNetworkProcess","QueueingProcess","QueueProperties","Quiet","Quit","Quotient","QuotientRemainder","RadialGradientImage","RadialityCentrality","RadicalBox","RadicalBoxOptions","RadioButton","RadioButtonBar","RadioButtonBox","RadioButtonBoxOptions","Radon","RadonTransform","RamanujanTau","RamanujanTauL","RamanujanTauTheta","RamanujanTauZ","Ramp","Random","RandomChoice","RandomColor","RandomComplex","RandomEntity","RandomFunction","RandomGeoPosition","RandomGraph","RandomImage","RandomInstance","RandomInteger","RandomPermutation","RandomPoint","RandomPolygon","RandomPolyhedron","RandomPrime","RandomReal","RandomSample","RandomSeed","RandomSeeding","RandomVariate","RandomWalkProcess","RandomWord","Range","RangeFilter","RangeSpecification","RankedMax","RankedMin","RarerProbability","Raster","Raster3D","Raster3DBox","Raster3DBoxOptions","RasterArray","RasterBox","RasterBoxOptions","Rasterize","RasterSize","Rational","RationalFunctions","Rationalize","Rationals","Ratios","RawArray","RawBoxes","RawData","RawMedium","RayleighDistribution","Re","Read","ReadByteArray","ReadLine","ReadList","ReadProtected","ReadString","Real","RealAbs","RealBlockDiagonalForm","RealDigits","RealExponent","Reals","RealSign","Reap","RebuildPacletData","RecognitionPrior","RecognitionThreshold","Record","RecordLists","RecordSeparators","Rectangle","RectangleBox","RectangleBoxOptions","RectangleChart","RectangleChart3D","RectangularRepeatingElement","RecurrenceFilter","RecurrenceTable","RecurringDigitsForm","Red","Reduce","RefBox","ReferenceLineStyle","ReferenceMarkers","ReferenceMarkerStyle","Refine","ReflectionMatrix","ReflectionTransform","Refresh","RefreshRate","Region","RegionBinarize","RegionBoundary","RegionBoundaryStyle","RegionBounds","RegionCentroid","RegionDifference","RegionDimension","RegionDisjoint","RegionDistance","RegionDistanceFunction","RegionEmbeddingDimension","RegionEqual","RegionFillingStyle","RegionFunction","RegionImage","RegionIntersection","RegionMeasure","RegionMember","RegionMemberFunction","RegionMoment","RegionNearest","RegionNearestFunction","RegionPlot","RegionPlot3D","RegionProduct","RegionQ","RegionResize","RegionSize","RegionSymmetricDifference","RegionUnion","RegionWithin","RegisterExternalEvaluator","RegularExpression","Regularization","RegularlySampledQ","RegularPolygon","ReIm","ReImLabels","ReImPlot","ReImStyle","Reinstall","RelationalDatabase","RelationGraph","Release","ReleaseHold","ReliabilityDistribution","ReliefImage","ReliefPlot","RemoteAuthorizationCaching","RemoteConnect","RemoteConnectionObject","RemoteFile","RemoteRun","RemoteRunProcess","Remove","RemoveAlphaChannel","RemoveAsynchronousTask","RemoveAudioStream","RemoveBackground","RemoveChannelListener","RemoveChannelSubscribers","Removed","RemoveDiacritics","RemoveInputStreamMethod","RemoveOutputStreamMethod","RemoveProperty","RemoveScheduledTask","RemoveUsers","RemoveVideoStream","RenameDirectory","RenameFile","RenderAll","RenderingOptions","RenewalProcess","RenkoChart","RepairMesh","Repeated","RepeatedNull","RepeatedString","RepeatedTiming","RepeatingElement","Replace","ReplaceAll","ReplaceHeldPart","ReplaceImageValue","ReplaceList","ReplacePart","ReplacePixelValue","ReplaceRepeated","ReplicateLayer","RequiredPhysicalQuantities","Resampling","ResamplingAlgorithmData","ResamplingMethod","Rescale","RescalingTransform","ResetDirectory","ResetMenusPacket","ResetScheduledTask","ReshapeLayer","Residue","ResizeLayer","Resolve","ResourceAcquire","ResourceData","ResourceFunction","ResourceObject","ResourceRegister","ResourceRemove","ResourceSearch","ResourceSubmissionObject","ResourceSubmit","ResourceSystemBase","ResourceSystemPath","ResourceUpdate","ResourceVersion","ResponseForm","Rest","RestartInterval","Restricted","Resultant","ResumePacket","Return","ReturnEntersInput","ReturnExpressionPacket","ReturnInputFormPacket","ReturnPacket","ReturnReceiptFunction","ReturnTextPacket","Reverse","ReverseApplied","ReverseBiorthogonalSplineWavelet","ReverseElement","ReverseEquilibrium","ReverseGraph","ReverseSort","ReverseSortBy","ReverseUpEquilibrium","RevolutionAxis","RevolutionPlot3D","RGBColor","RiccatiSolve","RiceDistribution","RidgeFilter","RiemannR","RiemannSiegelTheta","RiemannSiegelZ","RiemannXi","Riffle","Right","RightArrow","RightArrowBar","RightArrowLeftArrow","RightComposition","RightCosetRepresentative","RightDownTeeVector","RightDownVector","RightDownVectorBar","RightTee","RightTeeArrow","RightTeeVector","RightTriangle","RightTriangleBar","RightTriangleEqual","RightUpDownVector","RightUpTeeVector","RightUpVector","RightUpVectorBar","RightVector","RightVectorBar","RiskAchievementImportance","RiskReductionImportance","RogersTanimotoDissimilarity","RollPitchYawAngles","RollPitchYawMatrix","RomanNumeral","Root","RootApproximant","RootIntervals","RootLocusPlot","RootMeanSquare","RootOfUnityQ","RootReduce","Roots","RootSum","Rotate","RotateLabel","RotateLeft","RotateRight","RotationAction","RotationBox","RotationBoxOptions","RotationMatrix","RotationTransform","Round","RoundImplies","RoundingRadius","Row","RowAlignments","RowBackgrounds","RowBox","RowHeights","RowLines","RowMinHeight","RowReduce","RowsEqual","RowSpacings","RSolve","RSolveValue","RudinShapiro","RudvalisGroupRu","Rule","RuleCondition","RuleDelayed","RuleForm","RulePlot","RulerUnits","Run","RunProcess","RunScheduledTask","RunThrough","RuntimeAttributes","RuntimeOptions","RussellRaoDissimilarity","SameQ","SameTest","SameTestProperties","SampledEntityClass","SampleDepth","SampledSoundFunction","SampledSoundList","SampleRate","SamplingPeriod","SARIMAProcess","SARMAProcess","SASTriangle","SatelliteData","SatisfiabilityCount","SatisfiabilityInstances","SatisfiableQ","Saturday","Save","Saveable","SaveAutoDelete","SaveConnection","SaveDefinitions","SavitzkyGolayMatrix","SawtoothWave","Scale","Scaled","ScaleDivisions","ScaledMousePosition","ScaleOrigin","ScalePadding","ScaleRanges","ScaleRangeStyle","ScalingFunctions","ScalingMatrix","ScalingTransform","Scan","ScheduledTask","ScheduledTaskActiveQ","ScheduledTaskInformation","ScheduledTaskInformationData","ScheduledTaskObject","ScheduledTasks","SchurDecomposition","ScientificForm","ScientificNotationThreshold","ScorerGi","ScorerGiPrime","ScorerHi","ScorerHiPrime","ScreenRectangle","ScreenStyleEnvironment","ScriptBaselineShifts","ScriptForm","ScriptLevel","ScriptMinSize","ScriptRules","ScriptSizeMultipliers","Scrollbars","ScrollingOptions","ScrollPosition","SearchAdjustment","SearchIndexObject","SearchIndices","SearchQueryString","SearchResultObject","Sec","Sech","SechDistribution","SecondOrderConeOptimization","SectionGrouping","SectorChart","SectorChart3D","SectorOrigin","SectorSpacing","SecuredAuthenticationKey","SecuredAuthenticationKeys","SeedRandom","Select","Selectable","SelectComponents","SelectedCells","SelectedNotebook","SelectFirst","Selection","SelectionAnimate","SelectionCell","SelectionCellCreateCell","SelectionCellDefaultStyle","SelectionCellParentStyle","SelectionCreateCell","SelectionDebuggerTag","SelectionDuplicateCell","SelectionEvaluate","SelectionEvaluateCreateCell","SelectionMove","SelectionPlaceholder","SelectionSetStyle","SelectWithContents","SelfLoops","SelfLoopStyle","SemanticImport","SemanticImportString","SemanticInterpretation","SemialgebraicComponentInstances","SemidefiniteOptimization","SendMail","SendMessage","Sequence","SequenceAlignment","SequenceAttentionLayer","SequenceCases","SequenceCount","SequenceFold","SequenceFoldList","SequenceForm","SequenceHold","SequenceLastLayer","SequenceMostLayer","SequencePosition","SequencePredict","SequencePredictorFunction","SequenceReplace","SequenceRestLayer","SequenceReverseLayer","SequenceSplit","Series","SeriesCoefficient","SeriesData","SeriesTermGoal","ServiceConnect","ServiceDisconnect","ServiceExecute","ServiceObject","ServiceRequest","ServiceResponse","ServiceSubmit","SessionSubmit","SessionTime","Set","SetAccuracy","SetAlphaChannel","SetAttributes","Setbacks","SetBoxFormNamesPacket","SetCloudDirectory","SetCookies","SetDelayed","SetDirectory","SetEnvironment","SetEvaluationNotebook","SetFileDate","SetFileLoadingContext","SetNotebookStatusLine","SetOptions","SetOptionsPacket","SetPermissions","SetPrecision","SetProperty","SetSecuredAuthenticationKey","SetSelectedNotebook","SetSharedFunction","SetSharedVariable","SetSpeechParametersPacket","SetStreamPosition","SetSystemModel","SetSystemOptions","Setter","SetterBar","SetterBox","SetterBoxOptions","Setting","SetUsers","SetValue","Shading","Shallow","ShannonWavelet","ShapiroWilkTest","Share","SharingList","Sharpen","ShearingMatrix","ShearingTransform","ShellRegion","ShenCastanMatrix","ShiftedGompertzDistribution","ShiftRegisterSequence","Short","ShortDownArrow","Shortest","ShortestMatch","ShortestPathFunction","ShortLeftArrow","ShortRightArrow","ShortTimeFourier","ShortTimeFourierData","ShortUpArrow","Show","ShowAutoConvert","ShowAutoSpellCheck","ShowAutoStyles","ShowCellBracket","ShowCellLabel","ShowCellTags","ShowClosedCellArea","ShowCodeAssist","ShowContents","ShowControls","ShowCursorTracker","ShowGroupOpenCloseIcon","ShowGroupOpener","ShowInvisibleCharacters","ShowPageBreaks","ShowPredictiveInterface","ShowSelection","ShowShortBoxForm","ShowSpecialCharacters","ShowStringCharacters","ShowSyntaxStyles","ShrinkingDelay","ShrinkWrapBoundingBox","SiderealTime","SiegelTheta","SiegelTukeyTest","SierpinskiCurve","SierpinskiMesh","Sign","Signature","SignedRankTest","SignedRegionDistance","SignificanceLevel","SignPadding","SignTest","SimilarityRules","SimpleGraph","SimpleGraphQ","SimplePolygonQ","SimplePolyhedronQ","Simplex","Simplify","Sin","Sinc","SinghMaddalaDistribution","SingleEvaluation","SingleLetterItalics","SingleLetterStyle","SingularValueDecomposition","SingularValueList","SingularValuePlot","SingularValues","Sinh","SinhIntegral","SinIntegral","SixJSymbol","Skeleton","SkeletonTransform","SkellamDistribution","Skewness","SkewNormalDistribution","SkinStyle","Skip","SliceContourPlot3D","SliceDensityPlot3D","SliceDistribution","SliceVectorPlot3D","Slider","Slider2D","Slider2DBox","Slider2DBoxOptions","SliderBox","SliderBoxOptions","SlideView","Slot","SlotSequence","Small","SmallCircle","Smaller","SmithDecomposition","SmithDelayCompensator","SmithWatermanSimilarity","SmoothDensityHistogram","SmoothHistogram","SmoothHistogram3D","SmoothKernelDistribution","SnDispersion","Snippet","SnubPolyhedron","SocialMediaData","Socket","SocketConnect","SocketListen","SocketListener","SocketObject","SocketOpen","SocketReadMessage","SocketReadyQ","Sockets","SocketWaitAll","SocketWaitNext","SoftmaxLayer","SokalSneathDissimilarity","SolarEclipse","SolarSystemFeatureData","SolidAngle","SolidData","SolidRegionQ","Solve","SolveAlways","SolveDelayed","Sort","SortBy","SortedBy","SortedEntityClass","Sound","SoundAndGraphics","SoundNote","SoundVolume","SourceLink","Sow","Space","SpaceCurveData","SpaceForm","Spacer","Spacings","Span","SpanAdjustments","SpanCharacterRounding","SpanFromAbove","SpanFromBoth","SpanFromLeft","SpanLineThickness","SpanMaxSize","SpanMinSize","SpanningCharacters","SpanSymmetric","SparseArray","SpatialGraphDistribution","SpatialMedian","SpatialTransformationLayer","Speak","SpeakerMatchQ","SpeakTextPacket","SpearmanRankTest","SpearmanRho","SpeciesData","SpecificityGoal","SpectralLineData","Spectrogram","SpectrogramArray","Specularity","SpeechCases","SpeechInterpreter","SpeechRecognize","SpeechSynthesize","SpellingCorrection","SpellingCorrectionList","SpellingDictionaries","SpellingDictionariesPath","SpellingOptions","SpellingSuggestionsPacket","Sphere","SphereBox","SpherePoints","SphericalBesselJ","SphericalBesselY","SphericalHankelH1","SphericalHankelH2","SphericalHarmonicY","SphericalPlot3D","SphericalRegion","SphericalShell","SpheroidalEigenvalue","SpheroidalJoiningFactor","SpheroidalPS","SpheroidalPSPrime","SpheroidalQS","SpheroidalQSPrime","SpheroidalRadialFactor","SpheroidalS1","SpheroidalS1Prime","SpheroidalS2","SpheroidalS2Prime","Splice","SplicedDistribution","SplineClosed","SplineDegree","SplineKnots","SplineWeights","Split","SplitBy","SpokenString","Sqrt","SqrtBox","SqrtBoxOptions","Square","SquaredEuclideanDistance","SquareFreeQ","SquareIntersection","SquareMatrixQ","SquareRepeatingElement","SquaresR","SquareSubset","SquareSubsetEqual","SquareSuperset","SquareSupersetEqual","SquareUnion","SquareWave","SSSTriangle","StabilityMargins","StabilityMarginsStyle","StableDistribution","Stack","StackBegin","StackComplete","StackedDateListPlot","StackedListPlot","StackInhibit","StadiumShape","StandardAtmosphereData","StandardDeviation","StandardDeviationFilter","StandardForm","Standardize","Standardized","StandardOceanData","StandbyDistribution","Star","StarClusterData","StarData","StarGraph","StartAsynchronousTask","StartExternalSession","StartingStepSize","StartOfLine","StartOfString","StartProcess","StartScheduledTask","StartupSound","StartWebSession","StateDimensions","StateFeedbackGains","StateOutputEstimator","StateResponse","StateSpaceModel","StateSpaceRealization","StateSpaceTransform","StateTransformationLinearize","StationaryDistribution","StationaryWaveletPacketTransform","StationaryWaveletTransform","StatusArea","StatusCentrality","StepMonitor","StereochemistryElements","StieltjesGamma","StippleShading","StirlingS1","StirlingS2","StopAsynchronousTask","StoppingPowerData","StopScheduledTask","StrataVariables","StratonovichProcess","StreamColorFunction","StreamColorFunctionScaling","StreamDensityPlot","StreamMarkers","StreamPlot","StreamPoints","StreamPosition","Streams","StreamScale","StreamStyle","String","StringBreak","StringByteCount","StringCases","StringContainsQ","StringCount","StringDelete","StringDrop","StringEndsQ","StringExpression","StringExtract","StringForm","StringFormat","StringFreeQ","StringInsert","StringJoin","StringLength","StringMatchQ","StringPadLeft","StringPadRight","StringPart","StringPartition","StringPosition","StringQ","StringRepeat","StringReplace","StringReplaceList","StringReplacePart","StringReverse","StringRiffle","StringRotateLeft","StringRotateRight","StringSkeleton","StringSplit","StringStartsQ","StringTake","StringTemplate","StringToByteArray","StringToStream","StringTrim","StripBoxes","StripOnInput","StripWrapperBoxes","StrokeForm","StructuralImportance","StructuredArray","StructuredArrayHeadQ","StructuredSelection","StruveH","StruveL","Stub","StudentTDistribution","Style","StyleBox","StyleBoxAutoDelete","StyleData","StyleDefinitions","StyleForm","StyleHints","StyleKeyMapping","StyleMenuListing","StyleNameDialogSettings","StyleNames","StylePrint","StyleSheetPath","Subdivide","Subfactorial","Subgraph","SubMinus","SubPlus","SubresultantPolynomialRemainders","SubresultantPolynomials","Subresultants","Subscript","SubscriptBox","SubscriptBoxOptions","Subscripted","Subsequences","Subset","SubsetCases","SubsetCount","SubsetEqual","SubsetMap","SubsetPosition","SubsetQ","SubsetReplace","Subsets","SubStar","SubstitutionSystem","Subsuperscript","SubsuperscriptBox","SubsuperscriptBoxOptions","SubtitleEncoding","SubtitleTracks","Subtract","SubtractFrom","SubtractSides","SubValues","Succeeds","SucceedsEqual","SucceedsSlantEqual","SucceedsTilde","Success","SuchThat","Sum","SumConvergence","SummationLayer","Sunday","SunPosition","Sunrise","Sunset","SuperDagger","SuperMinus","SupernovaData","SuperPlus","Superscript","SuperscriptBox","SuperscriptBoxOptions","Superset","SupersetEqual","SuperStar","Surd","SurdForm","SurfaceAppearance","SurfaceArea","SurfaceColor","SurfaceData","SurfaceGraphics","SurvivalDistribution","SurvivalFunction","SurvivalModel","SurvivalModelFit","SuspendPacket","SuzukiDistribution","SuzukiGroupSuz","SwatchLegend","Switch","Symbol","SymbolName","SymletWavelet","Symmetric","SymmetricGroup","SymmetricKey","SymmetricMatrixQ","SymmetricPolynomial","SymmetricReduction","Symmetrize","SymmetrizedArray","SymmetrizedArrayRules","SymmetrizedDependentComponents","SymmetrizedIndependentComponents","SymmetrizedReplacePart","SynchronousInitialization","SynchronousUpdating","Synonyms","Syntax","SyntaxForm","SyntaxInformation","SyntaxLength","SyntaxPacket","SyntaxQ","SynthesizeMissingValues","SystemCredential","SystemCredentialData","SystemCredentialKey","SystemCredentialKeys","SystemCredentialStoreObject","SystemDialogInput","SystemException","SystemGet","SystemHelpPath","SystemInformation","SystemInformationData","SystemInstall","SystemModel","SystemModeler","SystemModelExamples","SystemModelLinearize","SystemModelParametricSimulate","SystemModelPlot","SystemModelProgressReporting","SystemModelReliability","SystemModels","SystemModelSimulate","SystemModelSimulateSensitivity","SystemModelSimulationData","SystemOpen","SystemOptions","SystemProcessData","SystemProcesses","SystemsConnectionsModel","SystemsModelDelay","SystemsModelDelayApproximate","SystemsModelDelete","SystemsModelDimensions","SystemsModelExtract","SystemsModelFeedbackConnect","SystemsModelLabels","SystemsModelLinearity","SystemsModelMerge","SystemsModelOrder","SystemsModelParallelConnect","SystemsModelSeriesConnect","SystemsModelStateFeedbackConnect","SystemsModelVectorRelativeOrders","SystemStub","SystemTest","Tab","TabFilling","Table","TableAlignments","TableDepth","TableDirections","TableForm","TableHeadings","TableSpacing","TableView","TableViewBox","TableViewBoxBackground","TableViewBoxItemSize","TableViewBoxOptions","TabSpacings","TabView","TabViewBox","TabViewBoxOptions","TagBox","TagBoxNote","TagBoxOptions","TaggingRules","TagSet","TagSetDelayed","TagStyle","TagUnset","Take","TakeDrop","TakeLargest","TakeLargestBy","TakeList","TakeSmallest","TakeSmallestBy","TakeWhile","Tally","Tan","Tanh","TargetDevice","TargetFunctions","TargetSystem","TargetUnits","TaskAbort","TaskExecute","TaskObject","TaskRemove","TaskResume","Tasks","TaskSuspend","TaskWait","TautologyQ","TelegraphProcess","TemplateApply","TemplateArgBox","TemplateBox","TemplateBoxOptions","TemplateEvaluate","TemplateExpression","TemplateIf","TemplateObject","TemplateSequence","TemplateSlot","TemplateSlotSequence","TemplateUnevaluated","TemplateVerbatim","TemplateWith","TemporalData","TemporalRegularity","Temporary","TemporaryVariable","TensorContract","TensorDimensions","TensorExpand","TensorProduct","TensorQ","TensorRank","TensorReduce","TensorSymmetry","TensorTranspose","TensorWedge","TestID","TestReport","TestReportObject","TestResultObject","Tetrahedron","TetrahedronBox","TetrahedronBoxOptions","TeXForm","TeXSave","Text","Text3DBox","Text3DBoxOptions","TextAlignment","TextBand","TextBoundingBox","TextBox","TextCases","TextCell","TextClipboardType","TextContents","TextData","TextElement","TextForm","TextGrid","TextJustification","TextLine","TextPacket","TextParagraph","TextPosition","TextRecognize","TextSearch","TextSearchReport","TextSentences","TextString","TextStructure","TextStyle","TextTranslation","Texture","TextureCoordinateFunction","TextureCoordinateScaling","TextWords","Therefore","ThermodynamicData","ThermometerGauge","Thick","Thickness","Thin","Thinning","ThisLink","ThompsonGroupTh","Thread","ThreadingLayer","ThreeJSymbol","Threshold","Through","Throw","ThueMorse","Thumbnail","Thursday","Ticks","TicksStyle","TideData","Tilde","TildeEqual","TildeFullEqual","TildeTilde","TimeConstrained","TimeConstraint","TimeDirection","TimeFormat","TimeGoal","TimelinePlot","TimeObject","TimeObjectQ","TimeRemaining","Times","TimesBy","TimeSeries","TimeSeriesAggregate","TimeSeriesForecast","TimeSeriesInsert","TimeSeriesInvertibility","TimeSeriesMap","TimeSeriesMapThread","TimeSeriesModel","TimeSeriesModelFit","TimeSeriesResample","TimeSeriesRescale","TimeSeriesShift","TimeSeriesThread","TimeSeriesWindow","TimeUsed","TimeValue","TimeWarpingCorrespondence","TimeWarpingDistance","TimeZone","TimeZoneConvert","TimeZoneOffset","Timing","Tiny","TitleGrouping","TitsGroupT","ToBoxes","ToCharacterCode","ToColor","ToContinuousTimeModel","ToDate","Today","ToDiscreteTimeModel","ToEntity","ToeplitzMatrix","ToExpression","ToFileName","Together","Toggle","ToggleFalse","Toggler","TogglerBar","TogglerBox","TogglerBoxOptions","ToHeldExpression","ToInvertibleTimeSeries","TokenWords","Tolerance","ToLowerCase","Tomorrow","ToNumberField","TooBig","Tooltip","TooltipBox","TooltipBoxOptions","TooltipDelay","TooltipStyle","ToonShading","Top","TopHatTransform","ToPolarCoordinates","TopologicalSort","ToRadicals","ToRules","ToSphericalCoordinates","ToString","Total","TotalHeight","TotalLayer","TotalVariationFilter","TotalWidth","TouchPosition","TouchscreenAutoZoom","TouchscreenControlPlacement","ToUpperCase","Tr","Trace","TraceAbove","TraceAction","TraceBackward","TraceDepth","TraceDialog","TraceForward","TraceInternal","TraceLevel","TraceOff","TraceOn","TraceOriginal","TracePrint","TraceScan","TrackedSymbols","TrackingFunction","TracyWidomDistribution","TradingChart","TraditionalForm","TraditionalFunctionNotation","TraditionalNotation","TraditionalOrder","TrainingProgressCheckpointing","TrainingProgressFunction","TrainingProgressMeasurements","TrainingProgressReporting","TrainingStoppingCriterion","TrainingUpdateSchedule","TransferFunctionCancel","TransferFunctionExpand","TransferFunctionFactor","TransferFunctionModel","TransferFunctionPoles","TransferFunctionTransform","TransferFunctionZeros","TransformationClass","TransformationFunction","TransformationFunctions","TransformationMatrix","TransformedDistribution","TransformedField","TransformedProcess","TransformedRegion","TransitionDirection","TransitionDuration","TransitionEffect","TransitiveClosureGraph","TransitiveReductionGraph","Translate","TranslationOptions","TranslationTransform","Transliterate","Transparent","TransparentColor","Transpose","TransposeLayer","TrapSelection","TravelDirections","TravelDirectionsData","TravelDistance","TravelDistanceList","TravelMethod","TravelTime","TreeForm","TreeGraph","TreeGraphQ","TreePlot","TrendStyle","Triangle","TriangleCenter","TriangleConstruct","TriangleMeasurement","TriangleWave","TriangularDistribution","TriangulateMesh","Trig","TrigExpand","TrigFactor","TrigFactorList","Trigger","TrigReduce","TrigToExp","TrimmedMean","TrimmedVariance","TropicalStormData","True","TrueQ","TruncatedDistribution","TruncatedPolyhedron","TsallisQExponentialDistribution","TsallisQGaussianDistribution","TTest","Tube","TubeBezierCurveBox","TubeBezierCurveBoxOptions","TubeBox","TubeBoxOptions","TubeBSplineCurveBox","TubeBSplineCurveBoxOptions","Tuesday","TukeyLambdaDistribution","TukeyWindow","TunnelData","Tuples","TuranGraph","TuringMachine","TuttePolynomial","TwoWayRule","Typed","TypeSpecifier","UnateQ","Uncompress","UnconstrainedParameters","Undefined","UnderBar","Underflow","Underlined","Underoverscript","UnderoverscriptBox","UnderoverscriptBoxOptions","Underscript","UnderscriptBox","UnderscriptBoxOptions","UnderseaFeatureData","UndirectedEdge","UndirectedGraph","UndirectedGraphQ","UndoOptions","UndoTrackedVariables","Unequal","UnequalTo","Unevaluated","UniformDistribution","UniformGraphDistribution","UniformPolyhedron","UniformSumDistribution","Uninstall","Union","UnionedEntityClass","UnionPlus","Unique","UnitaryMatrixQ","UnitBox","UnitConvert","UnitDimensions","Unitize","UnitRootTest","UnitSimplify","UnitStep","UnitSystem","UnitTriangle","UnitVector","UnitVectorLayer","UnityDimensions","UniverseModelData","UniversityData","UnixTime","Unprotect","UnregisterExternalEvaluator","UnsameQ","UnsavedVariables","Unset","UnsetShared","UntrackedVariables","Up","UpArrow","UpArrowBar","UpArrowDownArrow","Update","UpdateDynamicObjects","UpdateDynamicObjectsSynchronous","UpdateInterval","UpdatePacletSites","UpdateSearchIndex","UpDownArrow","UpEquilibrium","UpperCaseQ","UpperLeftArrow","UpperRightArrow","UpperTriangularize","UpperTriangularMatrixQ","Upsample","UpSet","UpSetDelayed","UpTee","UpTeeArrow","UpTo","UpValues","URL","URLBuild","URLDecode","URLDispatcher","URLDownload","URLDownloadSubmit","URLEncode","URLExecute","URLExpand","URLFetch","URLFetchAsynchronous","URLParse","URLQueryDecode","URLQueryEncode","URLRead","URLResponseTime","URLSave","URLSaveAsynchronous","URLShorten","URLSubmit","UseGraphicsRange","UserDefinedWavelet","Using","UsingFrontEnd","UtilityFunction","V2Get","ValenceErrorHandling","ValidationLength","ValidationSet","Value","ValueBox","ValueBoxOptions","ValueDimensions","ValueForm","ValuePreprocessingFunction","ValueQ","Values","ValuesData","Variables","Variance","VarianceEquivalenceTest","VarianceEstimatorFunction","VarianceGammaDistribution","VarianceTest","VectorAngle","VectorAround","VectorAspectRatio","VectorColorFunction","VectorColorFunctionScaling","VectorDensityPlot","VectorGlyphData","VectorGreater","VectorGreaterEqual","VectorLess","VectorLessEqual","VectorMarkers","VectorPlot","VectorPlot3D","VectorPoints","VectorQ","VectorRange","Vectors","VectorScale","VectorScaling","VectorSizes","VectorStyle","Vee","Verbatim","Verbose","VerboseConvertToPostScriptPacket","VerificationTest","VerifyConvergence","VerifyDerivedKey","VerifyDigitalSignature","VerifyFileSignature","VerifyInterpretation","VerifySecurityCertificates","VerifySolutions","VerifyTestAssumptions","Version","VersionedPreferences","VersionNumber","VertexAdd","VertexCapacity","VertexColors","VertexComponent","VertexConnectivity","VertexContract","VertexCoordinateRules","VertexCoordinates","VertexCorrelationSimilarity","VertexCosineSimilarity","VertexCount","VertexCoverQ","VertexDataCoordinates","VertexDegree","VertexDelete","VertexDiceSimilarity","VertexEccentricity","VertexInComponent","VertexInDegree","VertexIndex","VertexJaccardSimilarity","VertexLabeling","VertexLabels","VertexLabelStyle","VertexList","VertexNormals","VertexOutComponent","VertexOutDegree","VertexQ","VertexRenderingFunction","VertexReplace","VertexShape","VertexShapeFunction","VertexSize","VertexStyle","VertexTextureCoordinates","VertexWeight","VertexWeightedGraphQ","Vertical","VerticalBar","VerticalForm","VerticalGauge","VerticalSeparator","VerticalSlider","VerticalTilde","Video","VideoEncoding","VideoExtractFrames","VideoFrameList","VideoFrameMap","VideoPause","VideoPlay","VideoQ","VideoStop","VideoStream","VideoStreams","VideoTimeSeries","VideoTracks","VideoTrim","ViewAngle","ViewCenter","ViewMatrix","ViewPoint","ViewPointSelectorSettings","ViewPort","ViewProjection","ViewRange","ViewVector","ViewVertical","VirtualGroupData","Visible","VisibleCell","VoiceStyleData","VoigtDistribution","VolcanoData","Volume","VonMisesDistribution","VoronoiMesh","WaitAll","WaitAsynchronousTask","WaitNext","WaitUntil","WakebyDistribution","WalleniusHypergeometricDistribution","WaringYuleDistribution","WarpingCorrespondence","WarpingDistance","WatershedComponents","WatsonUSquareTest","WattsStrogatzGraphDistribution","WaveletBestBasis","WaveletFilterCoefficients","WaveletImagePlot","WaveletListPlot","WaveletMapIndexed","WaveletMatrixPlot","WaveletPhi","WaveletPsi","WaveletScale","WaveletScalogram","WaveletThreshold","WeaklyConnectedComponents","WeaklyConnectedGraphComponents","WeaklyConnectedGraphQ","WeakStationarity","WeatherData","WeatherForecastData","WebAudioSearch","WebElementObject","WeberE","WebExecute","WebImage","WebImageSearch","WebSearch","WebSessionObject","WebSessions","WebWindowObject","Wedge","Wednesday","WeibullDistribution","WeierstrassE1","WeierstrassE2","WeierstrassE3","WeierstrassEta1","WeierstrassEta2","WeierstrassEta3","WeierstrassHalfPeriods","WeierstrassHalfPeriodW1","WeierstrassHalfPeriodW2","WeierstrassHalfPeriodW3","WeierstrassInvariantG2","WeierstrassInvariantG3","WeierstrassInvariants","WeierstrassP","WeierstrassPPrime","WeierstrassSigma","WeierstrassZeta","WeightedAdjacencyGraph","WeightedAdjacencyMatrix","WeightedData","WeightedGraphQ","Weights","WelchWindow","WheelGraph","WhenEvent","Which","While","White","WhiteNoiseProcess","WhitePoint","Whitespace","WhitespaceCharacter","WhittakerM","WhittakerW","WienerFilter","WienerProcess","WignerD","WignerSemicircleDistribution","WikidataData","WikidataSearch","WikipediaData","WikipediaSearch","WilksW","WilksWTest","WindDirectionData","WindingCount","WindingPolygon","WindowClickSelect","WindowElements","WindowFloating","WindowFrame","WindowFrameElements","WindowMargins","WindowMovable","WindowOpacity","WindowPersistentStyles","WindowSelected","WindowSize","WindowStatusArea","WindowTitle","WindowToolbars","WindowWidth","WindSpeedData","WindVectorData","WinsorizedMean","WinsorizedVariance","WishartMatrixDistribution","With","WolframAlpha","WolframAlphaDate","WolframAlphaQuantity","WolframAlphaResult","WolframLanguageData","Word","WordBoundary","WordCharacter","WordCloud","WordCount","WordCounts","WordData","WordDefinition","WordFrequency","WordFrequencyData","WordList","WordOrientation","WordSearch","WordSelectionFunction","WordSeparators","WordSpacings","WordStem","WordTranslation","WorkingPrecision","WrapAround","Write","WriteLine","WriteString","Wronskian","XMLElement","XMLObject","XMLTemplate","Xnor","Xor","XYZColor","Yellow","Yesterday","YuleDissimilarity","ZernikeR","ZeroSymmetric","ZeroTest","ZeroWidthTimes","Zeta","ZetaZero","ZIPCodeData","ZipfDistribution","ZoomCenter","ZoomFactor","ZTest","ZTransform","$Aborted","$ActivationGroupID","$ActivationKey","$ActivationUserRegistered","$AddOnsDirectory","$AllowDataUpdates","$AllowExternalChannelFunctions","$AllowInternet","$AssertFunction","$Assumptions","$AsynchronousTask","$AudioDecoders","$AudioEncoders","$AudioInputDevices","$AudioOutputDevices","$BaseDirectory","$BasePacletsDirectory","$BatchInput","$BatchOutput","$BlockchainBase","$BoxForms","$ByteOrdering","$CacheBaseDirectory","$Canceled","$ChannelBase","$CharacterEncoding","$CharacterEncodings","$CloudAccountName","$CloudBase","$CloudConnected","$CloudConnection","$CloudCreditsAvailable","$CloudEvaluation","$CloudExpressionBase","$CloudObjectNameFormat","$CloudObjectURLType","$CloudRootDirectory","$CloudSymbolBase","$CloudUserID","$CloudUserUUID","$CloudVersion","$CloudVersionNumber","$CloudWolframEngineVersionNumber","$CommandLine","$CompilationTarget","$ConditionHold","$ConfiguredKernels","$Context","$ContextPath","$ControlActiveSetting","$Cookies","$CookieStore","$CreationDate","$CurrentLink","$CurrentTask","$CurrentWebSession","$DataStructures","$DateStringFormat","$DefaultAudioInputDevice","$DefaultAudioOutputDevice","$DefaultFont","$DefaultFrontEnd","$DefaultImagingDevice","$DefaultLocalBase","$DefaultMailbox","$DefaultNetworkInterface","$DefaultPath","$DefaultProxyRules","$DefaultSystemCredentialStore","$Display","$DisplayFunction","$DistributedContexts","$DynamicEvaluation","$Echo","$EmbedCodeEnvironments","$EmbeddableServices","$EntityStores","$Epilog","$EvaluationCloudBase","$EvaluationCloudObject","$EvaluationEnvironment","$ExportFormats","$ExternalIdentifierTypes","$ExternalStorageBase","$Failed","$FinancialDataSource","$FontFamilies","$FormatType","$FrontEnd","$FrontEndSession","$GeoEntityTypes","$GeoLocation","$GeoLocationCity","$GeoLocationCountry","$GeoLocationPrecision","$GeoLocationSource","$HistoryLength","$HomeDirectory","$HTMLExportRules","$HTTPCookies","$HTTPRequest","$IgnoreEOF","$ImageFormattingWidth","$ImageResolution","$ImagingDevice","$ImagingDevices","$ImportFormats","$IncomingMailSettings","$InitialDirectory","$Initialization","$InitializationContexts","$Input","$InputFileName","$InputStreamMethods","$Inspector","$InstallationDate","$InstallationDirectory","$InterfaceEnvironment","$InterpreterTypes","$IterationLimit","$KernelCount","$KernelID","$Language","$LaunchDirectory","$LibraryPath","$LicenseExpirationDate","$LicenseID","$LicenseProcesses","$LicenseServer","$LicenseSubprocesses","$LicenseType","$Line","$Linked","$LinkSupported","$LoadedFiles","$LocalBase","$LocalSymbolBase","$MachineAddresses","$MachineDomain","$MachineDomains","$MachineEpsilon","$MachineID","$MachineName","$MachinePrecision","$MachineType","$MaxExtraPrecision","$MaxLicenseProcesses","$MaxLicenseSubprocesses","$MaxMachineNumber","$MaxNumber","$MaxPiecewiseCases","$MaxPrecision","$MaxRootDegree","$MessageGroups","$MessageList","$MessagePrePrint","$Messages","$MinMachineNumber","$MinNumber","$MinorReleaseNumber","$MinPrecision","$MobilePhone","$ModuleNumber","$NetworkConnected","$NetworkInterfaces","$NetworkLicense","$NewMessage","$NewSymbol","$NotebookInlineStorageLimit","$Notebooks","$NoValue","$NumberMarks","$Off","$OperatingSystem","$Output","$OutputForms","$OutputSizeLimit","$OutputStreamMethods","$Packages","$ParentLink","$ParentProcessID","$PasswordFile","$PatchLevelID","$Path","$PathnameSeparator","$PerformanceGoal","$Permissions","$PermissionsGroupBase","$PersistenceBase","$PersistencePath","$PipeSupported","$PlotTheme","$Post","$Pre","$PreferencesDirectory","$PreInitialization","$PrePrint","$PreRead","$PrintForms","$PrintLiteral","$Printout3DPreviewer","$ProcessID","$ProcessorCount","$ProcessorType","$ProductInformation","$ProgramName","$PublisherID","$RandomState","$RecursionLimit","$RegisteredDeviceClasses","$RegisteredUserName","$ReleaseNumber","$RequesterAddress","$RequesterWolframID","$RequesterWolframUUID","$RootDirectory","$ScheduledTask","$ScriptCommandLine","$ScriptInputString","$SecuredAuthenticationKeyTokens","$ServiceCreditsAvailable","$Services","$SessionID","$SetParentLink","$SharedFunctions","$SharedVariables","$SoundDisplay","$SoundDisplayFunction","$SourceLink","$SSHAuthentication","$SubtitleDecoders","$SubtitleEncoders","$SummaryBoxDataSizeLimit","$SuppressInputFormHeads","$SynchronousEvaluation","$SyntaxHandler","$System","$SystemCharacterEncoding","$SystemCredentialStore","$SystemID","$SystemMemory","$SystemShell","$SystemTimeZone","$SystemWordLength","$TemplatePath","$TemporaryDirectory","$TemporaryPrefix","$TestFileName","$TextStyle","$TimedOut","$TimeUnit","$TimeZone","$TimeZoneEntity","$TopDirectory","$TraceOff","$TraceOn","$TracePattern","$TracePostAction","$TracePreAction","$UnitSystem","$Urgent","$UserAddOnsDirectory","$UserAgentLanguages","$UserAgentMachine","$UserAgentName","$UserAgentOperatingSystem","$UserAgentString","$UserAgentVersion","$UserBaseDirectory","$UserBasePacletsDirectory","$UserDocumentsDirectory","$Username","$UserName","$UserURLBase","$Version","$VersionNumber","$VideoDecoders","$VideoEncoders","$VoiceStyles","$WolframDocumentsDirectory","$WolframID","$WolframUUID"]
-;function t(e){return e?"string"==typeof e?e:e.source:null}function i(e){
-return o("(",e,")?")}function o(...e){return e.map((e=>t(e))).join("")}
-function a(...e){return"("+e.map((e=>t(e))).join("|")+")"}return t=>{
-const n=a(o(/([2-9]|[1-2]\d|[3][0-5])\^\^/,/(\w*\.\w+|\w+\.\w*|\w+)/),/(\d*\.\d+|\d+\.\d*|\d+)/),r={
-className:"number",relevance:0,
-begin:o(n,i(a(/``[+-]?(\d*\.\d+|\d+\.\d*|\d+)/,/`([+-]?(\d*\.\d+|\d+\.\d*|\d+))?/)),i(/\*\^[+-]?\d+/))
-},l=/[a-zA-Z$][a-zA-Z0-9$]*/,s=new Set(e),c={variants:[{
-className:"builtin-symbol",begin:l,"on:begin":(e,t)=>{
-s.has(e[0])||t.ignoreMatch()}},{className:"symbol",relevance:0,begin:l}]},u={
-className:"message-name",relevance:0,begin:o("::",l)};return{name:"Mathematica",
-aliases:["mma","wl"],classNameAliases:{brace:"punctuation",pattern:"type",
-slot:"type",symbol:"variable","named-character":"variable",
-"builtin-symbol":"built_in","message-name":"string"},
-contains:[t.COMMENT(/\(\*/,/\*\)/,{contains:["self"]}),{className:"pattern",
-relevance:0,begin:/([a-zA-Z$][a-zA-Z0-9$]*)?_+([a-zA-Z$][a-zA-Z0-9$]*)?/},{
-className:"slot",relevance:0,begin:/#[a-zA-Z$][a-zA-Z0-9$]*|#+[0-9]?/},u,c,{
-className:"named-character",begin:/\\\[[$a-zA-Z][$a-zA-Z0-9]+\]/
-},t.QUOTE_STRING_MODE,r,{className:"operator",relevance:0,
-begin:/[+\-*/,;.:@~=><&|_`'^?!%]+/},{className:"brace",relevance:0,
-begin:/[[\](){}]/}]}}})());
-hljs.registerLanguage("matlab",(()=>{"use strict";return e=>{var a={relevance:0,
-contains:[{begin:"('|\\.')+"}]};return{name:"Matlab",keywords:{
-keyword:"arguments break case catch classdef continue else elseif end enumeration events for function global if methods otherwise parfor persistent properties return spmd switch try while",
-built_in:"sin sind sinh asin asind asinh cos cosd cosh acos acosd acosh tan tand tanh atan atand atan2 atanh sec secd sech asec asecd asech csc cscd csch acsc acscd acsch cot cotd coth acot acotd acoth hypot exp expm1 log log1p log10 log2 pow2 realpow reallog realsqrt sqrt nthroot nextpow2 abs angle complex conj imag real unwrap isreal cplxpair fix floor ceil round mod rem sign airy besselj bessely besselh besseli besselk beta betainc betaln ellipj ellipke erf erfc erfcx erfinv expint gamma gammainc gammaln psi legendre cross dot factor isprime primes gcd lcm rat rats perms nchoosek factorial cart2sph cart2pol pol2cart sph2cart hsv2rgb rgb2hsv zeros ones eye repmat rand randn linspace logspace freqspace meshgrid accumarray size length ndims numel disp isempty isequal isequalwithequalnans cat reshape diag blkdiag tril triu fliplr flipud flipdim rot90 find sub2ind ind2sub bsxfun ndgrid permute ipermute shiftdim circshift squeeze isscalar isvector ans eps realmax realmin pi i|0 inf nan isnan isinf isfinite j|0 why compan gallery hadamard hankel hilb invhilb magic pascal rosser toeplitz vander wilkinson max min nanmax nanmin mean nanmean type table readtable writetable sortrows sort figure plot plot3 scatter scatter3 cellfun legend intersect ismember procrustes hold num2cell "
-},illegal:'(//|"|#|/\\*|\\s+/\\w+)',contains:[{className:"function",
-beginKeywords:"function",end:"$",contains:[e.UNDERSCORE_TITLE_MODE,{
-className:"params",variants:[{begin:"\\(",end:"\\)"},{begin:"\\[",end:"\\]"}]}]
-},{className:"built_in",begin:/true|false/,relevance:0,starts:a},{
-begin:"[a-zA-Z][a-zA-Z_0-9]*('|\\.')+",relevance:0},{className:"number",
-begin:e.C_NUMBER_RE,relevance:0,starts:a},{className:"string",begin:"'",end:"'",
-contains:[e.BACKSLASH_ESCAPE,{begin:"''"}]},{begin:/\]|\}|\)/,relevance:0,
-starts:a},{className:"string",begin:'"',end:'"',contains:[e.BACKSLASH_ESCAPE,{
-begin:'""'}],starts:a
-},e.COMMENT("^\\s*%\\{\\s*$","^\\s*%\\}\\s*$"),e.COMMENT("%","$")]}}})());
-hljs.registerLanguage("maxima",(()=>{"use strict";return e=>({name:"Maxima",
-keywords:{$pattern:"[A-Za-z_%][0-9A-Za-z_%]*",
-keyword:"if then else elseif for thru do while unless step in and or not",
-literal:"true false unknown inf minf ind und %e %i %pi %phi %gamma",
-built_in:" abasep abs absint absolute_real_time acos acosh acot acoth acsc acsch activate addcol add_edge add_edges addmatrices addrow add_vertex add_vertices adjacency_matrix adjoin adjoint af agd airy airy_ai airy_bi airy_dai airy_dbi algsys alg_type alias allroots alphacharp alphanumericp amortization %and annuity_fv annuity_pv antid antidiff AntiDifference append appendfile apply apply1 apply2 applyb1 apropos args arit_amortization arithmetic arithsum array arrayapply arrayinfo arraymake arraysetapply ascii asec asech asin asinh askinteger asksign assoc assoc_legendre_p assoc_legendre_q assume assume_external_byte_order asympa at atan atan2 atanh atensimp atom atvalue augcoefmatrix augmented_lagrangian_method av average_degree backtrace bars barsplot barsplot_description base64 base64_decode bashindices batch batchload bc2 bdvac belln benefit_cost bern bernpoly bernstein_approx bernstein_expand bernstein_poly bessel bessel_i bessel_j bessel_k bessel_simplify bessel_y beta beta_incomplete beta_incomplete_generalized beta_incomplete_regularized bezout bfallroots bffac bf_find_root bf_fmin_cobyla bfhzeta bfloat bfloatp bfpsi bfpsi0 bfzeta biconnected_components bimetric binomial bipartition block blockmatrixp bode_gain bode_phase bothcoef box boxplot boxplot_description break bug_report build_info|10 buildq build_sample burn cabs canform canten cardinality carg cartan cartesian_product catch cauchy_matrix cbffac cdf_bernoulli cdf_beta cdf_binomial cdf_cauchy cdf_chi2 cdf_continuous_uniform cdf_discrete_uniform cdf_exp cdf_f cdf_gamma cdf_general_finite_discrete cdf_geometric cdf_gumbel cdf_hypergeometric cdf_laplace cdf_logistic cdf_lognormal cdf_negative_binomial cdf_noncentral_chi2 cdf_noncentral_student_t cdf_normal cdf_pareto cdf_poisson cdf_rank_sum cdf_rayleigh cdf_signed_rank cdf_student_t cdf_weibull cdisplay ceiling central_moment cequal cequalignore cf cfdisrep cfexpand cgeodesic cgreaterp cgreaterpignore changename changevar chaosgame charat charfun charfun2 charlist charp charpoly chdir chebyshev_t chebyshev_u checkdiv check_overlaps chinese cholesky christof chromatic_index chromatic_number cint circulant_graph clear_edge_weight clear_rules clear_vertex_label clebsch_gordan clebsch_graph clessp clesspignore close closefile cmetric coeff coefmatrix cograd col collapse collectterms columnop columnspace columnswap columnvector combination combine comp2pui compare compfile compile compile_file complement_graph complete_bipartite_graph complete_graph complex_number_p components compose_functions concan concat conjugate conmetderiv connected_components connect_vertices cons constant constantp constituent constvalue cont2part content continuous_freq contortion contour_plot contract contract_edge contragrad contrib_ode convert coord copy copy_file copy_graph copylist copymatrix cor cos cosh cot coth cov cov1 covdiff covect covers crc24sum create_graph create_list csc csch csetup cspline ctaylor ct_coordsys ctransform ctranspose cube_graph cuboctahedron_graph cunlisp cv cycle_digraph cycle_graph cylindrical days360 dblint deactivate declare declare_constvalue declare_dimensions declare_fundamental_dimensions declare_fundamental_units declare_qty declare_translated declare_unit_conversion declare_units declare_weights decsym defcon define define_alt_display define_variable defint defmatch defrule defstruct deftaylor degree_sequence del delete deleten delta demo demoivre denom depends derivdegree derivlist describe desolve determinant dfloat dgauss_a dgauss_b dgeev dgemm dgeqrf dgesv dgesvd diag diagmatrix diag_matrix diagmatrixp diameter diff digitcharp dimacs_export dimacs_import dimension dimensionless dimensions dimensions_as_list direct directory discrete_freq disjoin disjointp disolate disp dispcon dispform dispfun dispJordan display disprule dispterms distrib divide divisors divsum dkummer_m dkummer_u dlange dodecahedron_graph dotproduct dotsimp dpart draw draw2d draw3d drawdf draw_file draw_graph dscalar echelon edge_coloring edge_connectivity edges eigens_by_jacobi eigenvalues eigenvectors eighth einstein eivals eivects elapsed_real_time elapsed_run_time ele2comp ele2polynome ele2pui elem elementp elevation_grid elim elim_allbut eliminate eliminate_using ellipse elliptic_e elliptic_ec elliptic_eu elliptic_f elliptic_kc elliptic_pi ematrix empty_graph emptyp endcons entermatrix entertensor entier equal equalp equiv_classes erf erfc erf_generalized erfi errcatch error errormsg errors euler ev eval_string evenp every evolution evolution2d evundiff example exp expand expandwrt expandwrt_factored expint expintegral_chi expintegral_ci expintegral_e expintegral_e1 expintegral_ei expintegral_e_simplify expintegral_li expintegral_shi expintegral_si explicit explose exponentialize express expt exsec extdiff extract_linear_equations extremal_subset ezgcd %f f90 facsum factcomb factor factorfacsum factorial factorout factorsum facts fast_central_elements fast_linsolve fasttimes featurep fernfale fft fib fibtophi fifth filename_merge file_search file_type fillarray findde find_root find_root_abs find_root_error find_root_rel first fix flatten flength float floatnump floor flower_snark flush flush1deriv flushd flushnd flush_output fmin_cobyla forget fortran fourcos fourexpand fourier fourier_elim fourint fourintcos fourintsin foursimp foursin fourth fposition frame_bracket freeof freshline fresnel_c fresnel_s from_adjacency_matrix frucht_graph full_listify fullmap fullmapl fullratsimp fullratsubst fullsetify funcsolve fundamental_dimensions fundamental_units fundef funmake funp fv g0 g1 gamma gamma_greek gamma_incomplete gamma_incomplete_generalized gamma_incomplete_regularized gauss gauss_a gauss_b gaussprob gcd gcdex gcdivide gcfac gcfactor gd generalized_lambert_w genfact gen_laguerre genmatrix gensym geo_amortization geo_annuity_fv geo_annuity_pv geomap geometric geometric_mean geosum get getcurrentdirectory get_edge_weight getenv get_lu_factors get_output_stream_string get_pixel get_plot_option get_tex_environment get_tex_environment_default get_vertex_label gfactor gfactorsum ggf girth global_variances gn gnuplot_close gnuplot_replot gnuplot_reset gnuplot_restart gnuplot_start go Gosper GosperSum gr2d gr3d gradef gramschmidt graph6_decode graph6_encode graph6_export graph6_import graph_center graph_charpoly graph_eigenvalues graph_flow graph_order graph_periphery graph_product graph_size graph_union great_rhombicosidodecahedron_graph great_rhombicuboctahedron_graph grid_graph grind grobner_basis grotzch_graph hamilton_cycle hamilton_path hankel hankel_1 hankel_2 harmonic harmonic_mean hav heawood_graph hermite hessian hgfred hilbertmap hilbert_matrix hipow histogram histogram_description hodge horner hypergeometric i0 i1 %ibes ic1 ic2 ic_convert ichr1 ichr2 icosahedron_graph icosidodecahedron_graph icurvature ident identfor identity idiff idim idummy ieqn %if ifactors iframes ifs igcdex igeodesic_coords ilt image imagpart imetric implicit implicit_derivative implicit_plot indexed_tensor indices induced_subgraph inferencep inference_result infix info_display init_atensor init_ctensor in_neighbors innerproduct inpart inprod inrt integerp integer_partitions integrate intersect intersection intervalp intopois intosum invariant1 invariant2 inverse_fft inverse_jacobi_cd inverse_jacobi_cn inverse_jacobi_cs inverse_jacobi_dc inverse_jacobi_dn inverse_jacobi_ds inverse_jacobi_nc inverse_jacobi_nd inverse_jacobi_ns inverse_jacobi_sc inverse_jacobi_sd inverse_jacobi_sn invert invert_by_adjoint invert_by_lu inv_mod irr is is_biconnected is_bipartite is_connected is_digraph is_edge_in_graph is_graph is_graph_or_digraph ishow is_isomorphic isolate isomorphism is_planar isqrt isreal_p is_sconnected is_tree is_vertex_in_graph items_inference %j j0 j1 jacobi jacobian jacobi_cd jacobi_cn jacobi_cs jacobi_dc jacobi_dn jacobi_ds jacobi_nc jacobi_nd jacobi_ns jacobi_p jacobi_sc jacobi_sd jacobi_sn JF jn join jordan julia julia_set julia_sin %k kdels kdelta kill killcontext kostka kron_delta kronecker_product kummer_m kummer_u kurtosis kurtosis_bernoulli kurtosis_beta kurtosis_binomial kurtosis_chi2 kurtosis_continuous_uniform kurtosis_discrete_uniform kurtosis_exp kurtosis_f kurtosis_gamma kurtosis_general_finite_discrete kurtosis_geometric kurtosis_gumbel kurtosis_hypergeometric kurtosis_laplace kurtosis_logistic kurtosis_lognormal kurtosis_negative_binomial kurtosis_noncentral_chi2 kurtosis_noncentral_student_t kurtosis_normal kurtosis_pareto kurtosis_poisson kurtosis_rayleigh kurtosis_student_t kurtosis_weibull label labels lagrange laguerre lambda lambert_w laplace laplacian_matrix last lbfgs lc2kdt lcharp lc_l lcm lc_u ldefint ldisp ldisplay legendre_p legendre_q leinstein length let letrules letsimp levi_civita lfreeof lgtreillis lhs li liediff limit Lindstedt linear linearinterpol linear_program linear_regression line_graph linsolve listarray list_correlations listify list_matrix_entries list_nc_monomials listoftens listofvars listp lmax lmin load loadfile local locate_matrix_entry log logcontract log_gamma lopow lorentz_gauge lowercasep lpart lratsubst lreduce lriemann lsquares_estimates lsquares_estimates_approximate lsquares_estimates_exact lsquares_mse lsquares_residual_mse lsquares_residuals lsum ltreillis lu_backsub lucas lu_factor %m macroexpand macroexpand1 make_array makebox makefact makegamma make_graph make_level_picture makelist makeOrders make_poly_continent make_poly_country make_polygon make_random_state make_rgb_picture makeset make_string_input_stream make_string_output_stream make_transform mandelbrot mandelbrot_set map mapatom maplist matchdeclare matchfix mat_cond mat_fullunblocker mat_function mathml_display mat_norm matrix matrixmap matrixp matrix_size mattrace mat_trace mat_unblocker max max_clique max_degree max_flow maximize_lp max_independent_set max_matching maybe md5sum mean mean_bernoulli mean_beta mean_binomial mean_chi2 mean_continuous_uniform mean_deviation mean_discrete_uniform mean_exp mean_f mean_gamma mean_general_finite_discrete mean_geometric mean_gumbel mean_hypergeometric mean_laplace mean_logistic mean_lognormal mean_negative_binomial mean_noncentral_chi2 mean_noncentral_student_t mean_normal mean_pareto mean_poisson mean_rayleigh mean_student_t mean_weibull median median_deviation member mesh metricexpandall mgf1_sha1 min min_degree min_edge_cut minfactorial minimalPoly minimize_lp minimum_spanning_tree minor minpack_lsquares minpack_solve min_vertex_cover min_vertex_cut mkdir mnewton mod mode_declare mode_identity ModeMatrix moebius mon2schur mono monomial_dimensions multibernstein_poly multi_display_for_texinfo multi_elem multinomial multinomial_coeff multi_orbit multiplot_mode multi_pui multsym multthru mycielski_graph nary natural_unit nc_degree ncexpt ncharpoly negative_picture neighbors new newcontext newdet new_graph newline newton new_variable next_prime nicedummies niceindices ninth nofix nonarray noncentral_moment nonmetricity nonnegintegerp nonscalarp nonzeroandfreeof notequal nounify nptetrad npv nroots nterms ntermst nthroot nullity nullspace num numbered_boundaries numberp number_to_octets num_distinct_partitions numerval numfactor num_partitions nusum nzeta nzetai nzetar octets_to_number octets_to_oid odd_girth oddp ode2 ode_check odelin oid_to_octets op opena opena_binary openr openr_binary openw openw_binary operatorp opsubst optimize %or orbit orbits ordergreat ordergreatp orderless orderlessp orthogonal_complement orthopoly_recur orthopoly_weight outermap out_neighbors outofpois pade parabolic_cylinder_d parametric parametric_surface parg parGosper parse_string parse_timedate part part2cont partfrac partition partition_set partpol path_digraph path_graph pathname_directory pathname_name pathname_type pdf_bernoulli pdf_beta pdf_binomial pdf_cauchy pdf_chi2 pdf_continuous_uniform pdf_discrete_uniform pdf_exp pdf_f pdf_gamma pdf_general_finite_discrete pdf_geometric pdf_gumbel pdf_hypergeometric pdf_laplace pdf_logistic pdf_lognormal pdf_negative_binomial pdf_noncentral_chi2 pdf_noncentral_student_t pdf_normal pdf_pareto pdf_poisson pdf_rank_sum pdf_rayleigh pdf_signed_rank pdf_student_t pdf_weibull pearson_skewness permanent permut permutation permutations petersen_graph petrov pickapart picture_equalp picturep piechart piechart_description planar_embedding playback plog plot2d plot3d plotdf ploteq plsquares pochhammer points poisdiff poisexpt poisint poismap poisplus poissimp poissubst poistimes poistrim polar polarform polartorect polar_to_xy poly_add poly_buchberger poly_buchberger_criterion poly_colon_ideal poly_content polydecomp poly_depends_p poly_elimination_ideal poly_exact_divide poly_expand poly_expt poly_gcd polygon poly_grobner poly_grobner_equal poly_grobner_member poly_grobner_subsetp poly_ideal_intersection poly_ideal_polysaturation poly_ideal_polysaturation1 poly_ideal_saturation poly_ideal_saturation1 poly_lcm poly_minimization polymod poly_multiply polynome2ele polynomialp poly_normal_form poly_normalize poly_normalize_list poly_polysaturation_extension poly_primitive_part poly_pseudo_divide poly_reduced_grobner poly_reduction poly_saturation_extension poly_s_polynomial poly_subtract polytocompanion pop postfix potential power_mod powerseries powerset prefix prev_prime primep primes principal_components print printf printfile print_graph printpois printprops prodrac product properties propvars psi psubst ptriangularize pui pui2comp pui2ele pui2polynome pui_direct puireduc push put pv qput qrange qty quad_control quad_qag quad_qagi quad_qagp quad_qags quad_qawc quad_qawf quad_qawo quad_qaws quadrilateral quantile quantile_bernoulli quantile_beta quantile_binomial quantile_cauchy quantile_chi2 quantile_continuous_uniform quantile_discrete_uniform quantile_exp quantile_f quantile_gamma quantile_general_finite_discrete quantile_geometric quantile_gumbel quantile_hypergeometric quantile_laplace quantile_logistic quantile_lognormal quantile_negative_binomial quantile_noncentral_chi2 quantile_noncentral_student_t quantile_normal quantile_pareto quantile_poisson quantile_rayleigh quantile_student_t quantile_weibull quartile_skewness quit qunit quotient racah_v racah_w radcan radius random random_bernoulli random_beta random_binomial random_bipartite_graph random_cauchy random_chi2 random_continuous_uniform random_digraph random_discrete_uniform random_exp random_f random_gamma random_general_finite_discrete random_geometric random_graph random_graph1 random_gumbel random_hypergeometric random_laplace random_logistic random_lognormal random_negative_binomial random_network random_noncentral_chi2 random_noncentral_student_t random_normal random_pareto random_permutation random_poisson random_rayleigh random_regular_graph random_student_t random_tournament random_tree random_weibull range rank rat ratcoef ratdenom ratdiff ratdisrep ratexpand ratinterpol rational rationalize ratnumer ratnump ratp ratsimp ratsubst ratvars ratweight read read_array read_binary_array read_binary_list read_binary_matrix readbyte readchar read_hashed_array readline read_list read_matrix read_nested_list readonly read_xpm real_imagpart_to_conjugate realpart realroots rearray rectangle rectform rectform_log_if_constant recttopolar rediff reduce_consts reduce_order region region_boundaries region_boundaries_plus rem remainder remarray rembox remcomps remcon remcoord remfun remfunction remlet remove remove_constvalue remove_dimensions remove_edge remove_fundamental_dimensions remove_fundamental_units remove_plot_option remove_vertex rempart remrule remsym remvalue rename rename_file reset reset_displays residue resolvante resolvante_alternee1 resolvante_bipartite resolvante_diedrale resolvante_klein resolvante_klein3 resolvante_produit_sym resolvante_unitaire resolvante_vierer rest resultant return reveal reverse revert revert2 rgb2level rhs ricci riemann rinvariant risch rk rmdir rncombine romberg room rootscontract round row rowop rowswap rreduce run_testsuite %s save saving scalarp scaled_bessel_i scaled_bessel_i0 scaled_bessel_i1 scalefactors scanmap scatterplot scatterplot_description scene schur2comp sconcat scopy scsimp scurvature sdowncase sec sech second sequal sequalignore set_alt_display setdifference set_draw_defaults set_edge_weight setelmx setequalp setify setp set_partitions set_plot_option set_prompt set_random_state set_tex_environment set_tex_environment_default setunits setup_autoload set_up_dot_simplifications set_vertex_label seventh sexplode sf sha1sum sha256sum shortest_path shortest_weighted_path show showcomps showratvars sierpinskiale sierpinskimap sign signum similaritytransform simp_inequality simplify_sum simplode simpmetderiv simtran sin sinh sinsert sinvertcase sixth skewness skewness_bernoulli skewness_beta skewness_binomial skewness_chi2 skewness_continuous_uniform skewness_discrete_uniform skewness_exp skewness_f skewness_gamma skewness_general_finite_discrete skewness_geometric skewness_gumbel skewness_hypergeometric skewness_laplace skewness_logistic skewness_lognormal skewness_negative_binomial skewness_noncentral_chi2 skewness_noncentral_student_t skewness_normal skewness_pareto skewness_poisson skewness_rayleigh skewness_student_t skewness_weibull slength smake small_rhombicosidodecahedron_graph small_rhombicuboctahedron_graph smax smin smismatch snowmap snub_cube_graph snub_dodecahedron_graph solve solve_rec solve_rec_rat some somrac sort sparse6_decode sparse6_encode sparse6_export sparse6_import specint spherical spherical_bessel_j spherical_bessel_y spherical_hankel1 spherical_hankel2 spherical_harmonic spherical_to_xyz splice split sposition sprint sqfr sqrt sqrtdenest sremove sremovefirst sreverse ssearch ssort sstatus ssubst ssubstfirst staircase standardize standardize_inverse_trig starplot starplot_description status std std1 std_bernoulli std_beta std_binomial std_chi2 std_continuous_uniform std_discrete_uniform std_exp std_f std_gamma std_general_finite_discrete std_geometric std_gumbel std_hypergeometric std_laplace std_logistic std_lognormal std_negative_binomial std_noncentral_chi2 std_noncentral_student_t std_normal std_pareto std_poisson std_rayleigh std_student_t std_weibull stemplot stirling stirling1 stirling2 strim striml strimr string stringout stringp strong_components struve_h struve_l sublis sublist sublist_indices submatrix subsample subset subsetp subst substinpart subst_parallel substpart substring subvar subvarp sum sumcontract summand_to_rec supcase supcontext symbolp symmdifference symmetricp system take_channel take_inference tan tanh taylor taylorinfo taylorp taylor_simplifier taytorat tcl_output tcontract tellrat tellsimp tellsimpafter tentex tenth test_mean test_means_difference test_normality test_proportion test_proportions_difference test_rank_sum test_sign test_signed_rank test_variance test_variance_ratio tex tex1 tex_display texput %th third throw time timedate timer timer_info tldefint tlimit todd_coxeter toeplitz tokens to_lisp topological_sort to_poly to_poly_solve totaldisrep totalfourier totient tpartpol trace tracematrix trace_options transform_sample translate translate_file transpose treefale tree_reduce treillis treinat triangle triangularize trigexpand trigrat trigreduce trigsimp trunc truncate truncated_cube_graph truncated_dodecahedron_graph truncated_icosahedron_graph truncated_tetrahedron_graph tr_warnings_get tube tutte_graph ueivects uforget ultraspherical underlying_graph undiff union unique uniteigenvectors unitp units unit_step unitvector unorder unsum untellrat untimer untrace uppercasep uricci uriemann uvect vandermonde_matrix var var1 var_bernoulli var_beta var_binomial var_chi2 var_continuous_uniform var_discrete_uniform var_exp var_f var_gamma var_general_finite_discrete var_geometric var_gumbel var_hypergeometric var_laplace var_logistic var_lognormal var_negative_binomial var_noncentral_chi2 var_noncentral_student_t var_normal var_pareto var_poisson var_rayleigh var_student_t var_weibull vector vectorpotential vectorsimp verbify vers vertex_coloring vertex_connectivity vertex_degree vertex_distance vertex_eccentricity vertex_in_degree vertex_out_degree vertices vertices_to_cycle vertices_to_path %w weyl wheel_graph wiener_index wigner_3j wigner_6j wigner_9j with_stdout write_binary_data writebyte write_data writefile wronskian xreduce xthru %y Zeilberger zeroequiv zerofor zeromatrix zeromatrixp zeta zgeev zheev zlange zn_add_table zn_carmichael_lambda zn_characteristic_factors zn_determinant zn_factor_generators zn_invert_by_lu zn_log zn_mult_table absboxchar activecontexts adapt_depth additive adim aform algebraic algepsilon algexact aliases allbut all_dotsimp_denoms allocation allsym alphabetic animation antisymmetric arrays askexp assume_pos assume_pos_pred assumescalar asymbol atomgrad atrig1 axes axis_3d axis_bottom axis_left axis_right axis_top azimuth background background_color backsubst berlefact bernstein_explicit besselexpand beta_args_sum_to_integer beta_expand bftorat bftrunc bindtest border boundaries_array box boxchar breakup %c capping cauchysum cbrange cbtics center cflength cframe_flag cnonmet_flag color color_bar color_bar_tics colorbox columns commutative complex cone context contexts contour contour_levels cosnpiflag ctaypov ctaypt ctayswitch ctayvar ct_coords ctorsion_flag ctrgsimp cube current_let_rule_package cylinder data_file_name debugmode decreasing default_let_rule_package delay dependencies derivabbrev derivsubst detout diagmetric diff dim dimensions dispflag display2d|10 display_format_internal distribute_over doallmxops domain domxexpt domxmxops domxnctimes dontfactor doscmxops doscmxplus dot0nscsimp dot0simp dot1simp dotassoc dotconstrules dotdistrib dotexptsimp dotident dotscrules draw_graph_program draw_realpart edge_color edge_coloring edge_partition edge_type edge_width %edispflag elevation %emode endphi endtheta engineering_format_floats enhanced3d %enumer epsilon_lp erfflag erf_representation errormsg error_size error_syms error_type %e_to_numlog eval even evenfun evflag evfun ev_point expandwrt_denom expintexpand expintrep expon expop exptdispflag exptisolate exptsubst facexpand facsum_combine factlim factorflag factorial_expand factors_only fb feature features file_name file_output_append file_search_demo file_search_lisp file_search_maxima|10 file_search_tests file_search_usage file_type_lisp file_type_maxima|10 fill_color fill_density filled_func fixed_vertices flipflag float2bf font font_size fortindent fortspaces fpprec fpprintprec functions gamma_expand gammalim gdet genindex gensumnum GGFCFMAX GGFINFINITY globalsolve gnuplot_command gnuplot_curve_styles gnuplot_curve_titles gnuplot_default_term_command gnuplot_dumb_term_command gnuplot_file_args gnuplot_file_name gnuplot_out_file gnuplot_pdf_term_command gnuplot_pm3d gnuplot_png_term_command gnuplot_postamble gnuplot_preamble gnuplot_ps_term_command gnuplot_svg_term_command gnuplot_term gnuplot_view_args Gosper_in_Zeilberger gradefs grid grid2d grind halfangles head_angle head_both head_length head_type height hypergeometric_representation %iargs ibase icc1 icc2 icounter idummyx ieqnprint ifb ifc1 ifc2 ifg ifgi ifr iframe_bracket_form ifri igeowedge_flag ikt1 ikt2 imaginary inchar increasing infeval infinity inflag infolists inm inmc1 inmc2 intanalysis integer integervalued integrate_use_rootsof integration_constant integration_constant_counter interpolate_color intfaclim ip_grid ip_grid_in irrational isolate_wrt_times iterations itr julia_parameter %k1 %k2 keepfloat key key_pos kinvariant kt label label_alignment label_orientation labels lassociative lbfgs_ncorrections lbfgs_nfeval_max leftjust legend letrat let_rule_packages lfg lg lhospitallim limsubst linear linear_solver linechar linel|10 linenum line_type linewidth line_width linsolve_params linsolvewarn lispdisp listarith listconstvars listdummyvars lmxchar load_pathname loadprint logabs logarc logcb logconcoeffp logexpand lognegint logsimp logx logx_secondary logy logy_secondary logz lriem m1pbranch macroexpansion macros mainvar manual_demo maperror mapprint matrix_element_add matrix_element_mult matrix_element_transpose maxapplydepth maxapplyheight maxima_tempdir|10 maxima_userdir|10 maxnegex MAX_ORD maxposex maxpsifracdenom maxpsifracnum maxpsinegint maxpsiposint maxtayorder mesh_lines_color method mod_big_prime mode_check_errorp mode_checkp mode_check_warnp mod_test mod_threshold modular_linear_solver modulus multiplicative multiplicities myoptions nary negdistrib negsumdispflag newline newtonepsilon newtonmaxiter nextlayerfactor niceindicespref nm nmc noeval nolabels nonegative_lp noninteger nonscalar noun noundisp nouns np npi nticks ntrig numer numer_pbranch obase odd oddfun opacity opproperties opsubst optimprefix optionset orientation origin orthopoly_returns_intervals outative outchar packagefile palette partswitch pdf_file pfeformat phiresolution %piargs piece pivot_count_sx pivot_max_sx plot_format plot_options plot_realpart png_file pochhammer_max_index points pointsize point_size points_joined point_type poislim poisson poly_coefficient_ring poly_elimination_order polyfactor poly_grobner_algorithm poly_grobner_debug poly_monomial_order poly_primary_elimination_order poly_return_term_list poly_secondary_elimination_order poly_top_reduction_only posfun position powerdisp pred prederror primep_number_of_tests product_use_gamma program programmode promote_float_to_bigfloat prompt proportional_axes props psexpand ps_file radexpand radius radsubstflag rassociative ratalgdenom ratchristof ratdenomdivide rateinstein ratepsilon ratfac rational ratmx ratprint ratriemann ratsimpexpons ratvarswitch ratweights ratweyl ratwtlvl real realonly redraw refcheck resolution restart resultant ric riem rmxchar %rnum_list rombergabs rombergit rombergmin rombergtol rootsconmode rootsepsilon run_viewer same_xy same_xyz savedef savefactors scalar scalarmatrixp scale scale_lp setcheck setcheckbreak setval show_edge_color show_edges show_edge_type show_edge_width show_id show_label showtime show_vertex_color show_vertex_size show_vertex_type show_vertices show_weight simp simplified_output simplify_products simpproduct simpsum sinnpiflag solvedecomposes solveexplicit solvefactors solvenullwarn solveradcan solvetrigwarn space sparse sphere spring_embedding_depth sqrtdispflag stardisp startphi starttheta stats_numer stringdisp structures style sublis_apply_lambda subnumsimp sumexpand sumsplitfact surface surface_hide svg_file symmetric tab taylordepth taylor_logexpand taylor_order_coefficients taylor_truncate_polynomials tensorkill terminal testsuite_files thetaresolution timer_devalue title tlimswitch tr track transcompile transform transform_xy translate_fast_arrays transparent transrun tr_array_as_ref tr_bound_function_applyp tr_file_tty_messagesp tr_float_can_branch_complex tr_function_call_default trigexpandplus trigexpandtimes triginverses trigsign trivial_solutions tr_numer tr_optimize_max_loop tr_semicompile tr_state_vars tr_warn_bad_function_calls tr_warn_fexpr tr_warn_meval tr_warn_mode tr_warn_undeclared tr_warn_undefined_variable tstep ttyoff tube_extremes ufg ug %unitexpand unit_vectors uric uriem use_fast_arrays user_preamble usersetunits values vect_cross verbose vertex_color vertex_coloring vertex_partition vertex_size vertex_type view warnings weyl width windowname windowtitle wired_surface wireframe xaxis xaxis_color xaxis_secondary xaxis_type xaxis_width xlabel xlabel_secondary xlength xrange xrange_secondary xtics xtics_axis xtics_rotate xtics_rotate_secondary xtics_secondary xtics_secondary_axis xu_grid x_voxel xy_file xyplane xy_scale yaxis yaxis_color yaxis_secondary yaxis_type yaxis_width ylabel ylabel_secondary ylength yrange yrange_secondary ytics ytics_axis ytics_rotate ytics_rotate_secondary ytics_secondary ytics_secondary_axis yv_grid y_voxel yx_ratio zaxis zaxis_color zaxis_type zaxis_width zeroa zerob zerobern zeta%pi zlabel zlabel_rotate zlength zmin zn_primroot_limit zn_primroot_pretest",
-symbol:"_ __ %|0 %%|0"},contains:[{className:"comment",begin:"/\\*",end:"\\*/",
-contains:["self"]},e.QUOTE_STRING_MODE,{className:"number",relevance:0,
-variants:[{begin:"\\b(\\d+|\\d+\\.|\\.\\d+|\\d+\\.\\d+)[Ee][-+]?\\d+\\b"},{
-begin:"\\b(\\d+|\\d+\\.|\\.\\d+|\\d+\\.\\d+)[Bb][-+]?\\d+\\b",relevance:10},{
-begin:"\\b(\\.\\d+|\\d+\\.\\d+)\\b"},{begin:"\\b(\\d+|0[0-9A-Za-z]+)\\.?\\b"}]
-}],illegal:/@/})})());
-hljs.registerLanguage("mel",(()=>{"use strict";return e=>({name:"MEL",
-keywords:"int float string vector matrix if else switch case default while do for in break continue global proc return about abs addAttr addAttributeEditorNodeHelp addDynamic addNewShelfTab addPP addPanelCategory addPrefixToName advanceToNextDrivenKey affectedNet affects aimConstraint air alias aliasAttr align alignCtx alignCurve alignSurface allViewFit ambientLight angle angleBetween animCone animCurveEditor animDisplay animView annotate appendStringArray applicationName applyAttrPreset applyTake arcLenDimContext arcLengthDimension arclen arrayMapper art3dPaintCtx artAttrCtx artAttrPaintVertexCtx artAttrSkinPaintCtx artAttrTool artBuildPaintMenu artFluidAttrCtx artPuttyCtx artSelectCtx artSetPaintCtx artUserPaintCtx assignCommand assignInputDevice assignViewportFactories attachCurve attachDeviceAttr attachSurface attrColorSliderGrp attrCompatibility attrControlGrp attrEnumOptionMenu attrEnumOptionMenuGrp attrFieldGrp attrFieldSliderGrp attrNavigationControlGrp attrPresetEditWin attributeExists attributeInfo attributeMenu attributeQuery autoKeyframe autoPlace bakeClip bakeFluidShading bakePartialHistory bakeResults bakeSimulation basename basenameEx batchRender bessel bevel bevelPlus binMembership bindSkin blend2 blendShape blendShapeEditor blendShapePanel blendTwoAttr blindDataType boneLattice boundary boxDollyCtx boxZoomCtx bufferCurve buildBookmarkMenu buildKeyframeMenu button buttonManip CBG cacheFile cacheFileCombine cacheFileMerge cacheFileTrack camera cameraView canCreateManip canvas capitalizeString catch catchQuiet ceil changeSubdivComponentDisplayLevel changeSubdivRegion channelBox character characterMap characterOutlineEditor characterize chdir checkBox checkBoxGrp checkDefaultRenderGlobals choice circle circularFillet clamp clear clearCache clip clipEditor clipEditorCurrentTimeCtx clipSchedule clipSchedulerOutliner clipTrimBefore closeCurve closeSurface cluster cmdFileOutput cmdScrollFieldExecuter cmdScrollFieldReporter cmdShell coarsenSubdivSelectionList collision color colorAtPoint colorEditor colorIndex colorIndexSliderGrp colorSliderButtonGrp colorSliderGrp columnLayout commandEcho commandLine commandPort compactHairSystem componentEditor compositingInterop computePolysetVolume condition cone confirmDialog connectAttr connectControl connectDynamic connectJoint connectionInfo constrain constrainValue constructionHistory container containsMultibyte contextInfo control convertFromOldLayers convertIffToPsd convertLightmap convertSolidTx convertTessellation convertUnit copyArray copyFlexor copyKey copySkinWeights cos cpButton cpCache cpClothSet cpCollision cpConstraint cpConvClothToMesh cpForces cpGetSolverAttr cpPanel cpProperty cpRigidCollisionFilter cpSeam cpSetEdit cpSetSolverAttr cpSolver cpSolverTypes cpTool cpUpdateClothUVs createDisplayLayer createDrawCtx createEditor createLayeredPsdFile createMotionField createNewShelf createNode createRenderLayer createSubdivRegion cross crossProduct ctxAbort ctxCompletion ctxEditMode ctxTraverse currentCtx currentTime currentTimeCtx currentUnit curve curveAddPtCtx curveCVCtx curveEPCtx curveEditorCtx curveIntersect curveMoveEPCtx curveOnSurface curveSketchCtx cutKey cycleCheck cylinder dagPose date defaultLightListCheckBox defaultNavigation defineDataServer defineVirtualDevice deformer deg_to_rad delete deleteAttr deleteShadingGroupsAndMaterials deleteShelfTab deleteUI deleteUnusedBrushes delrandstr detachCurve detachDeviceAttr detachSurface deviceEditor devicePanel dgInfo dgdirty dgeval dgtimer dimWhen directKeyCtx directionalLight dirmap dirname disable disconnectAttr disconnectJoint diskCache displacementToPoly displayAffected displayColor displayCull displayLevelOfDetail displayPref displayRGBColor displaySmoothness displayStats displayString displaySurface distanceDimContext distanceDimension doBlur dolly dollyCtx dopeSheetEditor dot dotProduct doubleProfileBirailSurface drag dragAttrContext draggerContext dropoffLocator duplicate duplicateCurve duplicateSurface dynCache dynControl dynExport dynExpression dynGlobals dynPaintEditor dynParticleCtx dynPref dynRelEdPanel dynRelEditor dynamicLoad editAttrLimits editDisplayLayerGlobals editDisplayLayerMembers editRenderLayerAdjustment editRenderLayerGlobals editRenderLayerMembers editor editorTemplate effector emit emitter enableDevice encodeString endString endsWith env equivalent equivalentTol erf error eval evalDeferred evalEcho event exactWorldBoundingBox exclusiveLightCheckBox exec executeForEachObject exists exp expression expressionEditorListen extendCurve extendSurface extrude fcheck fclose feof fflush fgetline fgetword file fileBrowserDialog fileDialog fileExtension fileInfo filetest filletCurve filter filterCurve filterExpand filterStudioImport findAllIntersections findAnimCurves findKeyframe findMenuItem findRelatedSkinCluster finder firstParentOf fitBspline flexor floatEq floatField floatFieldGrp floatScrollBar floatSlider floatSlider2 floatSliderButtonGrp floatSliderGrp floor flow fluidCacheInfo fluidEmitter fluidVoxelInfo flushUndo fmod fontDialog fopen formLayout format fprint frameLayout fread freeFormFillet frewind fromNativePath fwrite gamma gauss geometryConstraint getApplicationVersionAsFloat getAttr getClassification getDefaultBrush getFileList getFluidAttr getInputDeviceRange getMayaPanelTypes getModifiers getPanel getParticleAttr getPluginResource getenv getpid glRender glRenderEditor globalStitch gmatch goal gotoBindPose grabColor gradientControl gradientControlNoAttr graphDollyCtx graphSelectContext graphTrackCtx gravity grid gridLayout group groupObjectsByName HfAddAttractorToAS HfAssignAS HfBuildEqualMap HfBuildFurFiles HfBuildFurImages HfCancelAFR HfConnectASToHF HfCreateAttractor HfDeleteAS HfEditAS HfPerformCreateAS HfRemoveAttractorFromAS HfSelectAttached HfSelectAttractors HfUnAssignAS hardenPointCurve hardware hardwareRenderPanel headsUpDisplay headsUpMessage help helpLine hermite hide hilite hitTest hotBox hotkey hotkeyCheck hsv_to_rgb hudButton hudSlider hudSliderButton hwReflectionMap hwRender hwRenderLoad hyperGraph hyperPanel hyperShade hypot iconTextButton iconTextCheckBox iconTextRadioButton iconTextRadioCollection iconTextScrollList iconTextStaticLabel ikHandle ikHandleCtx ikHandleDisplayScale ikSolver ikSplineHandleCtx ikSystem ikSystemInfo ikfkDisplayMethod illustratorCurves image imfPlugins inheritTransform insertJoint insertJointCtx insertKeyCtx insertKnotCurve insertKnotSurface instance instanceable instancer intField intFieldGrp intScrollBar intSlider intSliderGrp interToUI internalVar intersect iprEngine isAnimCurve isConnected isDirty isParentOf isSameObject isTrue isValidObjectName isValidString isValidUiName isolateSelect itemFilter itemFilterAttr itemFilterRender itemFilterType joint jointCluster jointCtx jointDisplayScale jointLattice keyTangent keyframe keyframeOutliner keyframeRegionCurrentTimeCtx keyframeRegionDirectKeyCtx keyframeRegionDollyCtx keyframeRegionInsertKeyCtx keyframeRegionMoveKeyCtx keyframeRegionScaleKeyCtx keyframeRegionSelectKeyCtx keyframeRegionSetKeyCtx keyframeRegionTrackCtx keyframeStats lassoContext lattice latticeDeformKeyCtx launch launchImageEditor layerButton layeredShaderPort layeredTexturePort layout layoutDialog lightList lightListEditor lightListPanel lightlink lineIntersection linearPrecision linstep listAnimatable listAttr listCameras listConnections listDeviceAttachments listHistory listInputDeviceAxes listInputDeviceButtons listInputDevices listMenuAnnotation listNodeTypes listPanelCategories listRelatives listSets listTransforms listUnselected listerEditor loadFluid loadNewShelf loadPlugin loadPluginLanguageResources loadPrefObjects localizedPanelLabel lockNode loft log longNameOf lookThru ls lsThroughFilter lsType lsUI Mayatomr mag makeIdentity makeLive makePaintable makeRoll makeSingleSurface makeTubeOn makebot manipMoveContext manipMoveLimitsCtx manipOptions manipRotateContext manipRotateLimitsCtx manipScaleContext manipScaleLimitsCtx marker match max memory menu menuBarLayout menuEditor menuItem menuItemToShelf menuSet menuSetPref messageLine min minimizeApp mirrorJoint modelCurrentTimeCtx modelEditor modelPanel mouse movIn movOut move moveIKtoFK moveKeyCtx moveVertexAlongDirection multiProfileBirailSurface mute nParticle nameCommand nameField namespace namespaceInfo newPanelItems newton nodeCast nodeIconButton nodeOutliner nodePreset nodeType noise nonLinear normalConstraint normalize nurbsBoolean nurbsCopyUVSet nurbsCube nurbsEditUV nurbsPlane nurbsSelect nurbsSquare nurbsToPoly nurbsToPolygonsPref nurbsToSubdiv nurbsToSubdivPref nurbsUVSet nurbsViewDirectionVector objExists objectCenter objectLayer objectType objectTypeUI obsoleteProc oceanNurbsPreviewPlane offsetCurve offsetCurveOnSurface offsetSurface openGLExtension openMayaPref optionMenu optionMenuGrp optionVar orbit orbitCtx orientConstraint outlinerEditor outlinerPanel overrideModifier paintEffectsDisplay pairBlend palettePort paneLayout panel panelConfiguration panelHistory paramDimContext paramDimension paramLocator parent parentConstraint particle particleExists particleInstancer particleRenderInfo partition pasteKey pathAnimation pause pclose percent performanceOptions pfxstrokes pickWalk picture pixelMove planarSrf plane play playbackOptions playblast plugAttr plugNode pluginInfo pluginResourceUtil pointConstraint pointCurveConstraint pointLight pointMatrixMult pointOnCurve pointOnSurface pointPosition poleVectorConstraint polyAppend polyAppendFacetCtx polyAppendVertex polyAutoProjection polyAverageNormal polyAverageVertex polyBevel polyBlendColor polyBlindData polyBoolOp polyBridgeEdge polyCacheMonitor polyCheck polyChipOff polyClipboard polyCloseBorder polyCollapseEdge polyCollapseFacet polyColorBlindData polyColorDel polyColorPerVertex polyColorSet polyCompare polyCone polyCopyUV polyCrease polyCreaseCtx polyCreateFacet polyCreateFacetCtx polyCube polyCut polyCutCtx polyCylinder polyCylindricalProjection polyDelEdge polyDelFacet polyDelVertex polyDuplicateAndConnect polyDuplicateEdge polyEditUV polyEditUVShell polyEvaluate polyExtrudeEdge polyExtrudeFacet polyExtrudeVertex polyFlipEdge polyFlipUV polyForceUV polyGeoSampler polyHelix polyInfo polyInstallAction polyLayoutUV polyListComponentConversion polyMapCut polyMapDel polyMapSew polyMapSewMove polyMergeEdge polyMergeEdgeCtx polyMergeFacet polyMergeFacetCtx polyMergeUV polyMergeVertex polyMirrorFace polyMoveEdge polyMoveFacet polyMoveFacetUV polyMoveUV polyMoveVertex polyNormal polyNormalPerVertex polyNormalizeUV polyOptUvs polyOptions polyOutput polyPipe polyPlanarProjection polyPlane polyPlatonicSolid polyPoke polyPrimitive polyPrism polyProjection polyPyramid polyQuad polyQueryBlindData polyReduce polySelect polySelectConstraint polySelectConstraintMonitor polySelectCtx polySelectEditCtx polySeparate polySetToFaceNormal polySewEdge polyShortestPathCtx polySmooth polySoftEdge polySphere polySphericalProjection polySplit polySplitCtx polySplitEdge polySplitRing polySplitVertex polyStraightenUVBorder polySubdivideEdge polySubdivideFacet polyToSubdiv polyTorus polyTransfer polyTriangulate polyUVSet polyUnite polyWedgeFace popen popupMenu pose pow preloadRefEd print progressBar progressWindow projFileViewer projectCurve projectTangent projectionContext projectionManip promptDialog propModCtx propMove psdChannelOutliner psdEditTextureFile psdExport psdTextureFile putenv pwd python querySubdiv quit rad_to_deg radial radioButton radioButtonGrp radioCollection radioMenuItemCollection rampColorPort rand randomizeFollicles randstate rangeControl readTake rebuildCurve rebuildSurface recordAttr recordDevice redo reference referenceEdit referenceQuery refineSubdivSelectionList refresh refreshAE registerPluginResource rehash reloadImage removeJoint removeMultiInstance removePanelCategory rename renameAttr renameSelectionList renameUI render renderGlobalsNode renderInfo renderLayerButton renderLayerParent renderLayerPostProcess renderLayerUnparent renderManip renderPartition renderQualityNode renderSettings renderThumbnailUpdate renderWindowEditor renderWindowSelectContext renderer reorder reorderDeformers requires reroot resampleFluid resetAE resetPfxToPolyCamera resetTool resolutionNode retarget reverseCurve reverseSurface revolve rgb_to_hsv rigidBody rigidSolver roll rollCtx rootOf rot rotate rotationInterpolation roundConstantRadius rowColumnLayout rowLayout runTimeCommand runup sampleImage saveAllShelves saveAttrPreset saveFluid saveImage saveInitialState saveMenu savePrefObjects savePrefs saveShelf saveToolSettings scale scaleBrushBrightness scaleComponents scaleConstraint scaleKey scaleKeyCtx sceneEditor sceneUIReplacement scmh scriptCtx scriptEditorInfo scriptJob scriptNode scriptTable scriptToShelf scriptedPanel scriptedPanelType scrollField scrollLayout sculpt searchPathArray seed selLoadSettings select selectContext selectCurveCV selectKey selectKeyCtx selectKeyframeRegionCtx selectMode selectPref selectPriority selectType selectedNodes selectionConnection separator setAttr setAttrEnumResource setAttrMapping setAttrNiceNameResource setConstraintRestPosition setDefaultShadingGroup setDrivenKeyframe setDynamic setEditCtx setEditor setFluidAttr setFocus setInfinity setInputDeviceMapping setKeyCtx setKeyPath setKeyframe setKeyframeBlendshapeTargetWts setMenuMode setNodeNiceNameResource setNodeTypeFlag setParent setParticleAttr setPfxToPolyCamera setPluginResource setProject setStampDensity setStartupMessage setState setToolTo setUITemplate setXformManip sets shadingConnection shadingGeometryRelCtx shadingLightRelCtx shadingNetworkCompare shadingNode shapeCompare shelfButton shelfLayout shelfTabLayout shellField shortNameOf showHelp showHidden showManipCtx showSelectionInTitle showShadingGroupAttrEditor showWindow sign simplify sin singleProfileBirailSurface size sizeBytes skinCluster skinPercent smoothCurve smoothTangentSurface smoothstep snap2to2 snapKey snapMode snapTogetherCtx snapshot soft softMod softModCtx sort sound soundControl source spaceLocator sphere sphrand spotLight spotLightPreviewPort spreadSheetEditor spring sqrt squareSurface srtContext stackTrace startString startsWith stitchAndExplodeShell stitchSurface stitchSurfacePoints strcmp stringArrayCatenate stringArrayContains stringArrayCount stringArrayInsertAtIndex stringArrayIntersector stringArrayRemove stringArrayRemoveAtIndex stringArrayRemoveDuplicates stringArrayRemoveExact stringArrayToString stringToStringArray strip stripPrefixFromName stroke subdAutoProjection subdCleanTopology subdCollapse subdDuplicateAndConnect subdEditUV subdListComponentConversion subdMapCut subdMapSewMove subdMatchTopology subdMirror subdToBlind subdToPoly subdTransferUVsToCache subdiv subdivCrease subdivDisplaySmoothness substitute substituteAllString substituteGeometry substring surface surfaceSampler surfaceShaderList swatchDisplayPort switchTable symbolButton symbolCheckBox sysFile system tabLayout tan tangentConstraint texLatticeDeformContext texManipContext texMoveContext texMoveUVShellContext texRotateContext texScaleContext texSelectContext texSelectShortestPathCtx texSmudgeUVContext texWinToolCtx text textCurves textField textFieldButtonGrp textFieldGrp textManip textScrollList textToShelf textureDisplacePlane textureHairColor texturePlacementContext textureWindow threadCount threePointArcCtx timeControl timePort timerX toNativePath toggle toggleAxis toggleWindowVisibility tokenize tokenizeList tolerance tolower toolButton toolCollection toolDropped toolHasOptions toolPropertyWindow torus toupper trace track trackCtx transferAttributes transformCompare transformLimits translator trim trunc truncateFluidCache truncateHairCache tumble tumbleCtx turbulence twoPointArcCtx uiRes uiTemplate unassignInputDevice undo undoInfo ungroup uniform unit unloadPlugin untangleUV untitledFileName untrim upAxis updateAE userCtx uvLink uvSnapshot validateShelfName vectorize view2dToolCtx viewCamera viewClipPlane viewFit viewHeadOn viewLookAt viewManip viewPlace viewSet visor volumeAxis vortex waitCursor warning webBrowser webBrowserPrefs whatIs window windowPref wire wireContext workspace wrinkle wrinkleContext writeTake xbmLangPathList xform",
-illegal:"</",contains:[e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{
-className:"string",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE]},{
-begin:/[$%@](\^\w\b|#\w+|[^\s\w{]|\{\w+\}|\w+)/
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]})})());
-hljs.registerLanguage("mercury",(()=>{"use strict";return e=>{
-const i=e.COMMENT("%","$"),n=e.inherit(e.APOS_STRING_MODE,{relevance:0
-}),r=e.inherit(e.QUOTE_STRING_MODE,{relevance:0})
-;return r.contains=r.contains.slice(),r.contains.push({className:"subst",
-begin:"\\\\[abfnrtv]\\|\\\\x[0-9a-fA-F]*\\\\\\|%[-+# *.0-9]*[dioxXucsfeEgGp]",
-relevance:0}),{name:"Mercury",aliases:["m","moo"],keywords:{
-keyword:"module use_module import_module include_module end_module initialise mutable initialize finalize finalise interface implementation pred mode func type inst solver any_pred any_func is semidet det nondet multi erroneous failure cc_nondet cc_multi typeclass instance where pragma promise external trace atomic or_else require_complete_switch require_det require_semidet require_multi require_nondet require_cc_multi require_cc_nondet require_erroneous require_failure",
-meta:"inline no_inline type_spec source_file fact_table obsolete memo loop_check minimal_model terminates does_not_terminate check_termination promise_equivalent_clauses foreign_proc foreign_decl foreign_code foreign_type foreign_import_module foreign_export_enum foreign_export foreign_enum may_call_mercury will_not_call_mercury thread_safe not_thread_safe maybe_thread_safe promise_pure promise_semipure tabled_for_io local untrailed trailed attach_to_io_state can_pass_as_mercury_type stable will_not_throw_exception may_modify_trail will_not_modify_trail may_duplicate may_not_duplicate affects_liveness does_not_affect_liveness doesnt_affect_liveness no_sharing unknown_sharing sharing",
-built_in:"some all not if then else true fail false try catch catch_any semidet_true semidet_false semidet_fail impure_true impure semipure"
-},contains:[{className:"built_in",variants:[{begin:"<=>"},{begin:"<=",
-relevance:0},{begin:"=>",relevance:0},{begin:"/\\\\"},{begin:"\\\\/"}]},{
-className:"built_in",variants:[{begin:":-\\|--\x3e"},{begin:"=",relevance:0}]
-},i,e.C_BLOCK_COMMENT_MODE,{className:"number",begin:"0'.\\|0[box][0-9a-fA-F]*"
-},e.NUMBER_MODE,n,r,{begin:/:-/},{begin:/\.$/}]}}})());
-hljs.registerLanguage("mipsasm",(()=>{"use strict";return e=>({
-name:"MIPS Assembly",case_insensitive:!0,aliases:["mips"],keywords:{
-$pattern:"\\.?"+e.IDENT_RE,
-meta:".2byte .4byte .align .ascii .asciz .balign .byte .code .data .else .end .endif .endm .endr .equ .err .exitm .extern .global .hword .if .ifdef .ifndef .include .irp .long .macro .rept .req .section .set .skip .space .text .word .ltorg ",
-built_in:"$0 $1 $2 $3 $4 $5 $6 $7 $8 $9 $10 $11 $12 $13 $14 $15 $16 $17 $18 $19 $20 $21 $22 $23 $24 $25 $26 $27 $28 $29 $30 $31 zero at v0 v1 a0 a1 a2 a3 a4 a5 a6 a7 t0 t1 t2 t3 t4 t5 t6 t7 t8 t9 s0 s1 s2 s3 s4 s5 s6 s7 s8 k0 k1 gp sp fp ra $f0 $f1 $f2 $f2 $f4 $f5 $f6 $f7 $f8 $f9 $f10 $f11 $f12 $f13 $f14 $f15 $f16 $f17 $f18 $f19 $f20 $f21 $f22 $f23 $f24 $f25 $f26 $f27 $f28 $f29 $f30 $f31 Context Random EntryLo0 EntryLo1 Context PageMask Wired EntryHi HWREna BadVAddr Count Compare SR IntCtl SRSCtl SRSMap Cause EPC PRId EBase Config Config1 Config2 Config3 LLAddr Debug DEPC DESAVE CacheErr ECC ErrorEPC TagLo DataLo TagHi DataHi WatchLo WatchHi PerfCtl PerfCnt "
-},contains:[{className:"keyword",
-begin:"\\b(addi?u?|andi?|b(al)?|beql?|bgez(al)?l?|bgtzl?|blezl?|bltz(al)?l?|bnel?|cl[oz]|divu?|ext|ins|j(al)?|jalr(\\.hb)?|jr(\\.hb)?|lbu?|lhu?|ll|lui|lw[lr]?|maddu?|mfhi|mflo|movn|movz|move|msubu?|mthi|mtlo|mul|multu?|nop|nor|ori?|rotrv?|sb|sc|se[bh]|sh|sllv?|slti?u?|srav?|srlv?|subu?|sw[lr]?|xori?|wsbh|abs\\.[sd]|add\\.[sd]|alnv.ps|bc1[ft]l?|c\\.(s?f|un|u?eq|[ou]lt|[ou]le|ngle?|seq|l[et]|ng[et])\\.[sd]|(ceil|floor|round|trunc)\\.[lw]\\.[sd]|cfc1|cvt\\.d\\.[lsw]|cvt\\.l\\.[dsw]|cvt\\.ps\\.s|cvt\\.s\\.[dlw]|cvt\\.s\\.p[lu]|cvt\\.w\\.[dls]|div\\.[ds]|ldx?c1|luxc1|lwx?c1|madd\\.[sd]|mfc1|mov[fntz]?\\.[ds]|msub\\.[sd]|mth?c1|mul\\.[ds]|neg\\.[ds]|nmadd\\.[ds]|nmsub\\.[ds]|p[lu][lu]\\.ps|recip\\.fmt|r?sqrt\\.[ds]|sdx?c1|sub\\.[ds]|suxc1|swx?c1|break|cache|d?eret|[de]i|ehb|mfc0|mtc0|pause|prefx?|rdhwr|rdpgpr|sdbbp|ssnop|synci?|syscall|teqi?|tgei?u?|tlb(p|r|w[ir])|tlti?u?|tnei?|wait|wrpgpr)",
-end:"\\s"
-},e.COMMENT("[;#](?!\\s*$)","$"),e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,{
-className:"string",begin:"'",end:"[^\\\\]'",relevance:0},{className:"title",
-begin:"\\|",end:"\\|",illegal:"\\n",relevance:0},{className:"number",variants:[{
-begin:"0x[0-9a-f]+"},{begin:"\\b-?\\d+"}],relevance:0},{className:"symbol",
-variants:[{begin:"^\\s*[a-z_\\.\\$][a-z0-9_\\.\\$]+:"},{begin:"^\\s*[0-9]+:"},{
-begin:"[0-9]+[bf]"}],relevance:0}],illegal:/\//})})());
-hljs.registerLanguage("mizar",(()=>{"use strict";return e=>({name:"Mizar",
-keywords:"environ vocabularies notations constructors definitions registrations theorems schemes requirements begin end definition registration cluster existence pred func defpred deffunc theorem proof let take assume then thus hence ex for st holds consider reconsider such that and in provided of as from be being by means equals implies iff redefine define now not or attr is mode suppose per cases set thesis contradiction scheme reserve struct correctness compatibility coherence symmetry assymetry reflexivity irreflexivity connectedness uniqueness commutativity idempotence involutiveness projectivity",
-contains:[e.COMMENT("::","$")]})})());
-hljs.registerLanguage("perl",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}function t(...n){
-return"("+n.map((n=>e(n))).join("|")+")"}return e=>{
-const r=/[dualxmsipngr]{0,12}/,s={$pattern:/[\w.]+/,
-keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0"
-},i={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:s},a={begin:/->\{/,
-end:/\}/},o={variants:[{begin:/\$\d/},{
-begin:n(/[$%@](\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])")
-},{begin:/[$%@][^\s\w{]/,relevance:0}]
-},c=[e.BACKSLASH_ESCAPE,i,o],g=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],l=(e,t,s="\\1")=>{
-const i="\\1"===s?s:n(s,t)
-;return n(n("(?:",e,")"),t,/(?:\\.|[^\\\/])*?/,i,/(?:\\.|[^\\\/])*?/,s,r)
-},d=(e,t,s)=>n(n("(?:",e,")"),t,/(?:\\.|[^\\\/])*?/,s,r),p=[o,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{
-endsWithParent:!0}),a,{className:"string",contains:c,variants:[{
-begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[",
-end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{
-begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">",
-relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'",
-contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`",
-contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{
-begin:"-?\\w+\\s*=>",relevance:0}]},{className:"number",
-begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",
-relevance:0},{
-begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*",
-keywords:"split return print reverse grep",relevance:0,
-contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{
-begin:l("s|tr|y",t(...g))},{begin:l("s|tr|y","\\(","\\)")},{
-begin:l("s|tr|y","\\[","\\]")},{begin:l("s|tr|y","\\{","\\}")}],relevance:2},{
-className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{
-begin:d("(?:m|qr)?",/\//,/\//)},{begin:d("m|qr",t(...g),/\1/)},{
-begin:d("m|qr",/\(/,/\)/)},{begin:d("m|qr",/\[/,/\]/)},{
-begin:d("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub",
-end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{
-begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$",
-subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}]
-}];return i.contains=p,a.contains=p,{name:"Perl",aliases:["pl","pm"],keywords:s,
-contains:p}}})());
-hljs.registerLanguage("mojolicious",(()=>{"use strict";return e=>({
-name:"Mojolicious",subLanguage:"xml",contains:[{className:"meta",
-begin:"^__(END|DATA)__$"},{begin:"^\\s*%{1,2}={0,2}",end:"$",subLanguage:"perl"
-},{begin:"<%{1,2}={0,2}",end:"={0,1}%>",subLanguage:"perl",excludeBegin:!0,
-excludeEnd:!0}]})})());
-hljs.registerLanguage("monkey",(()=>{"use strict";return e=>{const n={
-className:"number",relevance:0,variants:[{begin:"[$][a-fA-F0-9]+"
-},e.NUMBER_MODE]};return{name:"Monkey",case_insensitive:!0,keywords:{
-keyword:"public private property continue exit extern new try catch eachin not abstract final select case default const local global field end if then else elseif endif while wend repeat until forever for to step next return module inline throw import",
-built_in:"DebugLog DebugStop Error Print ACos ACosr ASin ASinr ATan ATan2 ATan2r ATanr Abs Abs Ceil Clamp Clamp Cos Cosr Exp Floor Log Max Max Min Min Pow Sgn Sgn Sin Sinr Sqrt Tan Tanr Seed PI HALFPI TWOPI",
-literal:"true false null and or shl shr mod"},illegal:/\/\*/,
-contains:[e.COMMENT("#rem","#end"),e.COMMENT("'","$",{relevance:0}),{
-className:"function",beginKeywords:"function method",end:"[(=:]|$",illegal:/\n/,
-contains:[e.UNDERSCORE_TITLE_MODE]},{className:"class",
-beginKeywords:"class interface",end:"$",contains:[{
-beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{
-className:"built_in",begin:"\\b(self|super)\\b"},{className:"meta",
-begin:"\\s*#",end:"$",keywords:{"meta-keyword":"if else elseif endif end then"}
-},{className:"meta",begin:"^\\s*strict\\b"},{beginKeywords:"alias",end:"=",
-contains:[e.UNDERSCORE_TITLE_MODE]},e.QUOTE_STRING_MODE,n]}}})());
-hljs.registerLanguage("moonscript",(()=>{"use strict";return e=>{const n={
-keyword:"if then not for in while do return else elseif break continue switch and or unless when class extends super local import export from using",
-literal:"true false nil",
-built_in:"_G _VERSION assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall coroutine debug io math os package string table"
-},s="[A-Za-z$_][0-9A-Za-z$_]*",a={className:"subst",begin:/#\{/,end:/\}/,
-keywords:n},t=[e.inherit(e.C_NUMBER_MODE,{starts:{end:"(\\s*/)?",relevance:0}
-}),{className:"string",variants:[{begin:/'/,end:/'/,
-contains:[e.BACKSLASH_ESCAPE]},{begin:/"/,end:/"/,
-contains:[e.BACKSLASH_ESCAPE,a]}]},{className:"built_in",begin:"@__"+e.IDENT_RE
-},{begin:"@"+e.IDENT_RE},{begin:e.IDENT_RE+"\\\\"+e.IDENT_RE}];a.contains=t
-;const i=e.inherit(e.TITLE_MODE,{begin:s}),r="(\\(.*\\)\\s*)?\\B[-=]>",l={
-className:"params",begin:"\\([^\\(]",returnBegin:!0,contains:[{begin:/\(/,
-end:/\)/,keywords:n,contains:["self"].concat(t)}]};return{name:"MoonScript",
-aliases:["moon"],keywords:n,illegal:/\/\*/,
-contains:t.concat([e.COMMENT("--","$"),{className:"function",
-begin:"^\\s*"+s+"\\s*=\\s*"+r,end:"[-=]>",returnBegin:!0,contains:[i,l]},{
-begin:/[\(,:=]\s*/,relevance:0,contains:[{className:"function",begin:r,
-end:"[-=]>",returnBegin:!0,contains:[l]}]},{className:"class",
-beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{
-beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[i]},i]
-},{className:"name",begin:s+":",end:":",returnBegin:!0,returnEnd:!0,relevance:0
-}])}}})());
-hljs.registerLanguage("n1ql",(()=>{"use strict";return e=>({name:"N1QL",
-case_insensitive:!0,contains:[{
-beginKeywords:"build create index delete drop explain infer|10 insert merge prepare select update upsert|10",
-end:/;/,endsWithParent:!0,keywords:{
-keyword:"all alter analyze and any array as asc begin between binary boolean break bucket build by call case cast cluster collate collection commit connect continue correlate cover create database dataset datastore declare decrement delete derived desc describe distinct do drop each element else end every except exclude execute exists explain fetch first flatten for force from function grant group gsi having if ignore ilike in include increment index infer inline inner insert intersect into is join key keys keyspace known last left let letting like limit lsm map mapping matched materialized merge minus namespace nest not number object offset on option or order outer over parse partition password path pool prepare primary private privilege procedure public raw realm reduce rename return returning revoke right role rollback satisfies schema select self semi set show some start statistics string system then to transaction trigger truncate under union unique unknown unnest unset update upsert use user using validate value valued values via view when where while with within work xor",
-literal:"true false null missing|5",
-built_in:"array_agg array_append array_concat array_contains array_count array_distinct array_ifnull array_length array_max array_min array_position array_prepend array_put array_range array_remove array_repeat array_replace array_reverse array_sort array_sum avg count max min sum greatest least ifmissing ifmissingornull ifnull missingif nullif ifinf ifnan ifnanorinf naninf neginfif posinfif clock_millis clock_str date_add_millis date_add_str date_diff_millis date_diff_str date_part_millis date_part_str date_trunc_millis date_trunc_str duration_to_str millis str_to_millis millis_to_str millis_to_utc millis_to_zone_name now_millis now_str str_to_duration str_to_utc str_to_zone_name decode_json encode_json encoded_size poly_length base64 base64_encode base64_decode meta uuid abs acos asin atan atan2 ceil cos degrees e exp ln log floor pi power radians random round sign sin sqrt tan trunc object_length object_names object_pairs object_inner_pairs object_values object_inner_values object_add object_put object_remove object_unwrap regexp_contains regexp_like regexp_position regexp_replace contains initcap length lower ltrim position repeat replace rtrim split substr title trim upper isarray isatom isboolean isnumber isobject isstring type toarray toatom toboolean tonumber toobject tostring"
-},contains:[{className:"string",begin:"'",end:"'",contains:[e.BACKSLASH_ESCAPE]
-},{className:"string",begin:'"',end:'"',contains:[e.BACKSLASH_ESCAPE]},{
-className:"symbol",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE],relevance:2
-},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE]},e.C_BLOCK_COMMENT_MODE]})})());
-hljs.registerLanguage("nginx",(()=>{"use strict";return e=>{const n={
-className:"variable",variants:[{begin:/\$\d+/},{begin:/\$\{/,end:/\}/},{
-begin:/[$@]/+e.UNDERSCORE_IDENT_RE}]},a={endsWithParent:!0,keywords:{
-$pattern:"[a-z/_]+",
-literal:"on off yes no true false none blocked debug info notice warn error crit select break last permanent redirect kqueue rtsig epoll poll /dev/poll"
-},relevance:0,illegal:"=>",contains:[e.HASH_COMMENT_MODE,{className:"string",
-contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/
-}]},{begin:"([a-z]+):/",end:"\\s",endsWithParent:!0,excludeEnd:!0,contains:[n]
-},{className:"regexp",contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:"\\s\\^",
-end:"\\s|\\{|;",returnEnd:!0},{begin:"~\\*?\\s+",end:"\\s|\\{|;",returnEnd:!0},{
-begin:"\\*(\\.[a-z\\-]+)+"},{begin:"([a-z\\-]+\\.)+\\*"}]},{className:"number",
-begin:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},{
-className:"number",begin:"\\b\\d+[kKmMgGdshdwy]*\\b",relevance:0},n]};return{
-name:"Nginx config",aliases:["nginxconf"],contains:[e.HASH_COMMENT_MODE,{
-begin:e.UNDERSCORE_IDENT_RE+"\\s+\\{",returnBegin:!0,end:/\{/,contains:[{
-className:"section",begin:e.UNDERSCORE_IDENT_RE}],relevance:0},{
-begin:e.UNDERSCORE_IDENT_RE+"\\s",end:";|\\{",returnBegin:!0,contains:[{
-className:"attribute",begin:e.UNDERSCORE_IDENT_RE,starts:a}],relevance:0}],
-illegal:"[^\\s\\}]"}}})());
-hljs.registerLanguage("nim",(()=>{"use strict";return e=>({name:"Nim",keywords:{
-keyword:"addr and as asm bind block break case cast const continue converter discard distinct div do elif else end enum except export finally for from func generic if import in include interface is isnot iterator let macro method mixin mod nil not notin object of or out proc ptr raise ref return shl shr static template try tuple type using var when while with without xor yield",
-literal:"shared guarded stdin stdout stderr result true false",
-built_in:"int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 float float32 float64 bool char string cstring pointer expr stmt void auto any range array openarray varargs seq set clong culong cchar cschar cshort cint csize clonglong cfloat cdouble clongdouble cuchar cushort cuint culonglong cstringarray semistatic"
-},contains:[{className:"meta",begin:/\{\./,end:/\.\}/,relevance:10},{
-className:"string",begin:/[a-zA-Z]\w*"/,end:/"/,contains:[{begin:/""/}]},{
-className:"string",begin:/([a-zA-Z]\w*)?"""/,end:/"""/},e.QUOTE_STRING_MODE,{
-className:"type",begin:/\b[A-Z]\w+\b/,relevance:0},{className:"number",
-relevance:0,variants:[{
-begin:/\b(0[xX][0-9a-fA-F][_0-9a-fA-F]*)('?[iIuU](8|16|32|64))?/},{
-begin:/\b(0o[0-7][_0-7]*)('?[iIuUfF](8|16|32|64))?/},{
-begin:/\b(0(b|B)[01][_01]*)('?[iIuUfF](8|16|32|64))?/},{
-begin:/\b(\d[_\d]*)('?[iIuUfF](8|16|32|64))?/}]},e.HASH_COMMENT_MODE]})})());
-hljs.registerLanguage("nix",(()=>{"use strict";return e=>{const n={
-keyword:"rec with let in inherit assert if else then",
-literal:"true false or and null",
-built_in:"import abort baseNameOf dirOf isNull builtins map removeAttrs throw toString derivation"
-},i={className:"subst",begin:/\$\{/,end:/\}/,keywords:n},s={className:"string",
-contains:[i],variants:[{begin:"''",end:"''"},{begin:'"',end:'"'}]
-},t=[e.NUMBER_MODE,e.HASH_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s,{
-begin:/[a-zA-Z0-9-_]+(\s*=)/,returnBegin:!0,relevance:0,contains:[{
-className:"attr",begin:/\S+/}]}];return i.contains=t,{name:"Nix",
-aliases:["nixos"],keywords:n,contains:t}}})());
-hljs.registerLanguage("node-repl",(()=>{"use strict";return e=>({
-name:"Node REPL",contains:[{className:"meta",starts:{end:/ |$/,starts:{end:"$",
-subLanguage:"javascript"}},variants:[{begin:/^>(?=[ ]|$)/},{
-begin:/^\.\.\.(?=[ ]|$)/}]}]})})());
-hljs.registerLanguage("nsis",(()=>{"use strict";return e=>{const t={
-className:"variable",begin:/\$+\{[\w.:-]+\}/},n={className:"variable",
-begin:/\$+\w+/,illegal:/\(\)\{\}/},i={className:"variable",
-begin:/\$+\([\w^.:-]+\)/},r={className:"string",variants:[{begin:'"',end:'"'},{
-begin:"'",end:"'"},{begin:"`",end:"`"}],illegal:/\n/,contains:[{
-className:"meta",begin:/\$(\\[nrt]|\$)/},{className:"variable",
-begin:/\$(ADMINTOOLS|APPDATA|CDBURN_AREA|CMDLINE|COMMONFILES32|COMMONFILES64|COMMONFILES|COOKIES|DESKTOP|DOCUMENTS|EXEDIR|EXEFILE|EXEPATH|FAVORITES|FONTS|HISTORY|HWNDPARENT|INSTDIR|INTERNET_CACHE|LANGUAGE|LOCALAPPDATA|MUSIC|NETHOOD|OUTDIR|PICTURES|PLUGINSDIR|PRINTHOOD|PROFILE|PROGRAMFILES32|PROGRAMFILES64|PROGRAMFILES|QUICKLAUNCH|RECENT|RESOURCES_LOCALIZED|RESOURCES|SENDTO|SMPROGRAMS|SMSTARTUP|STARTMENU|SYSDIR|TEMP|TEMPLATES|VIDEOS|WINDIR)/
-},t,n,i]};return{name:"NSIS",case_insensitive:!1,keywords:{
-keyword:"Abort AddBrandingImage AddSize AllowRootDirInstall AllowSkipFiles AutoCloseWindow BGFont BGGradient BrandingText BringToFront Call CallInstDLL Caption ChangeUI CheckBitmap ClearErrors CompletedText ComponentText CopyFiles CRCCheck CreateDirectory CreateFont CreateShortCut Delete DeleteINISec DeleteINIStr DeleteRegKey DeleteRegValue DetailPrint DetailsButtonText DirText DirVar DirVerify EnableWindow EnumRegKey EnumRegValue Exch Exec ExecShell ExecShellWait ExecWait ExpandEnvStrings File FileBufSize FileClose FileErrorText FileOpen FileRead FileReadByte FileReadUTF16LE FileReadWord FileWriteUTF16LE FileSeek FileWrite FileWriteByte FileWriteWord FindClose FindFirst FindNext FindWindow FlushINI GetCurInstType GetCurrentAddress GetDlgItem GetDLLVersion GetDLLVersionLocal GetErrorLevel GetFileTime GetFileTimeLocal GetFullPathName GetFunctionAddress GetInstDirError GetKnownFolderPath GetLabelAddress GetTempFileName Goto HideWindow Icon IfAbort IfErrors IfFileExists IfRebootFlag IfRtlLanguage IfShellVarContextAll IfSilent InitPluginsDir InstallButtonText InstallColors InstallDir InstallDirRegKey InstProgressFlags InstType InstTypeGetText InstTypeSetText Int64Cmp Int64CmpU Int64Fmt IntCmp IntCmpU IntFmt IntOp IntPtrCmp IntPtrCmpU IntPtrOp IsWindow LangString LicenseBkColor LicenseData LicenseForceSelection LicenseLangString LicenseText LoadAndSetImage LoadLanguageFile LockWindow LogSet LogText ManifestDPIAware ManifestLongPathAware ManifestMaxVersionTested ManifestSupportedOS MessageBox MiscButtonText Name Nop OutFile Page PageCallbacks PEAddResource PEDllCharacteristics PERemoveResource PESubsysVer Pop Push Quit ReadEnvStr ReadINIStr ReadRegDWORD ReadRegStr Reboot RegDLL Rename RequestExecutionLevel ReserveFile Return RMDir SearchPath SectionGetFlags SectionGetInstTypes SectionGetSize SectionGetText SectionIn SectionSetFlags SectionSetInstTypes SectionSetSize SectionSetText SendMessage SetAutoClose SetBrandingImage SetCompress SetCompressor SetCompressorDictSize SetCtlColors SetCurInstType SetDatablockOptimize SetDateSave SetDetailsPrint SetDetailsView SetErrorLevel SetErrors SetFileAttributes SetFont SetOutPath SetOverwrite SetRebootFlag SetRegView SetShellVarContext SetSilent ShowInstDetails ShowUninstDetails ShowWindow SilentInstall SilentUnInstall Sleep SpaceTexts StrCmp StrCmpS StrCpy StrLen SubCaption Unicode UninstallButtonText UninstallCaption UninstallIcon UninstallSubCaption UninstallText UninstPage UnRegDLL Var VIAddVersionKey VIFileVersion VIProductVersion WindowIcon WriteINIStr WriteRegBin WriteRegDWORD WriteRegExpandStr WriteRegMultiStr WriteRegNone WriteRegStr WriteUninstaller XPStyle",
-literal:"admin all auto both bottom bzip2 colored components current custom directory false force hide highest ifdiff ifnewer instfiles lastused leave left license listonly lzma nevershow none normal notset off on open print right show silent silentlog smooth textonly top true try un.components un.custom un.directory un.instfiles un.license uninstConfirm user Win10 Win7 Win8 WinVista zlib"
-},contains:[e.HASH_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.COMMENT(";","$",{
-relevance:0}),{className:"function",
-beginKeywords:"Function PageEx Section SectionGroup",end:"$"},r,{
-className:"keyword",
-begin:/!(addincludedir|addplugindir|appendfile|cd|define|delfile|echo|else|endif|error|execute|finalize|getdllversion|gettlbversion|if|ifdef|ifmacrodef|ifmacrondef|ifndef|include|insertmacro|macro|macroend|makensis|packhdr|searchparse|searchreplace|system|tempfile|undef|verbose|warning)/
-},t,n,i,{className:"params",
-begin:"(ARCHIVE|FILE_ATTRIBUTE_ARCHIVE|FILE_ATTRIBUTE_NORMAL|FILE_ATTRIBUTE_OFFLINE|FILE_ATTRIBUTE_READONLY|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_TEMPORARY|HKCR|HKCU|HKDD|HKEY_CLASSES_ROOT|HKEY_CURRENT_CONFIG|HKEY_CURRENT_USER|HKEY_DYN_DATA|HKEY_LOCAL_MACHINE|HKEY_PERFORMANCE_DATA|HKEY_USERS|HKLM|HKPD|HKU|IDABORT|IDCANCEL|IDIGNORE|IDNO|IDOK|IDRETRY|IDYES|MB_ABORTRETRYIGNORE|MB_DEFBUTTON1|MB_DEFBUTTON2|MB_DEFBUTTON3|MB_DEFBUTTON4|MB_ICONEXCLAMATION|MB_ICONINFORMATION|MB_ICONQUESTION|MB_ICONSTOP|MB_OK|MB_OKCANCEL|MB_RETRYCANCEL|MB_RIGHT|MB_RTLREADING|MB_SETFOREGROUND|MB_TOPMOST|MB_USERICON|MB_YESNO|NORMAL|OFFLINE|READONLY|SHCTX|SHELL_CONTEXT|SYSTEM|TEMPORARY)"
-},{className:"class",begin:/\w+::\w+/},e.NUMBER_MODE]}}})());
-hljs.registerLanguage("objectivec",(()=>{"use strict";return e=>{
-const n=/[a-zA-Z@][a-zA-Z0-9_]*/,_={$pattern:n,
-keyword:"@interface @class @protocol @implementation"};return{
-name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"],
-keywords:{$pattern:n,
-keyword:"int float while char export sizeof typedef const struct for union unsigned long volatile static bool mutable if do return goto void enum else break extern asm case short default double register explicit signed typename this switch continue wchar_t inline readonly assign readwrite self @synchronized id typeof nonatomic super unichar IBOutlet IBAction strong weak copy in out inout bycopy byref oneway __strong __weak __block __autoreleasing @private @protected @public @try @property @end @throw @catch @finally @autoreleasepool @synthesize @dynamic @selector @optional @required @encode @package @import @defs @compatibility_alias __bridge __bridge_transfer __bridge_retained __bridge_retain __covariant __contravariant __kindof _Nonnull _Nullable _Null_unspecified __FUNCTION__ __PRETTY_FUNCTION__ __attribute__ getter setter retain unsafe_unretained nonnull nullable null_unspecified null_resettable class instancetype NS_DESIGNATED_INITIALIZER NS_UNAVAILABLE NS_REQUIRES_SUPER NS_RETURNS_INNER_POINTER NS_INLINE NS_AVAILABLE NS_DEPRECATED NS_ENUM NS_OPTIONS NS_SWIFT_UNAVAILABLE NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END NS_REFINED_FOR_SWIFT NS_SWIFT_NAME NS_SWIFT_NOTHROW NS_DURING NS_HANDLER NS_ENDHANDLER NS_VALUERETURN NS_VOIDRETURN",
-literal:"false true FALSE TRUE nil YES NO NULL",
-built_in:"BOOL dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once"
-},illegal:"</",contains:[{className:"built_in",
-begin:"\\b(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)\\w+"
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{
-className:"string",variants:[{begin:'@"',end:'"',illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE]}]},{className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,
-keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},e.inherit(e.QUOTE_STRING_MODE,{
-className:"meta-string"}),{className:"meta-string",begin:/<.*?>/,end:/$/,
-illegal:"\\n"},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{
-className:"class",begin:"("+_.keyword.split(" ").join("|")+")\\b",end:/(\{|$)/,
-excludeEnd:!0,keywords:_,contains:[e.UNDERSCORE_TITLE_MODE]},{
-begin:"\\."+e.UNDERSCORE_IDENT_RE,relevance:0}]}}})());
-hljs.registerLanguage("ocaml",(()=>{"use strict";return e=>({name:"OCaml",
-aliases:["ml"],keywords:{$pattern:"[a-z_]\\w*!?",
-keyword:"and as assert asr begin class constraint do done downto else end exception external for fun function functor if in include inherit! inherit initializer land lazy let lor lsl lsr lxor match method!|10 method mod module mutable new object of open! open or private rec sig struct then to try type val! val virtual when while with parser value",
-built_in:"array bool bytes char exn|5 float int int32 int64 list lazy_t|5 nativeint|5 string unit in_channel out_channel ref",
-literal:"true false"},illegal:/\/\/|>>/,contains:[{className:"literal",
-begin:"\\[(\\|\\|)?\\]|\\(\\)",relevance:0},e.COMMENT("\\(\\*","\\*\\)",{
-contains:["self"]}),{className:"symbol",begin:"'[A-Za-z_](?!')[\\w']*"},{
-className:"type",begin:"`[A-Z][\\w']*"},{className:"type",
-begin:"\\b[A-Z][\\w']*",relevance:0},{begin:"[a-z_]\\w*'[\\w']*",relevance:0
-},e.inherit(e.APOS_STRING_MODE,{className:"string",relevance:0
-}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"number",
-begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",
-relevance:0},{begin:/->/}]})})());
-hljs.registerLanguage("openscad",(()=>{"use strict";return e=>{const n={
-className:"keyword",begin:"\\$(f[asn]|t|vp[rtd]|children)"},r={
-className:"number",begin:"\\b\\d+(\\.\\d+)?(e-?\\d+)?",relevance:0
-},s=e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),a={className:"function",
-beginKeywords:"module function",end:/=|\{/,contains:[{className:"params",
-begin:"\\(",end:"\\)",contains:["self",r,s,n,{className:"literal",
-begin:"false|true|PI|undef"}]},e.UNDERSCORE_TITLE_MODE]};return{name:"OpenSCAD",
-aliases:["scad"],keywords:{
-keyword:"function module include use for intersection_for if else \\%",
-literal:"false true PI undef",
-built_in:"circle square polygon text sphere cube cylinder polyhedron translate rotate scale resize mirror multmatrix color offset hull minkowski union difference intersection abs sign sin cos tan acos asin atan atan2 floor round ceil ln log pow sqrt exp rands min max concat lookup str chr search version version_num norm cross parent_module echo import import_dxf dxf_linear_extrude linear_extrude rotate_extrude surface projection render children dxf_cross dxf_dim let assign"
-},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,r,{className:"meta",
-keywords:{"meta-keyword":"include use"},begin:"include|use <",end:">"},s,n,{
-begin:"[*!#%]",relevance:0},a]}}})());
-hljs.registerLanguage("oxygene",(()=>{"use strict";return e=>{const n={
-$pattern:/\.?\w+/,
-keyword:"abstract add and array as asc aspect assembly async begin break block by case class concat const copy constructor continue create default delegate desc distinct div do downto dynamic each else empty end ensure enum equals event except exit extension external false final finalize finalizer finally flags for forward from function future global group has if implementation implements implies in index inherited inline interface into invariants is iterator join locked locking loop matching method mod module namespace nested new nil not notify nullable of old on operator or order out override parallel params partial pinned private procedure property protected public queryable raise read readonly record reintroduce remove repeat require result reverse sealed select self sequence set shl shr skip static step soft take then to true try tuple type union unit unsafe until uses using var virtual raises volatile where while with write xor yield await mapped deprecated stdcall cdecl pascal register safecall overload library platform reference packed strict published autoreleasepool selector strong weak unretained"
-},r=e.COMMENT(/\{/,/\}/,{relevance:0}),a=e.COMMENT("\\(\\*","\\*\\)",{
-relevance:10}),t={className:"string",begin:"'",end:"'",contains:[{begin:"''"}]
-},s={className:"string",begin:"(#\\d+)+"},i={className:"function",
-beginKeywords:"function constructor destructor procedure method",end:"[:;]",
-keywords:"function constructor|10 destructor|10 procedure|10 method|10",
-contains:[e.TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",keywords:n,
-contains:[t,s]},r,a]};return{name:"Oxygene",case_insensitive:!0,keywords:n,
-illegal:'("|\\$[G-Zg-z]|\\/\\*|</|=>|->)',
-contains:[r,a,e.C_LINE_COMMENT_MODE,t,s,e.NUMBER_MODE,i,{className:"class",
-begin:"=\\bclass\\b",end:"end;",keywords:n,
-contains:[t,s,r,a,e.C_LINE_COMMENT_MODE,i]}]}}})());
-hljs.registerLanguage("parser3",(()=>{"use strict";return e=>{
-const a=e.COMMENT(/\{/,/\}/,{contains:["self"]});return{name:"Parser3",
-subLanguage:"xml",relevance:0,
-contains:[e.COMMENT("^#","$"),e.COMMENT(/\^rem\{/,/\}/,{relevance:10,
-contains:[a]}),{className:"meta",begin:"^@(?:BASE|USE|CLASS|OPTIONS)$",
-relevance:10},{className:"title",
-begin:"@[\\w\\-]+\\[[\\w^;\\-]*\\](?:\\[[\\w^;\\-]*\\])?(?:.*)$"},{
-className:"variable",begin:/\$\{?[\w\-.:]+\}?/},{className:"keyword",
-begin:/\^[\w\-.:]+/},{className:"number",begin:"\\^#[0-9a-fA-F]+"
-},e.C_NUMBER_MODE]}}})());
-hljs.registerLanguage("pf",(()=>{"use strict";return t=>({
-name:"Packet Filter config",aliases:["pf.conf"],keywords:{
-$pattern:/[a-z0-9_<>-]+/,
-built_in:"block match pass load anchor|5 antispoof|10 set table",
-keyword:"in out log quick on rdomain inet inet6 proto from port os to route allow-opts divert-packet divert-reply divert-to flags group icmp-type icmp6-type label once probability recieved-on rtable prio queue tos tag tagged user keep fragment for os drop af-to|10 binat-to|10 nat-to|10 rdr-to|10 bitmask least-stats random round-robin source-hash static-port dup-to reply-to route-to parent bandwidth default min max qlimit block-policy debug fingerprints hostid limit loginterface optimization reassemble ruleset-optimization basic none profile skip state-defaults state-policy timeout const counters persist no modulate synproxy state|5 floating if-bound no-sync pflow|10 sloppy source-track global rule max-src-nodes max-src-states max-src-conn max-src-conn-rate overload flush scrub|5 max-mss min-ttl no-df|10 random-id",
-literal:"all any no-route self urpf-failed egress|5 unknown"},
-contains:[t.HASH_COMMENT_MODE,t.NUMBER_MODE,t.QUOTE_STRING_MODE,{
-className:"variable",begin:/\$[\w\d#@][\w\d_]*/},{className:"variable",
-begin:/<(?!\/)/,end:/>/}]})})());
-hljs.registerLanguage("pgsql",(()=>{"use strict";return E=>{
-const T=E.COMMENT("--","$"),N="\\$([a-zA-Z_]?|[a-zA-Z_][a-zA-Z_0-9]*)\\$",A="BIGINT INT8 BIGSERIAL SERIAL8 BIT VARYING VARBIT BOOLEAN BOOL BOX BYTEA CHARACTER CHAR VARCHAR CIDR CIRCLE DATE DOUBLE PRECISION FLOAT8 FLOAT INET INTEGER INT INT4 INTERVAL JSON JSONB LINE LSEG|10 MACADDR MACADDR8 MONEY NUMERIC DEC DECIMAL PATH POINT POLYGON REAL FLOAT4 SMALLINT INT2 SMALLSERIAL|10 SERIAL2|10 SERIAL|10 SERIAL4|10 TEXT TIME ZONE TIMETZ|10 TIMESTAMP TIMESTAMPTZ|10 TSQUERY|10 TSVECTOR|10 TXID_SNAPSHOT|10 UUID XML NATIONAL NCHAR INT4RANGE|10 INT8RANGE|10 NUMRANGE|10 TSRANGE|10 TSTZRANGE|10 DATERANGE|10 ANYELEMENT ANYARRAY ANYNONARRAY ANYENUM ANYRANGE CSTRING INTERNAL RECORD PG_DDL_COMMAND VOID UNKNOWN OPAQUE REFCURSOR NAME OID REGPROC|10 REGPROCEDURE|10 REGOPER|10 REGOPERATOR|10 REGCLASS|10 REGTYPE|10 REGROLE|10 REGNAMESPACE|10 REGCONFIG|10 REGDICTIONARY|10 ",R=A.trim().split(" ").map((E=>E.split("|")[0])).join("|"),I="ARRAY_AGG AVG BIT_AND BIT_OR BOOL_AND BOOL_OR COUNT EVERY JSON_AGG JSONB_AGG JSON_OBJECT_AGG JSONB_OBJECT_AGG MAX MIN MODE STRING_AGG SUM XMLAGG CORR COVAR_POP COVAR_SAMP REGR_AVGX REGR_AVGY REGR_COUNT REGR_INTERCEPT REGR_R2 REGR_SLOPE REGR_SXX REGR_SXY REGR_SYY STDDEV STDDEV_POP STDDEV_SAMP VARIANCE VAR_POP VAR_SAMP PERCENTILE_CONT PERCENTILE_DISC ROW_NUMBER RANK DENSE_RANK PERCENT_RANK CUME_DIST NTILE LAG LEAD FIRST_VALUE LAST_VALUE NTH_VALUE NUM_NONNULLS NUM_NULLS ABS CBRT CEIL CEILING DEGREES DIV EXP FLOOR LN LOG MOD PI POWER RADIANS ROUND SCALE SIGN SQRT TRUNC WIDTH_BUCKET RANDOM SETSEED ACOS ACOSD ASIN ASIND ATAN ATAND ATAN2 ATAN2D COS COSD COT COTD SIN SIND TAN TAND BIT_LENGTH CHAR_LENGTH CHARACTER_LENGTH LOWER OCTET_LENGTH OVERLAY POSITION SUBSTRING TREAT TRIM UPPER ASCII BTRIM CHR CONCAT CONCAT_WS CONVERT CONVERT_FROM CONVERT_TO DECODE ENCODE INITCAP LEFT LENGTH LPAD LTRIM MD5 PARSE_IDENT PG_CLIENT_ENCODING QUOTE_IDENT|10 QUOTE_LITERAL|10 QUOTE_NULLABLE|10 REGEXP_MATCH REGEXP_MATCHES REGEXP_REPLACE REGEXP_SPLIT_TO_ARRAY REGEXP_SPLIT_TO_TABLE REPEAT REPLACE REVERSE RIGHT RPAD RTRIM SPLIT_PART STRPOS SUBSTR TO_ASCII TO_HEX TRANSLATE OCTET_LENGTH GET_BIT GET_BYTE SET_BIT SET_BYTE TO_CHAR TO_DATE TO_NUMBER TO_TIMESTAMP AGE CLOCK_TIMESTAMP|10 DATE_PART DATE_TRUNC ISFINITE JUSTIFY_DAYS JUSTIFY_HOURS JUSTIFY_INTERVAL MAKE_DATE MAKE_INTERVAL|10 MAKE_TIME MAKE_TIMESTAMP|10 MAKE_TIMESTAMPTZ|10 NOW STATEMENT_TIMESTAMP|10 TIMEOFDAY TRANSACTION_TIMESTAMP|10 ENUM_FIRST ENUM_LAST ENUM_RANGE AREA CENTER DIAMETER HEIGHT ISCLOSED ISOPEN NPOINTS PCLOSE POPEN RADIUS WIDTH BOX BOUND_BOX CIRCLE LINE LSEG PATH POLYGON ABBREV BROADCAST HOST HOSTMASK MASKLEN NETMASK NETWORK SET_MASKLEN TEXT INET_SAME_FAMILY INET_MERGE MACADDR8_SET7BIT ARRAY_TO_TSVECTOR GET_CURRENT_TS_CONFIG NUMNODE PLAINTO_TSQUERY PHRASETO_TSQUERY WEBSEARCH_TO_TSQUERY QUERYTREE SETWEIGHT STRIP TO_TSQUERY TO_TSVECTOR JSON_TO_TSVECTOR JSONB_TO_TSVECTOR TS_DELETE TS_FILTER TS_HEADLINE TS_RANK TS_RANK_CD TS_REWRITE TSQUERY_PHRASE TSVECTOR_TO_ARRAY TSVECTOR_UPDATE_TRIGGER TSVECTOR_UPDATE_TRIGGER_COLUMN XMLCOMMENT XMLCONCAT XMLELEMENT XMLFOREST XMLPI XMLROOT XMLEXISTS XML_IS_WELL_FORMED XML_IS_WELL_FORMED_DOCUMENT XML_IS_WELL_FORMED_CONTENT XPATH XPATH_EXISTS XMLTABLE XMLNAMESPACES TABLE_TO_XML TABLE_TO_XMLSCHEMA TABLE_TO_XML_AND_XMLSCHEMA QUERY_TO_XML QUERY_TO_XMLSCHEMA QUERY_TO_XML_AND_XMLSCHEMA CURSOR_TO_XML CURSOR_TO_XMLSCHEMA SCHEMA_TO_XML SCHEMA_TO_XMLSCHEMA SCHEMA_TO_XML_AND_XMLSCHEMA DATABASE_TO_XML DATABASE_TO_XMLSCHEMA DATABASE_TO_XML_AND_XMLSCHEMA XMLATTRIBUTES TO_JSON TO_JSONB ARRAY_TO_JSON ROW_TO_JSON JSON_BUILD_ARRAY JSONB_BUILD_ARRAY JSON_BUILD_OBJECT JSONB_BUILD_OBJECT JSON_OBJECT JSONB_OBJECT JSON_ARRAY_LENGTH JSONB_ARRAY_LENGTH JSON_EACH JSONB_EACH JSON_EACH_TEXT JSONB_EACH_TEXT JSON_EXTRACT_PATH JSONB_EXTRACT_PATH JSON_OBJECT_KEYS JSONB_OBJECT_KEYS JSON_POPULATE_RECORD JSONB_POPULATE_RECORD JSON_POPULATE_RECORDSET JSONB_POPULATE_RECORDSET JSON_ARRAY_ELEMENTS JSONB_ARRAY_ELEMENTS JSON_ARRAY_ELEMENTS_TEXT JSONB_ARRAY_ELEMENTS_TEXT JSON_TYPEOF JSONB_TYPEOF JSON_TO_RECORD JSONB_TO_RECORD JSON_TO_RECORDSET JSONB_TO_RECORDSET JSON_STRIP_NULLS JSONB_STRIP_NULLS JSONB_SET JSONB_INSERT JSONB_PRETTY CURRVAL LASTVAL NEXTVAL SETVAL COALESCE NULLIF GREATEST LEAST ARRAY_APPEND ARRAY_CAT ARRAY_NDIMS ARRAY_DIMS ARRAY_FILL ARRAY_LENGTH ARRAY_LOWER ARRAY_POSITION ARRAY_POSITIONS ARRAY_PREPEND ARRAY_REMOVE ARRAY_REPLACE ARRAY_TO_STRING ARRAY_UPPER CARDINALITY STRING_TO_ARRAY UNNEST ISEMPTY LOWER_INC UPPER_INC LOWER_INF UPPER_INF RANGE_MERGE GENERATE_SERIES GENERATE_SUBSCRIPTS CURRENT_DATABASE CURRENT_QUERY CURRENT_SCHEMA|10 CURRENT_SCHEMAS|10 INET_CLIENT_ADDR INET_CLIENT_PORT INET_SERVER_ADDR INET_SERVER_PORT ROW_SECURITY_ACTIVE FORMAT_TYPE TO_REGCLASS TO_REGPROC TO_REGPROCEDURE TO_REGOPER TO_REGOPERATOR TO_REGTYPE TO_REGNAMESPACE TO_REGROLE COL_DESCRIPTION OBJ_DESCRIPTION SHOBJ_DESCRIPTION TXID_CURRENT TXID_CURRENT_IF_ASSIGNED TXID_CURRENT_SNAPSHOT TXID_SNAPSHOT_XIP TXID_SNAPSHOT_XMAX TXID_SNAPSHOT_XMIN TXID_VISIBLE_IN_SNAPSHOT TXID_STATUS CURRENT_SETTING SET_CONFIG BRIN_SUMMARIZE_NEW_VALUES BRIN_SUMMARIZE_RANGE BRIN_DESUMMARIZE_RANGE GIN_CLEAN_PENDING_LIST SUPPRESS_REDUNDANT_UPDATES_TRIGGER LO_FROM_BYTEA LO_PUT LO_GET LO_CREAT LO_CREATE LO_UNLINK LO_IMPORT LO_EXPORT LOREAD LOWRITE GROUPING CAST".split(" ").map((E=>E.split("|")[0])).join("|")
-;return{name:"PostgreSQL",aliases:["postgres","postgresql"],case_insensitive:!0,
-keywords:{
-keyword:"ABORT ALTER ANALYZE BEGIN CALL CHECKPOINT|10 CLOSE CLUSTER COMMENT COMMIT COPY CREATE DEALLOCATE DECLARE DELETE DISCARD DO DROP END EXECUTE EXPLAIN FETCH GRANT IMPORT INSERT LISTEN LOAD LOCK MOVE NOTIFY PREPARE REASSIGN|10 REFRESH REINDEX RELEASE RESET REVOKE ROLLBACK SAVEPOINT SECURITY SELECT SET SHOW START TRUNCATE UNLISTEN|10 UPDATE VACUUM|10 VALUES AGGREGATE COLLATION CONVERSION|10 DATABASE DEFAULT PRIVILEGES DOMAIN TRIGGER EXTENSION FOREIGN WRAPPER|10 TABLE FUNCTION GROUP LANGUAGE LARGE OBJECT MATERIALIZED VIEW OPERATOR CLASS FAMILY POLICY PUBLICATION|10 ROLE RULE SCHEMA SEQUENCE SERVER STATISTICS SUBSCRIPTION SYSTEM TABLESPACE CONFIGURATION DICTIONARY PARSER TEMPLATE TYPE USER MAPPING PREPARED ACCESS METHOD CAST AS TRANSFORM TRANSACTION OWNED TO INTO SESSION AUTHORIZATION INDEX PROCEDURE ASSERTION ALL ANALYSE AND ANY ARRAY ASC ASYMMETRIC|10 BOTH CASE CHECK COLLATE COLUMN CONCURRENTLY|10 CONSTRAINT CROSS DEFERRABLE RANGE DESC DISTINCT ELSE EXCEPT FOR FREEZE|10 FROM FULL HAVING ILIKE IN INITIALLY INNER INTERSECT IS ISNULL JOIN LATERAL LEADING LIKE LIMIT NATURAL NOT NOTNULL NULL OFFSET ON ONLY OR ORDER OUTER OVERLAPS PLACING PRIMARY REFERENCES RETURNING SIMILAR SOME SYMMETRIC TABLESAMPLE THEN TRAILING UNION UNIQUE USING VARIADIC|10 VERBOSE WHEN WHERE WINDOW WITH BY RETURNS INOUT OUT SETOF|10 IF STRICT CURRENT CONTINUE OWNER LOCATION OVER PARTITION WITHIN BETWEEN ESCAPE EXTERNAL INVOKER DEFINER WORK RENAME VERSION CONNECTION CONNECT TABLES TEMP TEMPORARY FUNCTIONS SEQUENCES TYPES SCHEMAS OPTION CASCADE RESTRICT ADD ADMIN EXISTS VALID VALIDATE ENABLE DISABLE REPLICA|10 ALWAYS PASSING COLUMNS PATH REF VALUE OVERRIDING IMMUTABLE STABLE VOLATILE BEFORE AFTER EACH ROW PROCEDURAL ROUTINE NO HANDLER VALIDATOR OPTIONS STORAGE OIDS|10 WITHOUT INHERIT DEPENDS CALLED INPUT LEAKPROOF|10 COST ROWS NOWAIT SEARCH UNTIL ENCRYPTED|10 PASSWORD CONFLICT|10 INSTEAD INHERITS CHARACTERISTICS WRITE CURSOR ALSO STATEMENT SHARE EXCLUSIVE INLINE ISOLATION REPEATABLE READ COMMITTED SERIALIZABLE UNCOMMITTED LOCAL GLOBAL SQL PROCEDURES RECURSIVE SNAPSHOT ROLLUP CUBE TRUSTED|10 INCLUDE FOLLOWING PRECEDING UNBOUNDED RANGE GROUPS UNENCRYPTED|10 SYSID FORMAT DELIMITER HEADER QUOTE ENCODING FILTER OFF FORCE_QUOTE FORCE_NOT_NULL FORCE_NULL COSTS BUFFERS TIMING SUMMARY DISABLE_PAGE_SKIPPING RESTART CYCLE GENERATED IDENTITY DEFERRED IMMEDIATE LEVEL LOGGED UNLOGGED OF NOTHING NONE EXCLUDE ATTRIBUTE USAGE ROUTINES TRUE FALSE NAN INFINITY ALIAS BEGIN CONSTANT DECLARE END EXCEPTION RETURN PERFORM|10 RAISE GET DIAGNOSTICS STACKED|10 FOREACH LOOP ELSIF EXIT WHILE REVERSE SLICE DEBUG LOG INFO NOTICE WARNING ASSERT OPEN SUPERUSER NOSUPERUSER CREATEDB NOCREATEDB CREATEROLE NOCREATEROLE INHERIT NOINHERIT LOGIN NOLOGIN REPLICATION NOREPLICATION BYPASSRLS NOBYPASSRLS ",
-built_in:"CURRENT_TIME CURRENT_TIMESTAMP CURRENT_USER CURRENT_CATALOG|10 CURRENT_DATE LOCALTIME LOCALTIMESTAMP CURRENT_ROLE|10 CURRENT_SCHEMA|10 SESSION_USER PUBLIC FOUND NEW OLD TG_NAME|10 TG_WHEN|10 TG_LEVEL|10 TG_OP|10 TG_RELID|10 TG_RELNAME|10 TG_TABLE_NAME|10 TG_TABLE_SCHEMA|10 TG_NARGS|10 TG_ARGV|10 TG_EVENT|10 TG_TAG|10 ROW_COUNT RESULT_OID|10 PG_CONTEXT|10 RETURNED_SQLSTATE COLUMN_NAME CONSTRAINT_NAME PG_DATATYPE_NAME|10 MESSAGE_TEXT TABLE_NAME SCHEMA_NAME PG_EXCEPTION_DETAIL|10 PG_EXCEPTION_HINT|10 PG_EXCEPTION_CONTEXT|10 SQLSTATE SQLERRM|10 SUCCESSFUL_COMPLETION WARNING DYNAMIC_RESULT_SETS_RETURNED IMPLICIT_ZERO_BIT_PADDING NULL_VALUE_ELIMINATED_IN_SET_FUNCTION PRIVILEGE_NOT_GRANTED PRIVILEGE_NOT_REVOKED STRING_DATA_RIGHT_TRUNCATION DEPRECATED_FEATURE NO_DATA NO_ADDITIONAL_DYNAMIC_RESULT_SETS_RETURNED SQL_STATEMENT_NOT_YET_COMPLETE CONNECTION_EXCEPTION CONNECTION_DOES_NOT_EXIST CONNECTION_FAILURE SQLCLIENT_UNABLE_TO_ESTABLISH_SQLCONNECTION SQLSERVER_REJECTED_ESTABLISHMENT_OF_SQLCONNECTION TRANSACTION_RESOLUTION_UNKNOWN PROTOCOL_VIOLATION TRIGGERED_ACTION_EXCEPTION FEATURE_NOT_SUPPORTED INVALID_TRANSACTION_INITIATION LOCATOR_EXCEPTION INVALID_LOCATOR_SPECIFICATION INVALID_GRANTOR INVALID_GRANT_OPERATION INVALID_ROLE_SPECIFICATION DIAGNOSTICS_EXCEPTION STACKED_DIAGNOSTICS_ACCESSED_WITHOUT_ACTIVE_HANDLER CASE_NOT_FOUND CARDINALITY_VIOLATION DATA_EXCEPTION ARRAY_SUBSCRIPT_ERROR CHARACTER_NOT_IN_REPERTOIRE DATETIME_FIELD_OVERFLOW DIVISION_BY_ZERO ERROR_IN_ASSIGNMENT ESCAPE_CHARACTER_CONFLICT INDICATOR_OVERFLOW INTERVAL_FIELD_OVERFLOW INVALID_ARGUMENT_FOR_LOGARITHM INVALID_ARGUMENT_FOR_NTILE_FUNCTION INVALID_ARGUMENT_FOR_NTH_VALUE_FUNCTION INVALID_ARGUMENT_FOR_POWER_FUNCTION INVALID_ARGUMENT_FOR_WIDTH_BUCKET_FUNCTION INVALID_CHARACTER_VALUE_FOR_CAST INVALID_DATETIME_FORMAT INVALID_ESCAPE_CHARACTER INVALID_ESCAPE_OCTET INVALID_ESCAPE_SEQUENCE NONSTANDARD_USE_OF_ESCAPE_CHARACTER INVALID_INDICATOR_PARAMETER_VALUE INVALID_PARAMETER_VALUE INVALID_REGULAR_EXPRESSION INVALID_ROW_COUNT_IN_LIMIT_CLAUSE INVALID_ROW_COUNT_IN_RESULT_OFFSET_CLAUSE INVALID_TABLESAMPLE_ARGUMENT INVALID_TABLESAMPLE_REPEAT INVALID_TIME_ZONE_DISPLACEMENT_VALUE INVALID_USE_OF_ESCAPE_CHARACTER MOST_SPECIFIC_TYPE_MISMATCH NULL_VALUE_NOT_ALLOWED NULL_VALUE_NO_INDICATOR_PARAMETER NUMERIC_VALUE_OUT_OF_RANGE SEQUENCE_GENERATOR_LIMIT_EXCEEDED STRING_DATA_LENGTH_MISMATCH STRING_DATA_RIGHT_TRUNCATION SUBSTRING_ERROR TRIM_ERROR UNTERMINATED_C_STRING ZERO_LENGTH_CHARACTER_STRING FLOATING_POINT_EXCEPTION INVALID_TEXT_REPRESENTATION INVALID_BINARY_REPRESENTATION BAD_COPY_FILE_FORMAT UNTRANSLATABLE_CHARACTER NOT_AN_XML_DOCUMENT INVALID_XML_DOCUMENT INVALID_XML_CONTENT INVALID_XML_COMMENT INVALID_XML_PROCESSING_INSTRUCTION INTEGRITY_CONSTRAINT_VIOLATION RESTRICT_VIOLATION NOT_NULL_VIOLATION FOREIGN_KEY_VIOLATION UNIQUE_VIOLATION CHECK_VIOLATION EXCLUSION_VIOLATION INVALID_CURSOR_STATE INVALID_TRANSACTION_STATE ACTIVE_SQL_TRANSACTION BRANCH_TRANSACTION_ALREADY_ACTIVE HELD_CURSOR_REQUIRES_SAME_ISOLATION_LEVEL INAPPROPRIATE_ACCESS_MODE_FOR_BRANCH_TRANSACTION INAPPROPRIATE_ISOLATION_LEVEL_FOR_BRANCH_TRANSACTION NO_ACTIVE_SQL_TRANSACTION_FOR_BRANCH_TRANSACTION READ_ONLY_SQL_TRANSACTION SCHEMA_AND_DATA_STATEMENT_MIXING_NOT_SUPPORTED NO_ACTIVE_SQL_TRANSACTION IN_FAILED_SQL_TRANSACTION IDLE_IN_TRANSACTION_SESSION_TIMEOUT INVALID_SQL_STATEMENT_NAME TRIGGERED_DATA_CHANGE_VIOLATION INVALID_AUTHORIZATION_SPECIFICATION INVALID_PASSWORD DEPENDENT_PRIVILEGE_DESCRIPTORS_STILL_EXIST DEPENDENT_OBJECTS_STILL_EXIST INVALID_TRANSACTION_TERMINATION SQL_ROUTINE_EXCEPTION FUNCTION_EXECUTED_NO_RETURN_STATEMENT MODIFYING_SQL_DATA_NOT_PERMITTED PROHIBITED_SQL_STATEMENT_ATTEMPTED READING_SQL_DATA_NOT_PERMITTED INVALID_CURSOR_NAME EXTERNAL_ROUTINE_EXCEPTION CONTAINING_SQL_NOT_PERMITTED MODIFYING_SQL_DATA_NOT_PERMITTED PROHIBITED_SQL_STATEMENT_ATTEMPTED READING_SQL_DATA_NOT_PERMITTED EXTERNAL_ROUTINE_INVOCATION_EXCEPTION INVALID_SQLSTATE_RETURNED NULL_VALUE_NOT_ALLOWED TRIGGER_PROTOCOL_VIOLATED SRF_PROTOCOL_VIOLATED EVENT_TRIGGER_PROTOCOL_VIOLATED SAVEPOINT_EXCEPTION INVALID_SAVEPOINT_SPECIFICATION INVALID_CATALOG_NAME INVALID_SCHEMA_NAME TRANSACTION_ROLLBACK TRANSACTION_INTEGRITY_CONSTRAINT_VIOLATION SERIALIZATION_FAILURE STATEMENT_COMPLETION_UNKNOWN DEADLOCK_DETECTED SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION SYNTAX_ERROR INSUFFICIENT_PRIVILEGE CANNOT_COERCE GROUPING_ERROR WINDOWING_ERROR INVALID_RECURSION INVALID_FOREIGN_KEY INVALID_NAME NAME_TOO_LONG RESERVED_NAME DATATYPE_MISMATCH INDETERMINATE_DATATYPE COLLATION_MISMATCH INDETERMINATE_COLLATION WRONG_OBJECT_TYPE GENERATED_ALWAYS UNDEFINED_COLUMN UNDEFINED_FUNCTION UNDEFINED_TABLE UNDEFINED_PARAMETER UNDEFINED_OBJECT DUPLICATE_COLUMN DUPLICATE_CURSOR DUPLICATE_DATABASE DUPLICATE_FUNCTION DUPLICATE_PREPARED_STATEMENT DUPLICATE_SCHEMA DUPLICATE_TABLE DUPLICATE_ALIAS DUPLICATE_OBJECT AMBIGUOUS_COLUMN AMBIGUOUS_FUNCTION AMBIGUOUS_PARAMETER AMBIGUOUS_ALIAS INVALID_COLUMN_REFERENCE INVALID_COLUMN_DEFINITION INVALID_CURSOR_DEFINITION INVALID_DATABASE_DEFINITION INVALID_FUNCTION_DEFINITION INVALID_PREPARED_STATEMENT_DEFINITION INVALID_SCHEMA_DEFINITION INVALID_TABLE_DEFINITION INVALID_OBJECT_DEFINITION WITH_CHECK_OPTION_VIOLATION INSUFFICIENT_RESOURCES DISK_FULL OUT_OF_MEMORY TOO_MANY_CONNECTIONS CONFIGURATION_LIMIT_EXCEEDED PROGRAM_LIMIT_EXCEEDED STATEMENT_TOO_COMPLEX TOO_MANY_COLUMNS TOO_MANY_ARGUMENTS OBJECT_NOT_IN_PREREQUISITE_STATE OBJECT_IN_USE CANT_CHANGE_RUNTIME_PARAM LOCK_NOT_AVAILABLE OPERATOR_INTERVENTION QUERY_CANCELED ADMIN_SHUTDOWN CRASH_SHUTDOWN CANNOT_CONNECT_NOW DATABASE_DROPPED SYSTEM_ERROR IO_ERROR UNDEFINED_FILE DUPLICATE_FILE SNAPSHOT_TOO_OLD CONFIG_FILE_ERROR LOCK_FILE_EXISTS FDW_ERROR FDW_COLUMN_NAME_NOT_FOUND FDW_DYNAMIC_PARAMETER_VALUE_NEEDED FDW_FUNCTION_SEQUENCE_ERROR FDW_INCONSISTENT_DESCRIPTOR_INFORMATION FDW_INVALID_ATTRIBUTE_VALUE FDW_INVALID_COLUMN_NAME FDW_INVALID_COLUMN_NUMBER FDW_INVALID_DATA_TYPE FDW_INVALID_DATA_TYPE_DESCRIPTORS FDW_INVALID_DESCRIPTOR_FIELD_IDENTIFIER FDW_INVALID_HANDLE FDW_INVALID_OPTION_INDEX FDW_INVALID_OPTION_NAME FDW_INVALID_STRING_LENGTH_OR_BUFFER_LENGTH FDW_INVALID_STRING_FORMAT FDW_INVALID_USE_OF_NULL_POINTER FDW_TOO_MANY_HANDLES FDW_OUT_OF_MEMORY FDW_NO_SCHEMAS FDW_OPTION_NAME_NOT_FOUND FDW_REPLY_HANDLE FDW_SCHEMA_NOT_FOUND FDW_TABLE_NOT_FOUND FDW_UNABLE_TO_CREATE_EXECUTION FDW_UNABLE_TO_CREATE_REPLY FDW_UNABLE_TO_ESTABLISH_CONNECTION PLPGSQL_ERROR RAISE_EXCEPTION NO_DATA_FOUND TOO_MANY_ROWS ASSERT_FAILURE INTERNAL_ERROR DATA_CORRUPTED INDEX_CORRUPTED "
-},illegal:/:==|\W\s*\(\*|(^|\s)\$[a-z]|\{\{|[a-z]:\s*$|\.\.\.|TO:|DO:/,
-contains:[{className:"keyword",variants:[{begin:/\bTEXT\s*SEARCH\b/},{
-begin:/\b(PRIMARY|FOREIGN|FOR(\s+NO)?)\s+KEY\b/},{
-begin:/\bPARALLEL\s+(UNSAFE|RESTRICTED|SAFE)\b/},{
-begin:/\bSTORAGE\s+(PLAIN|EXTERNAL|EXTENDED|MAIN)\b/},{
-begin:/\bMATCH\s+(FULL|PARTIAL|SIMPLE)\b/},{begin:/\bNULLS\s+(FIRST|LAST)\b/},{
-begin:/\bEVENT\s+TRIGGER\b/},{begin:/\b(MAPPING|OR)\s+REPLACE\b/},{
-begin:/\b(FROM|TO)\s+(PROGRAM|STDIN|STDOUT)\b/},{
-begin:/\b(SHARE|EXCLUSIVE)\s+MODE\b/},{
-begin:/\b(LEFT|RIGHT)\s+(OUTER\s+)?JOIN\b/},{
-begin:/\b(FETCH|MOVE)\s+(NEXT|PRIOR|FIRST|LAST|ABSOLUTE|RELATIVE|FORWARD|BACKWARD)\b/
-},{begin:/\bPRESERVE\s+ROWS\b/},{begin:/\bDISCARD\s+PLANS\b/},{
-begin:/\bREFERENCING\s+(OLD|NEW)\b/},{begin:/\bSKIP\s+LOCKED\b/},{
-begin:/\bGROUPING\s+SETS\b/},{
-begin:/\b(BINARY|INSENSITIVE|SCROLL|NO\s+SCROLL)\s+(CURSOR|FOR)\b/},{
-begin:/\b(WITH|WITHOUT)\s+HOLD\b/},{
-begin:/\bWITH\s+(CASCADED|LOCAL)\s+CHECK\s+OPTION\b/},{
-begin:/\bEXCLUDE\s+(TIES|NO\s+OTHERS)\b/},{
-begin:/\bFORMAT\s+(TEXT|XML|JSON|YAML)\b/},{
-begin:/\bSET\s+((SESSION|LOCAL)\s+)?NAMES\b/},{begin:/\bIS\s+(NOT\s+)?UNKNOWN\b/
-},{begin:/\bSECURITY\s+LABEL\b/},{begin:/\bSTANDALONE\s+(YES|NO|NO\s+VALUE)\b/
-},{begin:/\bWITH\s+(NO\s+)?DATA\b/},{begin:/\b(FOREIGN|SET)\s+DATA\b/},{
-begin:/\bSET\s+(CATALOG|CONSTRAINTS)\b/},{begin:/\b(WITH|FOR)\s+ORDINALITY\b/},{
-begin:/\bIS\s+(NOT\s+)?DOCUMENT\b/},{
-begin:/\bXML\s+OPTION\s+(DOCUMENT|CONTENT)\b/},{
-begin:/\b(STRIP|PRESERVE)\s+WHITESPACE\b/},{
-begin:/\bNO\s+(ACTION|MAXVALUE|MINVALUE)\b/},{
-begin:/\bPARTITION\s+BY\s+(RANGE|LIST|HASH)\b/},{begin:/\bAT\s+TIME\s+ZONE\b/},{
-begin:/\bGRANTED\s+BY\b/},{begin:/\bRETURN\s+(QUERY|NEXT)\b/},{
-begin:/\b(ATTACH|DETACH)\s+PARTITION\b/},{
-begin:/\bFORCE\s+ROW\s+LEVEL\s+SECURITY\b/},{
-begin:/\b(INCLUDING|EXCLUDING)\s+(COMMENTS|CONSTRAINTS|DEFAULTS|IDENTITY|INDEXES|STATISTICS|STORAGE|ALL)\b/
-},{begin:/\bAS\s+(ASSIGNMENT|IMPLICIT|PERMISSIVE|RESTRICTIVE|ENUM|RANGE)\b/}]},{
-begin:/\b(FORMAT|FAMILY|VERSION)\s*\(/},{begin:/\bINCLUDE\s*\(/,
-keywords:"INCLUDE"},{begin:/\bRANGE(?!\s*(BETWEEN|UNBOUNDED|CURRENT|[-0-9]+))/
-},{
-begin:/\b(VERSION|OWNER|TEMPLATE|TABLESPACE|CONNECTION\s+LIMIT|PROCEDURE|RESTRICT|JOIN|PARSER|COPY|START|END|COLLATION|INPUT|ANALYZE|STORAGE|LIKE|DEFAULT|DELIMITER|ENCODING|COLUMN|CONSTRAINT|TABLE|SCHEMA)\s*=/
-},{begin:/\b(PG_\w+?|HAS_[A-Z_]+_PRIVILEGE)\b/,relevance:10},{
-begin:/\bEXTRACT\s*\(/,end:/\bFROM\b/,returnEnd:!0,keywords:{
-type:"CENTURY DAY DECADE DOW DOY EPOCH HOUR ISODOW ISOYEAR MICROSECONDS MILLENNIUM MILLISECONDS MINUTE MONTH QUARTER SECOND TIMEZONE TIMEZONE_HOUR TIMEZONE_MINUTE WEEK YEAR"
-}},{begin:/\b(XMLELEMENT|XMLPI)\s*\(\s*NAME/,keywords:{keyword:"NAME"}},{
-begin:/\b(XMLPARSE|XMLSERIALIZE)\s*\(\s*(DOCUMENT|CONTENT)/,keywords:{
-keyword:"DOCUMENT CONTENT"}},{beginKeywords:"CACHE INCREMENT MAXVALUE MINVALUE",
-end:E.C_NUMBER_RE,returnEnd:!0,keywords:"BY CACHE INCREMENT MAXVALUE MINVALUE"
-},{className:"type",begin:/\b(WITH|WITHOUT)\s+TIME\s+ZONE\b/},{className:"type",
-begin:/\bINTERVAL\s+(YEAR|MONTH|DAY|HOUR|MINUTE|SECOND)(\s+TO\s+(MONTH|HOUR|MINUTE|SECOND))?\b/
-},{
-begin:/\bRETURNS\s+(LANGUAGE_HANDLER|TRIGGER|EVENT_TRIGGER|FDW_HANDLER|INDEX_AM_HANDLER|TSM_HANDLER)\b/,
-keywords:{keyword:"RETURNS",
-type:"LANGUAGE_HANDLER TRIGGER EVENT_TRIGGER FDW_HANDLER INDEX_AM_HANDLER TSM_HANDLER"
-}},{begin:"\\b("+I+")\\s*\\("},{begin:"\\.("+R+")\\b"},{
-begin:"\\b("+R+")\\s+PATH\\b",keywords:{keyword:"PATH",
-type:A.replace("PATH ","")}},{className:"type",begin:"\\b("+R+")\\b"},{
-className:"string",begin:"'",end:"'",contains:[{begin:"''"}]},{
-className:"string",begin:"(e|E|u&|U&)'",end:"'",contains:[{begin:"\\\\."}],
-relevance:10},E.END_SAME_AS_BEGIN({begin:N,end:N,contains:[{
-subLanguage:["pgsql","perl","python","tcl","r","lua","java","php","ruby","bash","scheme","xml","json"],
-endsWithParent:!0}]}),{begin:'"',end:'"',contains:[{begin:'""'}]
-},E.C_NUMBER_MODE,E.C_BLOCK_COMMENT_MODE,T,{className:"meta",variants:[{
-begin:"%(ROW)?TYPE",relevance:10},{begin:"\\$\\d+"},{begin:"^#\\w",end:"$"}]},{
-className:"symbol",begin:"<<\\s*[a-zA-Z_][a-zA-Z_0-9$]*\\s*>>",relevance:10}]}}
-})());
-hljs.registerLanguage("php",(()=>{"use strict";return e=>{const r={
-className:"variable",
-begin:"\\$+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*(?![A-Za-z0-9])(?![$])"},t={
-className:"meta",variants:[{begin:/<\?php/,relevance:10},{begin:/<\?[=]?/},{
-begin:/\?>/}]},a={className:"subst",variants:[{begin:/\$\w+/},{begin:/\{\$/,
-end:/\}/}]},n=e.inherit(e.APOS_STRING_MODE,{illegal:null
-}),i=e.inherit(e.QUOTE_STRING_MODE,{illegal:null,
-contains:e.QUOTE_STRING_MODE.contains.concat(a)}),o=e.END_SAME_AS_BEGIN({
-begin:/<<<[ \t]*(\w+)\n/,end:/[ \t]*(\w+)\b/,
-contains:e.QUOTE_STRING_MODE.contains.concat(a)}),l={className:"string",
-contains:[e.BACKSLASH_ESCAPE,t],variants:[e.inherit(n,{begin:"b'",end:"'"
-}),e.inherit(i,{begin:'b"',end:'"'}),i,n,o]},s={className:"number",variants:[{
-begin:"\\b0b[01]+(?:_[01]+)*\\b"},{begin:"\\b0o[0-7]+(?:_[0-7]+)*\\b"},{
-begin:"\\b0x[\\da-f]+(?:_[\\da-f]+)*\\b"},{
-begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:e[+-]?\\d+)?"
-}],relevance:0},c={
-keyword:"__CLASS__ __DIR__ __FILE__ __FUNCTION__ __LINE__ __METHOD__ __NAMESPACE__ __TRAIT__ die echo exit include include_once print require require_once array abstract and as binary bool boolean break callable case catch class clone const continue declare default do double else elseif empty enddeclare endfor endforeach endif endswitch endwhile enum eval extends final finally float for foreach from global goto if implements instanceof insteadof int integer interface isset iterable list match|0 mixed new object or private protected public real return string switch throw trait try unset use var void while xor yield",
-literal:"false null true",
-built_in:"Error|0 AppendIterator ArgumentCountError ArithmeticError ArrayIterator ArrayObject AssertionError BadFunctionCallException BadMethodCallException CachingIterator CallbackFilterIterator CompileError Countable DirectoryIterator DivisionByZeroError DomainException EmptyIterator ErrorException Exception FilesystemIterator FilterIterator GlobIterator InfiniteIterator InvalidArgumentException IteratorIterator LengthException LimitIterator LogicException MultipleIterator NoRewindIterator OutOfBoundsException OutOfRangeException OuterIterator OverflowException ParentIterator ParseError RangeException RecursiveArrayIterator RecursiveCachingIterator RecursiveCallbackFilterIterator RecursiveDirectoryIterator RecursiveFilterIterator RecursiveIterator RecursiveIteratorIterator RecursiveRegexIterator RecursiveTreeIterator RegexIterator RuntimeException SeekableIterator SplDoublyLinkedList SplFileInfo SplFileObject SplFixedArray SplHeap SplMaxHeap SplMinHeap SplObjectStorage SplObserver SplObserver SplPriorityQueue SplQueue SplStack SplSubject SplSubject SplTempFileObject TypeError UnderflowException UnexpectedValueException UnhandledMatchError ArrayAccess Closure Generator Iterator IteratorAggregate Serializable Stringable Throwable Traversable WeakReference WeakMap Directory __PHP_Incomplete_Class parent php_user_filter self static stdClass"
-};return{aliases:["php3","php4","php5","php6","php7","php8"],
-case_insensitive:!0,keywords:c,
-contains:[e.HASH_COMMENT_MODE,e.COMMENT("//","$",{contains:[t]
-}),e.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}]
-}),e.COMMENT("__halt_compiler.+?;",!1,{endsWithParent:!0,
-keywords:"__halt_compiler"}),t,{className:"keyword",begin:/\$this\b/},r,{
-begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{className:"function",
-relevance:0,beginKeywords:"fn function",end:/[;{]/,excludeEnd:!0,
-illegal:"[$%\\[]",contains:[{beginKeywords:"use"},e.UNDERSCORE_TITLE_MODE,{
-begin:"=>",endsParent:!0},{className:"params",begin:"\\(",end:"\\)",
-excludeBegin:!0,excludeEnd:!0,keywords:c,
-contains:["self",r,e.C_BLOCK_COMMENT_MODE,l,s]}]},{className:"class",variants:[{
-beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait",
-illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{
-beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{
-beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/,
-contains:[e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"use",relevance:0,end:";",
-contains:[e.UNDERSCORE_TITLE_MODE]},l,s]}}})());
-hljs.registerLanguage("php-template",(()=>{"use strict";return n=>({
-name:"PHP template",subLanguage:"xml",contains:[{begin:/<\?(php|=)?/,end:/\?>/,
-subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0},{begin:'b"',
-end:'"',skip:!0},{begin:"b'",end:"'",skip:!0},n.inherit(n.APOS_STRING_MODE,{
-illegal:null,className:null,contains:null,skip:!0
-}),n.inherit(n.QUOTE_STRING_MODE,{illegal:null,className:null,contains:null,
-skip:!0})]}]})})());
-hljs.registerLanguage("plaintext",(()=>{"use strict";return t=>({
-name:"Plain text",aliases:["text","txt"],disableAutodetect:!0})})());
-hljs.registerLanguage("pony",(()=>{"use strict";return e=>({name:"Pony",
-keywords:{
-keyword:"actor addressof and as be break class compile_error compile_intrinsic consume continue delegate digestof do else elseif embed end error for fun if ifdef in interface is isnt lambda let match new not object or primitive recover repeat return struct then trait try type until use var where while with xor",
-meta:"iso val tag trn box ref",literal:"this false true"},contains:[{
-className:"type",begin:"\\b_?[A-Z][\\w]*",relevance:0},{className:"string",
-begin:'"""',end:'"""',relevance:10},{className:"string",begin:'"',end:'"',
-contains:[e.BACKSLASH_ESCAPE]},{className:"string",begin:"'",end:"'",
-contains:[e.BACKSLASH_ESCAPE],relevance:0},{begin:e.IDENT_RE+"'",relevance:0},{
-className:"number",
-begin:"(-?)(\\b0[xX][a-fA-F0-9]+|\\b0[bB][01]+|(\\b\\d+(_\\d+)?(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",
-relevance:0},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]})})());
-hljs.registerLanguage("powershell",(()=>{"use strict";return e=>{const n={
-$pattern:/-?[A-z\.\-]+\b/,
-keyword:"if else foreach return do while until elseif begin for trap data dynamicparam end break throw param continue finally in switch exit filter try process catch hidden static parameter",
-built_in:"ac asnp cat cd CFS chdir clc clear clhy cli clp cls clv cnsn compare copy cp cpi cpp curl cvpa dbp del diff dir dnsn ebp echo|0 epal epcsv epsn erase etsn exsn fc fhx fl ft fw gal gbp gc gcb gci gcm gcs gdr gerr ghy gi gin gjb gl gm gmo gp gps gpv group gsn gsnp gsv gtz gu gv gwmi h history icm iex ihy ii ipal ipcsv ipmo ipsn irm ise iwmi iwr kill lp ls man md measure mi mount move mp mv nal ndr ni nmo npssc nsn nv ogv oh popd ps pushd pwd r rbp rcjb rcsn rd rdr ren ri rjb rm rmdir rmo rni rnp rp rsn rsnp rujb rv rvpa rwmi sajb sal saps sasv sbp sc scb select set shcm si sl sleep sls sort sp spjb spps spsv start stz sujb sv swmi tee trcm type wget where wjb write"
-},s={begin:"`[\\s\\S]",relevance:0},i={className:"variable",variants:[{
-begin:/\$\B/},{className:"keyword",begin:/\$this/},{begin:/\$[\w\d][\w\d_:]*/}]
-},a={className:"string",variants:[{begin:/"/,end:/"/},{begin:/@"/,end:/^"@/}],
-contains:[s,i,{className:"variable",begin:/\$[A-z]/,end:/[^A-z]/}]},t={
-className:"string",variants:[{begin:/'/,end:/'/},{begin:/@'/,end:/^'@/}]
-},r=e.inherit(e.COMMENT(null,null),{variants:[{begin:/#/,end:/$/},{begin:/<#/,
-end:/#>/}],contains:[{className:"doctag",variants:[{
-begin:/\.(synopsis|description|example|inputs|outputs|notes|link|component|role|functionality)/
-},{
-begin:/\.(parameter|forwardhelptargetname|forwardhelpcategory|remotehelprunspace|externalhelp)\s+\S+/
-}]}]}),c={className:"class",beginKeywords:"class enum",end:/\s*[{]/,
-excludeEnd:!0,relevance:0,contains:[e.TITLE_MODE]},l={className:"function",
-begin:/function\s+/,end:/\s*\{|$/,excludeEnd:!0,returnBegin:!0,relevance:0,
-contains:[{begin:"function",relevance:0,className:"keyword"},{className:"title",
-begin:/\w[\w\d]*((-)[\w\d]+)*/,relevance:0},{begin:/\(/,end:/\)/,
-className:"params",relevance:0,contains:[i]}]},o={begin:/using\s/,end:/$/,
-returnBegin:!0,contains:[a,t,{className:"keyword",
-begin:/(using|assembly|command|module|namespace|type)/}]},p={
-className:"function",begin:/\[.*\]\s*[\w]+[ ]??\(/,end:/$/,returnBegin:!0,
-relevance:0,contains:[{className:"keyword",
-begin:"(".concat(n.keyword.toString().replace(/\s/g,"|"),")\\b"),endsParent:!0,
-relevance:0},e.inherit(e.TITLE_MODE,{endsParent:!0})]
-},g=[p,r,s,e.NUMBER_MODE,a,t,{className:"built_in",variants:[{
-begin:"(Add|Clear|Close|Copy|Enter|Exit|Find|Format|Get|Hide|Join|Lock|Move|New|Open|Optimize|Pop|Push|Redo|Remove|Rename|Reset|Resize|Search|Select|Set|Show|Skip|Split|Step|Switch|Undo|Unlock|Watch|Backup|Checkpoint|Compare|Compress|Convert|ConvertFrom|ConvertTo|Dismount|Edit|Expand|Export|Group|Import|Initialize|Limit|Merge|Mount|Out|Publish|Restore|Save|Sync|Unpublish|Update|Approve|Assert|Build|Complete|Confirm|Deny|Deploy|Disable|Enable|Install|Invoke|Register|Request|Restart|Resume|Start|Stop|Submit|Suspend|Uninstall|Unregister|Wait|Debug|Measure|Ping|Repair|Resolve|Test|Trace|Connect|Disconnect|Read|Receive|Send|Write|Block|Grant|Protect|Revoke|Unblock|Unprotect|Use|ForEach|Sort|Tee|Where)+(-)[\\w\\d]+"
-}]},i,{className:"literal",begin:/\$(null|true|false)\b/},{
-className:"selector-tag",begin:/@\B/,relevance:0}],m={begin:/\[/,end:/\]/,
-excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[].concat("self",g,{
-begin:"(string|char|byte|int|long|bool|decimal|single|double|DateTime|xml|array|hashtable|void)",
-className:"built_in",relevance:0},{className:"type",begin:/[\.\w\d]+/,
-relevance:0})};return p.contains.unshift(m),{name:"PowerShell",
-aliases:["ps","ps1"],case_insensitive:!0,keywords:n,contains:g.concat(c,l,o,{
-variants:[{className:"operator",
-begin:"(-and|-as|-band|-bnot|-bor|-bxor|-casesensitive|-ccontains|-ceq|-cge|-cgt|-cle|-clike|-clt|-cmatch|-cne|-cnotcontains|-cnotlike|-cnotmatch|-contains|-creplace|-csplit|-eq|-exact|-f|-file|-ge|-gt|-icontains|-ieq|-ige|-igt|-ile|-ilike|-ilt|-imatch|-in|-ine|-inotcontains|-inotlike|-inotmatch|-ireplace|-is|-isnot|-isplit|-join|-le|-like|-lt|-match|-ne|-not|-notcontains|-notin|-notlike|-notmatch|-or|-regex|-replace|-shl|-shr|-split|-wildcard|-xor)\\b"
-},{className:"literal",begin:/(-)[\w\d]+/,relevance:0}]},m)}}})());
-hljs.registerLanguage("processing",(()=>{"use strict";return e=>({
-name:"Processing",keywords:{
-keyword:"BufferedReader PVector PFont PImage PGraphics HashMap boolean byte char color double float int long String Array FloatDict FloatList IntDict IntList JSONArray JSONObject Object StringDict StringList Table TableRow XML false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private",
-literal:"P2D P3D HALF_PI PI QUARTER_PI TAU TWO_PI",title:"setup draw",
-built_in:"displayHeight displayWidth mouseY mouseX mousePressed pmouseX pmouseY key keyCode pixels focused frameCount frameRate height width size createGraphics beginDraw createShape loadShape PShape arc ellipse line point quad rect triangle bezier bezierDetail bezierPoint bezierTangent curve curveDetail curvePoint curveTangent curveTightness shape shapeMode beginContour beginShape bezierVertex curveVertex endContour endShape quadraticVertex vertex ellipseMode noSmooth rectMode smooth strokeCap strokeJoin strokeWeight mouseClicked mouseDragged mouseMoved mousePressed mouseReleased mouseWheel keyPressed keyPressedkeyReleased keyTyped print println save saveFrame day hour millis minute month second year background clear colorMode fill noFill noStroke stroke alpha blue brightness color green hue lerpColor red saturation modelX modelY modelZ screenX screenY screenZ ambient emissive shininess specular add createImage beginCamera camera endCamera frustum ortho perspective printCamera printProjection cursor frameRate noCursor exit loop noLoop popStyle pushStyle redraw binary boolean byte char float hex int str unbinary unhex join match matchAll nf nfc nfp nfs split splitTokens trim append arrayCopy concat expand reverse shorten sort splice subset box sphere sphereDetail createInput createReader loadBytes loadJSONArray loadJSONObject loadStrings loadTable loadXML open parseXML saveTable selectFolder selectInput beginRaw beginRecord createOutput createWriter endRaw endRecord PrintWritersaveBytes saveJSONArray saveJSONObject saveStream saveStrings saveXML selectOutput popMatrix printMatrix pushMatrix resetMatrix rotate rotateX rotateY rotateZ scale shearX shearY translate ambientLight directionalLight lightFalloff lights lightSpecular noLights normal pointLight spotLight image imageMode loadImage noTint requestImage tint texture textureMode textureWrap blend copy filter get loadPixels set updatePixels blendMode loadShader PShaderresetShader shader createFont loadFont text textFont textAlign textLeading textMode textSize textWidth textAscent textDescent abs ceil constrain dist exp floor lerp log mag map max min norm pow round sq sqrt acos asin atan atan2 cos degrees radians sin tan noise noiseDetail noiseSeed random randomGaussian randomSeed"
-},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE]
-})})());
-hljs.registerLanguage("profile",(()=>{"use strict";return e=>({
-name:"Python profiler",contains:[e.C_NUMBER_MODE,{
-begin:"[a-zA-Z_][\\da-zA-Z_]+\\.[\\da-zA-Z_]{1,3}",end:":",excludeEnd:!0},{
-begin:"(ncalls|tottime|cumtime)",end:"$",
-keywords:"ncalls tottime|10 cumtime|10 filename",relevance:10},{
-begin:"function calls",end:"$",contains:[e.C_NUMBER_MODE],relevance:10
-},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string",begin:"\\(",
-end:"\\)$",excludeBegin:!0,excludeEnd:!0,relevance:0}]})})());
-hljs.registerLanguage("prolog",(()=>{"use strict";return n=>{const e={
-begin:/\(/,end:/\)/,relevance:0},a={begin:/\[/,end:/\]/},s={className:"comment",
-begin:/%/,end:/$/,contains:[n.PHRASAL_WORDS_MODE]},i={className:"string",
-begin:/`/,end:/`/,contains:[n.BACKSLASH_ESCAPE]},g=[{begin:/[a-z][A-Za-z0-9_]*/,
-relevance:0},{className:"symbol",variants:[{begin:/[A-Z][a-zA-Z0-9_]*/},{
-begin:/_[A-Za-z0-9_]*/}],relevance:0},e,{begin:/:-/
-},a,s,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,i,{
-className:"string",begin:/0'(\\'|.)/},{className:"string",begin:/0'\\s/
-},n.C_NUMBER_MODE];return e.contains=g,a.contains=g,{name:"Prolog",
-contains:g.concat([{begin:/\.$/}])}}})());
-hljs.registerLanguage("properties",(()=>{"use strict";return e=>{
-var n="[ \\t\\f]*",a=n+"[:=]"+n,t="("+a+"|[ \\t\\f]+)",r="([^\\\\\\W:= \\t\\f\\n]|\\\\.)+",s="([^\\\\:= \\t\\f\\n]|\\\\.)+",i={
-end:t,relevance:0,starts:{className:"string",end:/$/,relevance:0,contains:[{
-begin:"\\\\\\\\"},{begin:"\\\\\\n"}]}};return{name:".properties",
-case_insensitive:!0,illegal:/\S/,contains:[e.COMMENT("^\\s*[!#]","$"),{
-returnBegin:!0,variants:[{begin:r+a,relevance:1},{begin:r+"[ \\t\\f]+",
-relevance:0}],contains:[{className:"attr",begin:r,endsParent:!0,relevance:0}],
-starts:i},{begin:s+t,returnBegin:!0,relevance:0,contains:[{className:"meta",
-begin:s,endsParent:!0,relevance:0}],starts:i},{className:"attr",relevance:0,
-begin:s+n+"$"}]}}})());
-hljs.registerLanguage("protobuf",(()=>{"use strict";return e=>({
-name:"Protocol Buffers",keywords:{
-keyword:"package import option optional required repeated group oneof",
-built_in:"double float int32 int64 uint32 uint64 sint32 sint64 fixed32 fixed64 sfixed32 sfixed64 bool string bytes",
-literal:"true false"},
-contains:[e.QUOTE_STRING_MODE,e.NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-className:"class",beginKeywords:"message enum service",end:/\{/,illegal:/\n/,
-contains:[e.inherit(e.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]},{
-className:"function",beginKeywords:"rpc",end:/[{;]/,excludeEnd:!0,
-keywords:"rpc returns"},{begin:/^\s*[A-Z_]+(?=\s*=[^\n]+;$)/}]})})());
-hljs.registerLanguage("puppet",(()=>{"use strict";return e=>{
-const s=e.COMMENT("#","$"),r="([A-Za-z_]|::)(\\w|::)*",a=e.inherit(e.TITLE_MODE,{
-begin:r}),n={className:"variable",begin:"\\$"+r},i={className:"string",
-contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/
-}]};return{name:"Puppet",aliases:["pp"],contains:[s,n,i,{beginKeywords:"class",
-end:"\\{|;",illegal:/=/,contains:[a,s]},{beginKeywords:"define",end:/\{/,
-contains:[{className:"section",begin:e.IDENT_RE,endsParent:!0}]},{
-begin:e.IDENT_RE+"\\s+\\{",returnBegin:!0,end:/\S/,contains:[{
-className:"keyword",begin:e.IDENT_RE},{begin:/\{/,end:/\}/,keywords:{
-keyword:"and case default else elsif false if in import enherits node or true undef unless main settings $string ",
-literal:"alias audit before loglevel noop require subscribe tag owner ensure group mode name|0 changes context force incl lens load_path onlyif provider returns root show_diff type_check en_address ip_address realname command environment hour monute month monthday special target weekday creates cwd ogoutput refresh refreshonly tries try_sleep umask backup checksum content ctime force ignore links mtime purge recurse recurselimit replace selinux_ignore_defaults selrange selrole seltype seluser source souirce_permissions sourceselect validate_cmd validate_replacement allowdupe attribute_membership auth_membership forcelocal gid ia_load_module members system host_aliases ip allowed_trunk_vlans description device_url duplex encapsulation etherchannel native_vlan speed principals allow_root auth_class auth_type authenticate_user k_of_n mechanisms rule session_owner shared options device fstype enable hasrestart directory present absent link atboot blockdevice device dump pass remounts poller_tag use message withpath adminfile allow_virtual allowcdrom category configfiles flavor install_options instance package_settings platform responsefile status uninstall_options vendor unless_system_user unless_uid binary control flags hasstatus manifest pattern restart running start stop allowdupe auths expiry gid groups home iterations key_membership keys managehome membership password password_max_age password_min_age profile_membership profiles project purge_ssh_keys role_membership roles salt shell uid baseurl cost descr enabled enablegroups exclude failovermethod gpgcheck gpgkey http_caching include includepkgs keepalive metadata_expire metalink mirrorlist priority protect proxy proxy_password proxy_username repo_gpgcheck s3_enabled skip_if_unavailable sslcacert sslclientcert sslclientkey sslverify mounted",
-built_in:"architecture augeasversion blockdevices boardmanufacturer boardproductname boardserialnumber cfkey dhcp_servers domain ec2_ ec2_userdata facterversion filesystems ldom fqdn gid hardwareisa hardwaremodel hostname id|0 interfaces ipaddress ipaddress_ ipaddress6 ipaddress6_ iphostnumber is_virtual kernel kernelmajversion kernelrelease kernelversion kernelrelease kernelversion lsbdistcodename lsbdistdescription lsbdistid lsbdistrelease lsbmajdistrelease lsbminordistrelease lsbrelease macaddress macaddress_ macosx_buildversion macosx_productname macosx_productversion macosx_productverson_major macosx_productversion_minor manufacturer memoryfree memorysize netmask metmask_ network_ operatingsystem operatingsystemmajrelease operatingsystemrelease osfamily partitions path physicalprocessorcount processor processorcount productname ps puppetversion rubysitedir rubyversion selinux selinux_config_mode selinux_config_policy selinux_current_mode selinux_current_mode selinux_enforced selinux_policyversion serialnumber sp_ sshdsakey sshecdsakey sshrsakey swapencrypted swapfree swapsize timezone type uniqueid uptime uptime_days uptime_hours uptime_seconds uuid virtual vlans xendomains zfs_version zonenae zones zpool_version"
-},relevance:0,contains:[i,s,{begin:"[a-zA-Z_]+\\s*=>",returnBegin:!0,end:"=>",
-contains:[{className:"attr",begin:e.IDENT_RE}]},{className:"number",
-begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",
-relevance:0},n]}],relevance:0}]}}})());
-hljs.registerLanguage("purebasic",(()=>{"use strict";return e=>({
-name:"PureBASIC",aliases:["pb","pbi"],
-keywords:"Align And Array As Break CallDebugger Case CompilerCase CompilerDefault CompilerElse CompilerElseIf CompilerEndIf CompilerEndSelect CompilerError CompilerIf CompilerSelect CompilerWarning Continue Data DataSection Debug DebugLevel Declare DeclareC DeclareCDLL DeclareDLL DeclareModule Default Define Dim DisableASM DisableDebugger DisableExplicit Else ElseIf EnableASM EnableDebugger EnableExplicit End EndDataSection EndDeclareModule EndEnumeration EndIf EndImport EndInterface EndMacro EndModule EndProcedure EndSelect EndStructure EndStructureUnion EndWith Enumeration EnumerationBinary Extends FakeReturn For ForEach ForEver Global Gosub Goto If Import ImportC IncludeBinary IncludeFile IncludePath Interface List Macro MacroExpandedCount Map Module NewList NewMap Next Not Or Procedure ProcedureC ProcedureCDLL ProcedureDLL ProcedureReturn Protected Prototype PrototypeC ReDim Read Repeat Restore Return Runtime Select Shared Static Step Structure StructureUnion Swap Threaded To UndefineMacro Until Until  UnuseModule UseModule Wend While With XIncludeFile XOr",
-contains:[e.COMMENT(";","$",{relevance:0}),{className:"function",
-begin:"\\b(Procedure|Declare)(C|CDLL|DLL)?\\b",end:"\\(",excludeEnd:!0,
-returnBegin:!0,contains:[{className:"keyword",
-begin:"(Procedure|Declare)(C|CDLL|DLL)?",excludeEnd:!0},{className:"type",
-begin:"\\.\\w*"},e.UNDERSCORE_TITLE_MODE]},{className:"string",begin:'(~)?"',
-end:'"',illegal:"\\n"},{className:"symbol",begin:"#[a-zA-Z_]\\w*\\$?"}]})})());
-hljs.registerLanguage("python",(()=>{"use strict";return e=>{const n={
-$pattern:/[A-Za-z]\w+|__\w+__/,
-keyword:["and","as","assert","async","await","break","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],
-built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"],
-literal:["__debug__","Ellipsis","False","None","NotImplemented","True"],
-type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"]
-},a={className:"meta",begin:/^(>>>|\.\.\.) /},i={className:"subst",begin:/\{/,
-end:/\}/,keywords:n,illegal:/#/},s={begin:/\{\{/,relevance:0},t={
-className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{
-begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/,
-contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{
-begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/,
-contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{
-begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/,
-contains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/,
-end:/"""/,contains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/([uU]|[rR])'/,end:/'/,
-relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{
-begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/,
-end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/,
-contains:[e.BACKSLASH_ESCAPE,s,i]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/,
-contains:[e.BACKSLASH_ESCAPE,s,i]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-},r="[0-9](_?[0-9])*",l=`(\\b(${r}))?\\.(${r})|\\b(${r})\\.`,b={
-className:"number",relevance:0,variants:[{
-begin:`(\\b(${r})|(${l}))[eE][+-]?(${r})[jJ]?\\b`},{begin:`(${l})[jJ]?`},{
-begin:"\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?\\b"},{
-begin:"\\b0[bB](_?[01])+[lL]?\\b"},{begin:"\\b0[oO](_?[0-7])+[lL]?\\b"},{
-begin:"\\b0[xX](_?[0-9a-fA-F])+[lL]?\\b"},{begin:`\\b(${r})[jJ]\\b`}]},o={
-className:"comment",
-begin:(d=/# type:/,((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(?=",d,")")),
-end:/$/,keywords:n,contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,
-endsWithParent:!0}]},c={className:"params",variants:[{className:"",
-begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,
-keywords:n,contains:["self",a,b,t,e.HASH_COMMENT_MODE]}]};var d
-;return i.contains=[t,b,a],{name:"Python",aliases:["py","gyp","ipython"],
-keywords:n,illegal:/(<\/|->|\?)|=>/,contains:[a,b,{begin:/\bself\b/},{
-beginKeywords:"if",relevance:0},t,o,e.HASH_COMMENT_MODE,{variants:[{
-className:"function",beginKeywords:"def"},{className:"class",
-beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/,
-contains:[e.UNDERSCORE_TITLE_MODE,c,{begin:/->/,endsWithParent:!0,keywords:n}]
-},{className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[b,c,t]}]}}})());
-hljs.registerLanguage("python-repl",(()=>{"use strict";return s=>({
-aliases:["pycon"],contains:[{className:"meta",starts:{end:/ |$/,starts:{end:"$",
-subLanguage:"python"}},variants:[{begin:/^>>>(?=[ ]|$)/},{
-begin:/^\.\.\.(?=[ ]|$)/}]}]})})());
-hljs.registerLanguage("q",(()=>{"use strict";return e=>({name:"Q",
-aliases:["k","kdb"],keywords:{$pattern:/(`?)[A-Za-z0-9_]+\b/,
-keyword:"do while select delete by update from",literal:"0b 1b",
-built_in:"neg not null string reciprocal floor ceiling signum mod xbar xlog and or each scan over prior mmu lsq inv md5 ltime gtime count first var dev med cov cor all any rand sums prds mins maxs fills deltas ratios avgs differ prev next rank reverse iasc idesc asc desc msum mcount mavg mdev xrank mmin mmax xprev rotate distinct group where flip type key til get value attr cut set upsert raze union inter except cross sv vs sublist enlist read0 read1 hopen hclose hdel hsym hcount peach system ltrim rtrim trim lower upper ssr view tables views cols xcols keys xkey xcol xasc xdesc fkeys meta lj aj aj0 ij pj asof uj ww wj wj1 fby xgroup ungroup ej save load rsave rload show csv parse eval min max avg wavg wsum sin cos tan sum",
-type:"`float `double int `timestamp `timespan `datetime `time `boolean `symbol `char `byte `short `long `real `month `date `minute `second `guid"
-},contains:[e.C_LINE_COMMENT_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE]})})());
-hljs.registerLanguage("qml",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const r="[a-zA-Z_][a-zA-Z0-9\\._]*",a={
-className:"attribute",begin:"\\bid\\s*:",starts:{className:"string",end:r,
-returnEnd:!1}},t={begin:r+"\\s*:",returnBegin:!0,contains:[{
-className:"attribute",begin:r,end:"\\s*:",excludeEnd:!0,relevance:0}],
-relevance:0},i={begin:e(r,/\s*\{/),end:/\{/,returnBegin:!0,relevance:0,
-contains:[n.inherit(n.TITLE_MODE,{begin:r})]};return{name:"QML",aliases:["qt"],
-case_insensitive:!1,keywords:{
-keyword:"in of on if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await import",
-literal:"true false null undefined NaN Infinity",
-built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Behavior bool color coordinate date double enumeration font geocircle georectangle geoshape int list matrix4x4 parent point quaternion real rect size string url variant vector2d vector3d vector4d Promise"
-},contains:[{className:"meta",begin:/^\s*['"]use (strict|asm)['"]/
-},n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,{className:"string",begin:"`",end:"`",
-contains:[n.BACKSLASH_ESCAPE,{className:"subst",begin:"\\$\\{",end:"\\}"}]
-},n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,{className:"number",variants:[{
-begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:n.C_NUMBER_RE}],
-relevance:0},{begin:"("+n.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",
-keywords:"return throw case",
-contains:[n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.REGEXP_MODE,{begin:/</,
-end:/>\s*[);\]]/,relevance:0,subLanguage:"xml"}],relevance:0},{
-className:"keyword",begin:"\\bsignal\\b",starts:{className:"string",
-end:"(\\(|:|=|;|,|//|/\\*|$)",returnEnd:!0}},{className:"keyword",
-begin:"\\bproperty\\b",starts:{className:"string",end:"(:|=|;|,|//|/\\*|$)",
-returnEnd:!0}},{className:"function",beginKeywords:"function",end:/\{/,
-excludeEnd:!0,contains:[n.inherit(n.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/
-}),{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,
-contains:[n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE]}],illegal:/\[|%/},{
-begin:"\\."+n.IDENT_RE,relevance:0},a,t,i],illegal:/#/}}})());
-hljs.registerLanguage("r",(()=>{"use strict";function e(...e){return e.map((e=>{
-return(a=e)?"string"==typeof a?a:a.source:null;var a})).join("")}return a=>{
-const n=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/;return{name:"R",
-illegal:/->/,keywords:{$pattern:n,
-keyword:"function if in break next repeat else for while",
-literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10",
-built_in:"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm"
-},compilerExtensions:[(a,n)=>{if(!a.beforeMatch)return
-;if(a.starts)throw Error("beforeMatch cannot be used with starts")
-;const i=Object.assign({},a);Object.keys(a).forEach((e=>{delete a[e]
-})),a.begin=e(i.beforeMatch,e("(?=",i.begin,")")),a.starts={relevance:0,
-contains:[Object.assign(i,{endsParent:!0})]},a.relevance=0,delete i.beforeMatch
-}],contains:[a.COMMENT(/#'/,/$/,{contains:[{className:"doctag",
-begin:"@examples",starts:{contains:[{begin:/\n/},{begin:/#'\s*(?=@[a-zA-Z]+)/,
-endsParent:!0},{begin:/#'/,end:/$/,excludeBegin:!0}]}},{className:"doctag",
-begin:"@param",end:/$/,contains:[{className:"variable",variants:[{begin:n},{
-begin:/`(?:\\.|[^`\\])+`/}],endsParent:!0}]},{className:"doctag",
-begin:/@[a-zA-Z]+/},{className:"meta-keyword",begin:/\\[a-zA-Z]+/}]
-}),a.HASH_COMMENT_MODE,{className:"string",contains:[a.BACKSLASH_ESCAPE],
-variants:[a.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\(/,end:/\)(-*)"/
-}),a.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/,end:/\}(-*)"/
-}),a.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/
-}),a.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/
-}),a.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/
-}),a.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"',
-relevance:0},{begin:"'",end:"'",relevance:0}]},{className:"number",relevance:0,
-beforeMatch:/([^a-zA-Z0-9._])/,variants:[{
-match:/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/},{
-match:/0[xX][0-9a-fA-F]+([pP][+-]?\d+)?[Li]?/},{
-match:/(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?[Li]?/}]},{begin:"%",end:"%"},{
-begin:e(/[a-zA-Z][a-zA-Z_0-9]*/,"\\s+<-\\s+")},{begin:"`",end:"`",contains:[{
-begin:/\\./}]}]}}})());
-hljs.registerLanguage("reasonml",(()=>{"use strict";return e=>{
-const n="~?[a-z$_][0-9a-zA-Z$_]*",a="`?[A-Z$_][0-9a-zA-Z$_]*",s="("+["||","++","**","+.","*","/","*.","/.","..."].map((e=>e.split("").map((e=>"\\"+e)).join(""))).join("|")+"|\\|>|&&|==|===)",i="\\s+"+s+"\\s+",r={
-keyword:"and as asr assert begin class constraint do done downto else end exception external for fun function functor if in include inherit initializer land lazy let lor lsl lsr lxor match method mod module mutable new nonrec object of open or private rec sig struct then to try type val virtual when while with",
-built_in:"array bool bytes char exn|5 float int int32 int64 list lazy_t|5 nativeint|5 ref string unit ",
-literal:"true false"
-},l="\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",t={
-className:"number",relevance:0,variants:[{begin:l},{begin:"\\(-"+l+"\\)"}]},c={
-className:"operator",relevance:0,begin:s},o=[{className:"identifier",
-relevance:0,begin:n},c,t],g=[e.QUOTE_STRING_MODE,c,{className:"module",
-begin:"\\b"+a,returnBegin:!0,end:".",contains:[{className:"identifier",begin:a,
-relevance:0}]}],b=[{className:"module",begin:"\\b"+a,returnBegin:!0,end:".",
-relevance:0,contains:[{className:"identifier",begin:a,relevance:0}]}],m={
-className:"function",relevance:0,keywords:r,variants:[{
-begin:"\\s(\\(\\.?.*?\\)|"+n+")\\s*=>",end:"\\s*=>",returnBegin:!0,relevance:0,
-contains:[{className:"params",variants:[{begin:n},{
-begin:"~?[a-z$_][0-9a-zA-Z$_]*(\\s*:\\s*[a-z$_][0-9a-z$_]*(\\(\\s*('?[a-z$_][0-9a-z$_]*\\s*(,'?[a-z$_][0-9a-z$_]*\\s*)*)?\\))?){0,2}"
-},{begin:/\(\s*\)/}]}]},{begin:"\\s\\(\\.?[^;\\|]*\\)\\s*=>",end:"\\s=>",
-returnBegin:!0,relevance:0,contains:[{className:"params",relevance:0,variants:[{
-begin:n,end:"(,|\\n|\\))",relevance:0,contains:[c,{className:"typing",begin:":",
-end:"(,|\\n)",returnBegin:!0,relevance:0,contains:b}]}]}]},{
-begin:"\\(\\.\\s"+n+"\\)\\s*=>"}]};g.push(m);const d={className:"constructor",
-begin:a+"\\(",end:"\\)",illegal:"\\n",keywords:r,
-contains:[e.QUOTE_STRING_MODE,c,{className:"params",begin:"\\b"+n}]},u={
-className:"pattern-match",begin:"\\|",returnBegin:!0,keywords:r,end:"=>",
-relevance:0,contains:[d,c,{relevance:0,className:"constructor",begin:a}]},v={
-className:"module-access",keywords:r,returnBegin:!0,variants:[{
-begin:"\\b("+a+"\\.)+"+n},{begin:"\\b("+a+"\\.)+\\(",end:"\\)",returnBegin:!0,
-contains:[m,{begin:"\\(",end:"\\)",skip:!0}].concat(g)},{
-begin:"\\b("+a+"\\.)+\\{",end:/\}/}],contains:g};return b.push(v),{
-name:"ReasonML",aliases:["re"],keywords:r,illegal:"(:-|:=|\\$\\{|\\+=)",
-contains:[e.COMMENT("/\\*","\\*/",{illegal:"^(#,\\/\\/)"}),{
-className:"character",begin:"'(\\\\[^']+|[^'])'",illegal:"\\n",relevance:0
-},e.QUOTE_STRING_MODE,{className:"literal",begin:"\\(\\)",relevance:0},{
-className:"literal",begin:"\\[\\|",end:"\\|\\]",relevance:0,contains:o},{
-className:"literal",begin:"\\[",end:"\\]",relevance:0,contains:o},d,{
-className:"operator",begin:i,illegal:"--\x3e",relevance:0
-},t,e.C_LINE_COMMENT_MODE,u,m,{className:"module-def",
-begin:"\\bmodule\\s+"+n+"\\s+"+a+"\\s+=\\s+\\{",end:/\}/,returnBegin:!0,
-keywords:r,relevance:0,contains:[{className:"module",relevance:0,begin:a},{
-begin:/\{/,end:/\}/,skip:!0}].concat(g)},v]}}})());
-hljs.registerLanguage("rib",(()=>{"use strict";return e=>({name:"RenderMan RIB",
-keywords:"ArchiveRecord AreaLightSource Atmosphere Attribute AttributeBegin AttributeEnd Basis Begin Blobby Bound Clipping ClippingPlane Color ColorSamples ConcatTransform Cone CoordinateSystem CoordSysTransform CropWindow Curves Cylinder DepthOfField Detail DetailRange Disk Displacement Display End ErrorHandler Exposure Exterior Format FrameAspectRatio FrameBegin FrameEnd GeneralPolygon GeometricApproximation Geometry Hider Hyperboloid Identity Illuminate Imager Interior LightSource MakeCubeFaceEnvironment MakeLatLongEnvironment MakeShadow MakeTexture Matte MotionBegin MotionEnd NuPatch ObjectBegin ObjectEnd ObjectInstance Opacity Option Orientation Paraboloid Patch PatchMesh Perspective PixelFilter PixelSamples PixelVariance Points PointsGeneralPolygons PointsPolygons Polygon Procedural Projection Quantize ReadArchive RelativeDetail ReverseOrientation Rotate Scale ScreenWindow ShadingInterpolation ShadingRate Shutter Sides Skew SolidBegin SolidEnd Sphere SubdivisionMesh Surface TextureCoordinates Torus Transform TransformBegin TransformEnd TransformPoints Translate TrimCurve WorldBegin WorldEnd",
-illegal:"</",
-contains:[e.HASH_COMMENT_MODE,e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-})})());
-hljs.registerLanguage("roboconf",(()=>{"use strict";return e=>{
-const n="[a-zA-Z-_][^\\n{]+\\{",a={className:"attribute",begin:/[a-zA-Z-_]+/,
-end:/\s*:/,excludeEnd:!0,starts:{end:";",relevance:0,contains:[{
-className:"variable",begin:/\.[a-zA-Z-_]+/},{className:"keyword",
-begin:/\(optional\)/}]}};return{name:"Roboconf",aliases:["graph","instances"],
-case_insensitive:!0,keywords:"import",contains:[{begin:"^facet "+n,end:/\}/,
-keywords:"facet",contains:[a,e.HASH_COMMENT_MODE]},{begin:"^\\s*instance of "+n,
-end:/\}/,
-keywords:"name count channels instance-data instance-state instance of",
-illegal:/\S/,contains:["self",a,e.HASH_COMMENT_MODE]},{begin:"^"+n,end:/\}/,
-contains:[a,e.HASH_COMMENT_MODE]},e.HASH_COMMENT_MODE]}}})());
-hljs.registerLanguage("routeros",(()=>{"use strict";return e=>{
-const r="foreach do while for if from to step else on-error and or not in",n="true false yes no nothing nil null",i={
-className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{(.*?)\}/
-}]},s={className:"string",begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,i,{
-className:"variable",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]}]},t={
-className:"string",begin:/'/,end:/'/};return{name:"Microtik RouterOS script",
-aliases:["mikrotik"],case_insensitive:!0,keywords:{$pattern:/:?[\w-]+/,
-literal:n,
-keyword:r+" :"+r.split(" ").join(" :")+" :"+"global local beep delay put len typeof pick log time set find environment terminal error execute parse resolve toarray tobool toid toip toip6 tonum tostr totime".split(" ").join(" :")
-},contains:[{variants:[{begin:/\/\*/,end:/\*\//},{begin:/\/\//,end:/$/},{
-begin:/<\//,end:/>/}],illegal:/./},e.COMMENT("^#","$"),s,t,i,{
-begin:/[\w-]+=([^\s{}[\]()>]+)/,relevance:0,returnBegin:!0,contains:[{
-className:"attribute",begin:/[^=]+/},{begin:/=/,endsWithParent:!0,relevance:0,
-contains:[s,t,i,{className:"literal",begin:"\\b("+n.split(" ").join("|")+")\\b"
-},{begin:/("[^"]*"|[^\s{}[\]]+)/}]}]},{className:"number",begin:/\*[0-9a-fA-F]+/
-},{
-begin:"\\b(add|remove|enable|disable|set|get|print|export|edit|find|run|debug|error|info|warning)([\\s[(\\]|])",
-returnBegin:!0,contains:[{className:"builtin-name",begin:/\w+/}]},{
-className:"built_in",variants:[{
-begin:"(\\.\\./|/|\\s)((traffic-flow|traffic-generator|firewall|scheduler|aaa|accounting|address-list|address|align|area|bandwidth-server|bfd|bgp|bridge|client|clock|community|config|connection|console|customer|default|dhcp-client|dhcp-server|discovery|dns|e-mail|ethernet|filter|firmware|gps|graphing|group|hardware|health|hotspot|identity|igmp-proxy|incoming|instance|interface|ip|ipsec|ipv6|irq|l2tp-server|lcd|ldp|logging|mac-server|mac-winbox|mangle|manual|mirror|mme|mpls|nat|nd|neighbor|network|note|ntp|ospf|ospf-v3|ovpn-server|page|peer|pim|ping|policy|pool|port|ppp|pppoe-client|pptp-server|prefix|profile|proposal|proxy|queue|radius|resource|rip|ripng|route|routing|screen|script|security-profiles|server|service|service-port|settings|shares|smb|sms|sniffer|snmp|snooper|socks|sstp-server|system|tool|tracking|type|upgrade|upnp|user-manager|users|user|vlan|secret|vrrp|watchdog|web-access|wireless|pptp|pppoe|lan|wan|layer7-protocol|lease|simple|raw);?\\s)+"
-},{begin:/\.\./,relevance:0}]}]}}})());
-hljs.registerLanguage("rsl",(()=>{"use strict";return e=>({name:"RenderMan RSL",
-keywords:{
-keyword:"float color point normal vector matrix while for if do return else break extern continue",
-built_in:"abs acos ambient area asin atan atmosphere attribute calculatenormal ceil cellnoise clamp comp concat cos degrees depth Deriv diffuse distance Du Dv environment exp faceforward filterstep floor format fresnel incident length lightsource log match max min mod noise normalize ntransform opposite option phong pnoise pow printf ptlined radians random reflect refract renderinfo round setcomp setxcomp setycomp setzcomp shadow sign sin smoothstep specular specularbrdf spline sqrt step tan texture textureinfo trace transform vtransform xcomp ycomp zcomp"
-},illegal:"</",
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"#",end:"$"},{className:"class",
-beginKeywords:"surface displacement light volume imager",end:"\\("},{
-beginKeywords:"illuminate illuminance gather",end:"\\("}]})})());
-hljs.registerLanguage("ruleslanguage",(()=>{"use strict";return T=>({
-name:"Oracle Rules Language",keywords:{
-keyword:"BILL_PERIOD BILL_START BILL_STOP RS_EFFECTIVE_START RS_EFFECTIVE_STOP RS_JURIS_CODE RS_OPCO_CODE INTDADDATTRIBUTE|5 INTDADDVMSG|5 INTDBLOCKOP|5 INTDBLOCKOPNA|5 INTDCLOSE|5 INTDCOUNT|5 INTDCOUNTSTATUSCODE|5 INTDCREATEMASK|5 INTDCREATEDAYMASK|5 INTDCREATEFACTORMASK|5 INTDCREATEHANDLE|5 INTDCREATEOVERRIDEDAYMASK|5 INTDCREATEOVERRIDEMASK|5 INTDCREATESTATUSCODEMASK|5 INTDCREATETOUPERIOD|5 INTDDELETE|5 INTDDIPTEST|5 INTDEXPORT|5 INTDGETERRORCODE|5 INTDGETERRORMESSAGE|5 INTDISEQUAL|5 INTDJOIN|5 INTDLOAD|5 INTDLOADACTUALCUT|5 INTDLOADDATES|5 INTDLOADHIST|5 INTDLOADLIST|5 INTDLOADLISTDATES|5 INTDLOADLISTENERGY|5 INTDLOADLISTHIST|5 INTDLOADRELATEDCHANNEL|5 INTDLOADSP|5 INTDLOADSTAGING|5 INTDLOADUOM|5 INTDLOADUOMDATES|5 INTDLOADUOMHIST|5 INTDLOADVERSION|5 INTDOPEN|5 INTDREADFIRST|5 INTDREADNEXT|5 INTDRECCOUNT|5 INTDRELEASE|5 INTDREPLACE|5 INTDROLLAVG|5 INTDROLLPEAK|5 INTDSCALAROP|5 INTDSCALE|5 INTDSETATTRIBUTE|5 INTDSETDSTPARTICIPANT|5 INTDSETSTRING|5 INTDSETVALUE|5 INTDSETVALUESTATUS|5 INTDSHIFTSTARTTIME|5 INTDSMOOTH|5 INTDSORT|5 INTDSPIKETEST|5 INTDSUBSET|5 INTDTOU|5 INTDTOURELEASE|5 INTDTOUVALUE|5 INTDUPDATESTATS|5 INTDVALUE|5 STDEV INTDDELETEEX|5 INTDLOADEXACTUAL|5 INTDLOADEXCUT|5 INTDLOADEXDATES|5 INTDLOADEX|5 INTDLOADEXRELATEDCHANNEL|5 INTDSAVEEX|5 MVLOAD|5 MVLOADACCT|5 MVLOADACCTDATES|5 MVLOADACCTHIST|5 MVLOADDATES|5 MVLOADHIST|5 MVLOADLIST|5 MVLOADLISTDATES|5 MVLOADLISTHIST|5 IF FOR NEXT DONE SELECT END CALL ABORT CLEAR CHANNEL FACTOR LIST NUMBER OVERRIDE SET WEEK DISTRIBUTIONNODE ELSE WHEN THEN OTHERWISE IENUM CSV INCLUDE LEAVE RIDER SAVE DELETE NOVALUE SECTION WARN SAVE_UPDATE DETERMINANT LABEL REPORT REVENUE EACH IN FROM TOTAL CHARGE BLOCK AND OR CSV_FILE RATE_CODE AUXILIARY_DEMAND UIDACCOUNT RS BILL_PERIOD_SELECT HOURS_PER_MONTH INTD_ERROR_STOP SEASON_SCHEDULE_NAME ACCOUNTFACTOR ARRAYUPPERBOUND CALLSTOREDPROC GETADOCONNECTION GETCONNECT GETDATASOURCE GETQUALIFIER GETUSERID HASVALUE LISTCOUNT LISTOP LISTUPDATE LISTVALUE PRORATEFACTOR RSPRORATE SETBINPATH SETDBMONITOR WQ_OPEN BILLINGHOURS DATE DATEFROMFLOAT DATETIMEFROMSTRING DATETIMETOSTRING DATETOFLOAT DAY DAYDIFF DAYNAME DBDATETIME HOUR MINUTE MONTH MONTHDIFF MONTHHOURS MONTHNAME ROUNDDATE SAMEWEEKDAYLASTYEAR SECOND WEEKDAY WEEKDIFF YEAR YEARDAY YEARSTR COMPSUM HISTCOUNT HISTMAX HISTMIN HISTMINNZ HISTVALUE MAXNRANGE MAXRANGE MINRANGE COMPIKVA COMPKVA COMPKVARFROMKQKW COMPLF IDATTR FLAG LF2KW LF2KWH MAXKW POWERFACTOR READING2USAGE AVGSEASON MAXSEASON MONTHLYMERGE SEASONVALUE SUMSEASON ACCTREADDATES ACCTTABLELOAD CONFIGADD CONFIGGET CREATEOBJECT CREATEREPORT EMAILCLIENT EXPBLKMDMUSAGE EXPMDMUSAGE EXPORT_USAGE FACTORINEFFECT GETUSERSPECIFIEDSTOP INEFFECT ISHOLIDAY RUNRATE SAVE_PROFILE SETREPORTTITLE USEREXIT WATFORRUNRATE TO TABLE ACOS ASIN ATAN ATAN2 BITAND CEIL COS COSECANT COSH COTANGENT DIVQUOT DIVREM EXP FABS FLOOR FMOD FREPM FREXPN LOG LOG10 MAX MAXN MIN MINNZ MODF POW ROUND ROUND2VALUE ROUNDINT SECANT SIN SINH SQROOT TAN TANH FLOAT2STRING FLOAT2STRINGNC INSTR LEFT LEN LTRIM MID RIGHT RTRIM STRING STRINGNC TOLOWER TOUPPER TRIM NUMDAYS READ_DATE STAGING",
-built_in:"IDENTIFIER OPTIONS XML_ELEMENT XML_OP XML_ELEMENT_OF DOMDOCCREATE DOMDOCLOADFILE DOMDOCLOADXML DOMDOCSAVEFILE DOMDOCGETROOT DOMDOCADDPI DOMNODEGETNAME DOMNODEGETTYPE DOMNODEGETVALUE DOMNODEGETCHILDCT DOMNODEGETFIRSTCHILD DOMNODEGETSIBLING DOMNODECREATECHILDELEMENT DOMNODESETATTRIBUTE DOMNODEGETCHILDELEMENTCT DOMNODEGETFIRSTCHILDELEMENT DOMNODEGETSIBLINGELEMENT DOMNODEGETATTRIBUTECT DOMNODEGETATTRIBUTEI DOMNODEGETATTRIBUTEBYNAME DOMNODEGETBYNAME"
-},
-contains:[T.C_LINE_COMMENT_MODE,T.C_BLOCK_COMMENT_MODE,T.APOS_STRING_MODE,T.QUOTE_STRING_MODE,T.C_NUMBER_MODE,{
-className:"literal",variants:[{begin:"#\\s+",relevance:0},{begin:"#[a-zA-Z .]+"
-}]}]})})());
-hljs.registerLanguage("rust",(()=>{"use strict";return e=>{
-const n="([ui](8|16|32|64|128|size)|f(32|64))?",t="drop i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 str char bool Box Option Result String Vec Copy Send Sized Sync Drop Fn FnMut FnOnce ToOwned Clone Debug PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator SliceConcatExt ToString assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules! assert_ne! debug_assert_ne!"
-;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",
-keyword:"abstract as async await become box break const continue crate do dyn else enum extern false final fn for if impl in let loop macro match mod move mut override priv pub ref return self Self static struct super trait true try type typeof unsafe unsized use virtual where while yield",
-literal:"true false Some None Ok Err",built_in:t},illegal:"</",
-contains:[e.C_LINE_COMMENT_MODE,e.COMMENT("/\\*","\\*/",{contains:["self"]
-}),e.inherit(e.QUOTE_STRING_MODE,{begin:/b?"/,illegal:null}),{
-className:"string",variants:[{begin:/r(#*)"(.|\n)*?"\1(?!#)/},{
-begin:/b?'\\?(x\w{2}|u\w{4}|U\w{8}|.)'/}]},{className:"symbol",
-begin:/'[a-zA-Z_][a-zA-Z0-9_]*/},{className:"number",variants:[{
-begin:"\\b0b([01_]+)"+n},{begin:"\\b0o([0-7_]+)"+n},{
-begin:"\\b0x([A-Fa-f0-9_]+)"+n},{
-begin:"\\b(\\d[\\d_]*(\\.[0-9_]+)?([eE][+-]?[0-9_]+)?)"+n}],relevance:0},{
-className:"function",beginKeywords:"fn",end:"(\\(|<)",excludeEnd:!0,
-contains:[e.UNDERSCORE_TITLE_MODE]},{className:"meta",begin:"#!?\\[",end:"\\]",
-contains:[{className:"meta-string",begin:/"/,end:/"/}]},{className:"class",
-beginKeywords:"type",end:";",contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{
-endsParent:!0})],illegal:"\\S"},{className:"class",
-beginKeywords:"trait enum struct union",end:/\{/,
-contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"[\\w\\d]"
-},{begin:e.IDENT_RE+"::",keywords:{built_in:t}},{begin:"->"}]}}})());
-hljs.registerLanguage("sas",(()=>{"use strict";return e=>({name:"SAS",
-case_insensitive:!0,keywords:{
-literal:"null missing _all_ _automatic_ _character_ _infile_ _n_ _name_ _null_ _numeric_ _user_ _webout_",
-meta:"do if then else end until while abort array attrib by call cards cards4 catname continue datalines datalines4 delete delim delimiter display dm drop endsas error file filename footnote format goto in infile informat input keep label leave length libname link list lostcard merge missing modify options output out page put redirect remove rename replace retain return select set skip startsas stop title update waitsas where window x systask add and alter as cascade check create delete describe distinct drop foreign from group having index insert into in key like message modify msgtype not null on or order primary references reset restrict select set table unique update validate view where"
-},contains:[{className:"keyword",begin:/^\s*(proc [\w\d_]+|data|run|quit)[\s;]/
-},{className:"variable",begin:/&[a-zA-Z_&][a-zA-Z0-9_]*\.?/},{
-className:"emphasis",begin:/^\s*datalines|cards.*;/,end:/^\s*;\s*$/},{
-className:"built_in",
-begin:"%(bquote|nrbquote|cmpres|qcmpres|compstor|datatyp|display|do|else|end|eval|global|goto|if|index|input|keydef|label|left|length|let|local|lowcase|macro|mend|nrbquote|nrquote|nrstr|put|qcmpres|qleft|qlowcase|qscan|qsubstr|qsysfunc|qtrim|quote|qupcase|scan|str|substr|superq|syscall|sysevalf|sysexec|sysfunc|sysget|syslput|sysprod|sysrc|sysrput|then|to|trim|unquote|until|upcase|verify|while|window)"
-},{className:"name",begin:/%[a-zA-Z_][a-zA-Z_0-9]*/},{className:"meta",
-begin:"[^%](abs|addr|airy|arcos|arsin|atan|attrc|attrn|band|betainv|blshift|bnot|bor|brshift|bxor|byte|cdf|ceil|cexist|cinv|close|cnonct|collate|compbl|compound|compress|cos|cosh|css|curobs|cv|daccdb|daccdbsl|daccsl|daccsyd|dacctab|dairy|date|datejul|datepart|datetime|day|dclose|depdb|depdbsl|depdbsl|depsl|depsl|depsyd|depsyd|deptab|deptab|dequote|dhms|dif|digamma|dim|dinfo|dnum|dopen|doptname|doptnum|dread|dropnote|dsname|erf|erfc|exist|exp|fappend|fclose|fcol|fdelete|fetch|fetchobs|fexist|fget|fileexist|filename|fileref|finfo|finv|fipname|fipnamel|fipstate|floor|fnonct|fnote|fopen|foptname|foptnum|fpoint|fpos|fput|fread|frewind|frlen|fsep|fuzz|fwrite|gaminv|gamma|getoption|getvarc|getvarn|hbound|hms|hosthelp|hour|ibessel|index|indexc|indexw|input|inputc|inputn|int|intck|intnx|intrr|irr|jbessel|juldate|kurtosis|lag|lbound|left|length|lgamma|libname|libref|log|log10|log2|logpdf|logpmf|logsdf|lowcase|max|mdy|mean|min|minute|mod|month|mopen|mort|n|netpv|nmiss|normal|note|npv|open|ordinal|pathname|pdf|peek|peekc|pmf|point|poisson|poke|probbeta|probbnml|probchi|probf|probgam|probhypr|probit|probnegb|probnorm|probt|put|putc|putn|qtr|quote|ranbin|rancau|ranexp|rangam|range|rank|rannor|ranpoi|rantbl|rantri|ranuni|repeat|resolve|reverse|rewind|right|round|saving|scan|sdf|second|sign|sin|sinh|skewness|soundex|spedis|sqrt|std|stderr|stfips|stname|stnamel|substr|sum|symget|sysget|sysmsg|sysprod|sysrc|system|tan|tanh|time|timepart|tinv|tnonct|today|translate|tranwrd|trigamma|trim|trimn|trunc|uniform|upcase|uss|var|varfmt|varinfmt|varlabel|varlen|varname|varnum|varray|varrayx|vartype|verify|vformat|vformatd|vformatdx|vformatn|vformatnx|vformatw|vformatwx|vformatx|vinarray|vinarrayx|vinformat|vinformatd|vinformatdx|vinformatn|vinformatnx|vinformatw|vinformatwx|vinformatx|vlabel|vlabelx|vlength|vlengthx|vname|vnamex|vtype|vtypex|weekday|year|yyq|zipfips|zipname|zipnamel|zipstate)[(]"
-},{className:"string",variants:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-},e.COMMENT("\\*",";"),e.C_BLOCK_COMMENT_MODE]})})());
-hljs.registerLanguage("scala",(()=>{"use strict";return e=>{const n={
-className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"},{begin:/\$\{/,end:/\}/}]
-},a={className:"string",variants:[{begin:'"""',end:'"""'},{begin:'"',end:'"',
-illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{begin:'[a-z]+"',end:'"',
-illegal:"\\n",contains:[e.BACKSLASH_ESCAPE,n]},{className:"string",
-begin:'[a-z]+"""',end:'"""',contains:[n],relevance:10}]},s={className:"type",
-begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},t={className:"title",
-begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/,
-relevance:0},i={className:"class",beginKeywords:"class object trait type",
-end:/[:={\[\n;]/,excludeEnd:!0,
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-beginKeywords:"extends with",relevance:10},{begin:/\[/,end:/\]/,excludeBegin:!0,
-excludeEnd:!0,relevance:0,contains:[s]},{className:"params",begin:/\(/,end:/\)/,
-excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[s]},t]},l={
-className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,
-contains:[t]};return{name:"Scala",keywords:{literal:"true false null",
-keyword:"type yield lazy override def with val var sealed abstract private trait object if forSome for while throw finally protected extends import final return else break new catch super class case package default try this match continue throws implicit"
-},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,{className:"symbol",
-begin:"'\\w[\\w\\d_]*(?!')"},s,l,i,e.C_NUMBER_MODE,{className:"meta",
-begin:"@[A-Za-z]+"}]}}})());
-hljs.registerLanguage("scheme",(()=>{"use strict";return e=>{
-const t="[^\\(\\)\\[\\]\\{\\}\",'`;#|\\\\\\s]+",n={$pattern:t,
-"builtin-name":"case-lambda call/cc class define-class exit-handler field import inherit init-field interface let*-values let-values let/ec mixin opt-lambda override protect provide public rename require require-for-syntax syntax syntax-case syntax-error unit/sig unless when with-syntax and begin call-with-current-continuation call-with-input-file call-with-output-file case cond define define-syntax delay do dynamic-wind else for-each if lambda let let* let-syntax letrec letrec-syntax map or syntax-rules ' * + , ,@ - ... / ; < <= = => > >= ` abs acos angle append apply asin assoc assq assv atan boolean? caar cadr call-with-input-file call-with-output-file call-with-values car cdddar cddddr cdr ceiling char->integer char-alphabetic? char-ci<=? char-ci<? char-ci=? char-ci>=? char-ci>? char-downcase char-lower-case? char-numeric? char-ready? char-upcase char-upper-case? char-whitespace? char<=? char<? char=? char>=? char>? char? close-input-port close-output-port complex? cons cos current-input-port current-output-port denominator display eof-object? eq? equal? eqv? eval even? exact->inexact exact? exp expt floor force gcd imag-part inexact->exact inexact? input-port? integer->char integer? interaction-environment lcm length list list->string list->vector list-ref list-tail list? load log magnitude make-polar make-rectangular make-string make-vector max member memq memv min modulo negative? newline not null-environment null? number->string number? numerator odd? open-input-file open-output-file output-port? pair? peek-char port? positive? procedure? quasiquote quote quotient rational? rationalize read read-char real-part real? remainder reverse round scheme-report-environment set! set-car! set-cdr! sin sqrt string string->list string->number string->symbol string-append string-ci<=? string-ci<? string-ci=? string-ci>=? string-ci>? string-copy string-fill! string-length string-ref string-set! string<=? string<? string=? string>=? string>? string? substring symbol->string symbol? tan transcript-off transcript-on truncate values vector vector->list vector-fill! vector-length vector-ref vector-set! with-input-from-file with-output-to-file write write-char zero?"
-},r={className:"literal",begin:"(#t|#f|#\\\\"+t+"|#\\\\.)"},a={
-className:"number",variants:[{begin:"(-|\\+)?\\d+([./]\\d+)?",relevance:0},{
-begin:"(-|\\+)?\\d+([./]\\d+)?[+\\-](-|\\+)?\\d+([./]\\d+)?i",relevance:0},{
-begin:"#b[0-1]+(/[0-1]+)?"},{begin:"#o[0-7]+(/[0-7]+)?"},{
-begin:"#x[0-9a-f]+(/[0-9a-f]+)?"}]},i=e.QUOTE_STRING_MODE,c=[e.COMMENT(";","$",{
-relevance:0}),e.COMMENT("#\\|","\\|#")],s={begin:t,relevance:0},l={
-className:"symbol",begin:"'"+t},o={endsWithParent:!0,relevance:0},g={variants:[{
-begin:/'/},{begin:"`"}],contains:[{begin:"\\(",end:"\\)",
-contains:["self",r,i,a,s,l]}]},u={className:"name",relevance:0,begin:t,
-keywords:n},d={variants:[{begin:"\\(",end:"\\)"},{begin:"\\[",end:"\\]"}],
-contains:[{begin:/lambda/,endsWithParent:!0,returnBegin:!0,contains:[u,{
-endsParent:!0,variants:[{begin:/\(/,end:/\)/},{begin:/\[/,end:/\]/}],
-contains:[s]}]},u,o]};return o.contains=[r,a,i,s,l,g,d].concat(c),{
-name:"Scheme",illegal:/\S/,contains:[e.SHEBANG(),a,i,l,g,d].concat(c)}}})());
-hljs.registerLanguage("scilab",(()=>{"use strict";return e=>{
-const n=[e.C_NUMBER_MODE,{className:"string",begin:"'|\"",end:"'|\"",
-contains:[e.BACKSLASH_ESCAPE,{begin:"''"}]}];return{name:"Scilab",
-aliases:["sci"],keywords:{$pattern:/%?\w+/,
-keyword:"abort break case clear catch continue do elseif else endfunction end for function global if pause return resume select try then while",
-literal:"%f %F %t %T %pi %eps %inf %nan %e %i %z %s",
-built_in:"abs and acos asin atan ceil cd chdir clearglobal cosh cos cumprod deff disp error exec execstr exists exp eye gettext floor fprintf fread fsolve imag isdef isempty isinfisnan isvector lasterror length load linspace list listfiles log10 log2 log max min msprintf mclose mopen ones or pathconvert poly printf prod pwd rand real round sinh sin size gsort sprintf sqrt strcat strcmps tring sum system tanh tan type typename warning zeros matrix"
-},illegal:'("|#|/\\*|\\s+/\\w+)',contains:[{className:"function",
-beginKeywords:"function",end:"$",contains:[e.UNDERSCORE_TITLE_MODE,{
-className:"params",begin:"\\(",end:"\\)"}]},{
-begin:"[a-zA-Z_][a-zA-Z_0-9]*[\\.']+",relevance:0},{begin:"\\[",
-end:"\\][\\.']*",relevance:0,contains:n},e.COMMENT("//","$")].concat(n)}}})());
-hljs.registerLanguage("scss",(()=>{"use strict"
-;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],o=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],r=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse()
-;return a=>{const n=(e=>({IMPORTANT:{className:"meta",begin:"!important"},
-HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"},
-ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/,
-illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}
-}))(a),l=o,s=i,d="@[a-z-]+",c={className:"variable",
-begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b"};return{name:"SCSS",case_insensitive:!0,
-illegal:"[=/|']",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{
-className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{
-className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0
-},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag",
-begin:"\\b("+e.join("|")+")\\b",relevance:0},{className:"selector-pseudo",
-begin:":("+s.join("|")+")"},{className:"selector-pseudo",
-begin:"::("+l.join("|")+")"},c,{begin:/\(/,end:/\)/,contains:[a.CSS_NUMBER_MODE]
-},{className:"attribute",begin:"\\b("+r.join("|")+")\\b"},{
-begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b"
-},{begin:":",end:";",
-contains:[c,n.HEXCOLOR,a.CSS_NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,n.IMPORTANT]
-},{begin:"@(page|font-face)",lexemes:d,keywords:"@page @font-face"},{begin:"@",
-end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/,
-keyword:"and or not only",attribute:t.join(" ")},contains:[{begin:d,
-className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute"
-},c,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,n.HEXCOLOR,a.CSS_NUMBER_MODE]}]}}
-})());
-hljs.registerLanguage("shell",(()=>{"use strict";return s=>({
-name:"Shell Session",aliases:["console"],contains:[{className:"meta",
-begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#]/,starts:{end:/[^\\](?=\s*$)/,
-subLanguage:"bash"}}]})})());
-hljs.registerLanguage("smali",(()=>{"use strict";return e=>{
-const n=["add","and","cmp","cmpg","cmpl","const","div","double","float","goto","if","int","long","move","mul","neg","new","nop","not","or","rem","return","shl","shr","sput","sub","throw","ushr","xor"]
-;return{name:"Smali",contains:[{className:"string",begin:'"',end:'"',relevance:0
-},e.COMMENT("#","$",{relevance:0}),{className:"keyword",variants:[{
-begin:"\\s*\\.end\\s[a-zA-Z0-9]*"},{begin:"^[ ]*\\.[a-zA-Z]*",relevance:0},{
-begin:"\\s:[a-zA-Z_0-9]*",relevance:0},{
-begin:"\\s(transient|constructor|abstract|final|synthetic|public|private|protected|static|bridge|system)"
-}]},{className:"built_in",variants:[{begin:"\\s("+n.join("|")+")\\s"},{
-begin:"\\s("+n.join("|")+")((-|/)[a-zA-Z0-9]+)+\\s",relevance:10},{
-begin:"\\s(aget|aput|array|check|execute|fill|filled|goto/16|goto/32|iget|instance|invoke|iput|monitor|packed|sget|sparse)((-|/)[a-zA-Z0-9]+)*\\s",
-relevance:10}]},{className:"class",begin:"L[^(;:\n]*;",relevance:0},{
-begin:"[vp][0-9]+"}]}}})());
-hljs.registerLanguage("smalltalk",(()=>{"use strict";return e=>{
-const n="[a-z][a-zA-Z0-9_]*",a={className:"string",begin:"\\$.{1}"},s={
-className:"symbol",begin:"#"+e.UNDERSCORE_IDENT_RE};return{name:"Smalltalk",
-aliases:["st"],keywords:"self super nil true false thisContext",
-contains:[e.COMMENT('"','"'),e.APOS_STRING_MODE,{className:"type",
-begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},{begin:n+":",relevance:0
-},e.C_NUMBER_MODE,s,a,{begin:"\\|[ ]*"+n+"([ ]+"+n+")*[ ]*\\|",returnBegin:!0,
-end:/\|/,illegal:/\S/,contains:[{begin:"(\\|[ ]*)?"+n}]},{begin:"#\\(",
-end:"\\)",contains:[e.APOS_STRING_MODE,a,e.C_NUMBER_MODE,s]}]}}})());
-hljs.registerLanguage("sml",(()=>{"use strict";return e=>({
-name:"SML (Standard ML)",aliases:["ml"],keywords:{$pattern:"[a-z_]\\w*!?",
-keyword:"abstype and andalso as case datatype do else end eqtype exception fn fun functor handle if in include infix infixr let local nonfix of op open orelse raise rec sharing sig signature struct structure then type val with withtype where while",
-built_in:"array bool char exn int list option order real ref string substring vector unit word",
-literal:"true false NONE SOME LESS EQUAL GREATER nil"},illegal:/\/\/|>>/,
-contains:[{className:"literal",begin:/\[(\|\|)?\]|\(\)/,relevance:0
-},e.COMMENT("\\(\\*","\\*\\)",{contains:["self"]}),{className:"symbol",
-begin:"'[A-Za-z_](?!')[\\w']*"},{className:"type",begin:"`[A-Z][\\w']*"},{
-className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},{
-begin:"[a-z_]\\w*'[\\w']*"},e.inherit(e.APOS_STRING_MODE,{className:"string",
-relevance:0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"number",
-begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",
-relevance:0},{begin:/[-=]>/}]})})());
-hljs.registerLanguage("soy",(()=>{"use strict";return e=>({
-contains:[e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE,{begin:/\{delpackage/,
-ends:/\}/,relevance:10,keywords:"delpackage"},{begin:/\{namespace/,end:/\}/,
-relevance:10,keywords:"namespace autoescape",contains:[e.QUOTE_STRING_MODE]},{
-className:"template-tag",begin:/\{template/,end:/\{\/template\}/,relevance:10,
-keywords:"alias as autoescape call case default delcall else elseif fallbackmsg foreach if ifempty let msg namespace param print switch template",
-contains:[e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE,e.QUOTE_STRING_MODE,{
-className:"template-variable",relevance:0,begin:/\$[^}\s]+/},{className:"tag",
-begin:"</?",end:"/?>",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0
-},{endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:"attr",
-begin:"[A-Za-z0-9\\._:-]+",relevance:0},{begin:/=\s*/,relevance:0,contains:[{
-className:"string",endsParent:!0,variants:[{begin:/"/,end:/"/},{begin:/'/,
-end:/'/},{begin:/[^\s"'=<>`]+/}]}]}]}]}]}]})})());
-hljs.registerLanguage("sqf",(()=>{"use strict";return e=>{const t={
-className:"string",variants:[{begin:'"',end:'"',contains:[{begin:'""',
-relevance:0}]},{begin:"'",end:"'",contains:[{begin:"''",relevance:0}]}]},a={
-className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"define undef ifdef ifndef else endif include"},contains:[{
-begin:/\\\n/,relevance:0},e.inherit(t,{className:"meta-string"}),{
-className:"meta-string",begin:/<[^\n>]*>/,end:/$/,illegal:"\\n"
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]};return{name:"SQF",
-case_insensitive:!0,keywords:{
-keyword:"case catch default do else exit exitWith for forEach from if private switch then throw to try waitUntil while with",
-built_in:"abs accTime acos action actionIDs actionKeys actionKeysImages actionKeysNames actionKeysNamesArray actionName actionParams activateAddons activatedAddons activateKey add3DENConnection add3DENEventHandler add3DENLayer addAction addBackpack addBackpackCargo addBackpackCargoGlobal addBackpackGlobal addCamShake addCuratorAddons addCuratorCameraArea addCuratorEditableObjects addCuratorEditingArea addCuratorPoints addEditorObject addEventHandler addForce addGoggles addGroupIcon addHandgunItem addHeadgear addItem addItemCargo addItemCargoGlobal addItemPool addItemToBackpack addItemToUniform addItemToVest addLiveStats addMagazine addMagazineAmmoCargo addMagazineCargo addMagazineCargoGlobal addMagazineGlobal addMagazinePool addMagazines addMagazineTurret addMenu addMenuItem addMissionEventHandler addMPEventHandler addMusicEventHandler addOwnedMine addPlayerScores addPrimaryWeaponItem addPublicVariableEventHandler addRating addResources addScore addScoreSide addSecondaryWeaponItem addSwitchableUnit addTeamMember addToRemainsCollector addTorque addUniform addVehicle addVest addWaypoint addWeapon addWeaponCargo addWeaponCargoGlobal addWeaponGlobal addWeaponItem addWeaponPool addWeaponTurret admin agent agents AGLToASL aimedAtTarget aimPos airDensityRTD airplaneThrottle airportSide AISFinishHeal alive all3DENEntities allAirports allControls allCurators allCutLayers allDead allDeadMen allDisplays allGroups allMapMarkers allMines allMissionObjects allow3DMode allowCrewInImmobile allowCuratorLogicIgnoreAreas allowDamage allowDammage allowFileOperations allowFleeing allowGetIn allowSprint allPlayers allSimpleObjects allSites allTurrets allUnits allUnitsUAV allVariables ammo ammoOnPylon and animate animateBay animateDoor animatePylon animateSource animationNames animationPhase animationSourcePhase animationState append apply armoryPoints arrayIntersect asin ASLToAGL ASLToATL assert assignAsCargo assignAsCargoIndex assignAsCommander assignAsDriver assignAsGunner assignAsTurret assignCurator assignedCargo assignedCommander assignedDriver assignedGunner assignedItems assignedTarget assignedTeam assignedVehicle assignedVehicleRole assignItem assignTeam assignToAirport atan atan2 atg ATLToASL attachedObject attachedObjects attachedTo attachObject attachTo attackEnabled backpack backpackCargo backpackContainer backpackItems backpackMagazines backpackSpaceFor behaviour benchmark binocular boundingBox boundingBoxReal boundingCenter breakOut breakTo briefingName buildingExit buildingPos buttonAction buttonSetAction cadetMode call callExtension camCommand camCommit camCommitPrepared camCommitted camConstuctionSetParams camCreate camDestroy cameraEffect cameraEffectEnableHUD cameraInterest cameraOn cameraView campaignConfigFile camPreload camPreloaded camPrepareBank camPrepareDir camPrepareDive camPrepareFocus camPrepareFov camPrepareFovRange camPreparePos camPrepareRelPos camPrepareTarget camSetBank camSetDir camSetDive camSetFocus camSetFov camSetFovRange camSetPos camSetRelPos camSetTarget camTarget camUseNVG canAdd canAddItemToBackpack canAddItemToUniform canAddItemToVest cancelSimpleTaskDestination canFire canMove canSlingLoad canStand canSuspend canTriggerDynamicSimulation canUnloadInCombat canVehicleCargo captive captiveNum cbChecked cbSetChecked ceil channelEnabled cheatsEnabled checkAIFeature checkVisibility className clearAllItemsFromBackpack clearBackpackCargo clearBackpackCargoGlobal clearGroupIcons clearItemCargo clearItemCargoGlobal clearItemPool clearMagazineCargo clearMagazineCargoGlobal clearMagazinePool clearOverlay clearRadio clearWeaponCargo clearWeaponCargoGlobal clearWeaponPool clientOwner closeDialog closeDisplay closeOverlay collapseObjectTree collect3DENHistory collectiveRTD combatMode commandArtilleryFire commandChat commander commandFire commandFollow commandFSM commandGetOut commandingMenu commandMove commandRadio commandStop commandSuppressiveFire commandTarget commandWatch comment commitOverlay compile compileFinal completedFSM composeText configClasses configFile configHierarchy configName configProperties configSourceAddonList configSourceMod configSourceModList confirmSensorTarget connectTerminalToUAV controlsGroupCtrl copyFromClipboard copyToClipboard copyWaypoints cos count countEnemy countFriendly countSide countType countUnknown create3DENComposition create3DENEntity createAgent createCenter createDialog createDiaryLink createDiaryRecord createDiarySubject createDisplay createGearDialog createGroup createGuardedPoint createLocation createMarker createMarkerLocal createMenu createMine createMissionDisplay createMPCampaignDisplay createSimpleObject createSimpleTask createSite createSoundSource createTask createTeam createTrigger createUnit createVehicle createVehicleCrew createVehicleLocal crew ctAddHeader ctAddRow ctClear ctCurSel ctData ctFindHeaderRows ctFindRowHeader ctHeaderControls ctHeaderCount ctRemoveHeaders ctRemoveRows ctrlActivate ctrlAddEventHandler ctrlAngle ctrlAutoScrollDelay ctrlAutoScrollRewind ctrlAutoScrollSpeed ctrlChecked ctrlClassName ctrlCommit ctrlCommitted ctrlCreate ctrlDelete ctrlEnable ctrlEnabled ctrlFade ctrlHTMLLoaded ctrlIDC ctrlIDD ctrlMapAnimAdd ctrlMapAnimClear ctrlMapAnimCommit ctrlMapAnimDone ctrlMapCursor ctrlMapMouseOver ctrlMapScale ctrlMapScreenToWorld ctrlMapWorldToScreen ctrlModel ctrlModelDirAndUp ctrlModelScale ctrlParent ctrlParentControlsGroup ctrlPosition ctrlRemoveAllEventHandlers ctrlRemoveEventHandler ctrlScale ctrlSetActiveColor ctrlSetAngle ctrlSetAutoScrollDelay ctrlSetAutoScrollRewind ctrlSetAutoScrollSpeed ctrlSetBackgroundColor ctrlSetChecked ctrlSetEventHandler ctrlSetFade ctrlSetFocus ctrlSetFont ctrlSetFontH1 ctrlSetFontH1B ctrlSetFontH2 ctrlSetFontH2B ctrlSetFontH3 ctrlSetFontH3B ctrlSetFontH4 ctrlSetFontH4B ctrlSetFontH5 ctrlSetFontH5B ctrlSetFontH6 ctrlSetFontH6B ctrlSetFontHeight ctrlSetFontHeightH1 ctrlSetFontHeightH2 ctrlSetFontHeightH3 ctrlSetFontHeightH4 ctrlSetFontHeightH5 ctrlSetFontHeightH6 ctrlSetFontHeightSecondary ctrlSetFontP ctrlSetFontPB ctrlSetFontSecondary ctrlSetForegroundColor ctrlSetModel ctrlSetModelDirAndUp ctrlSetModelScale ctrlSetPixelPrecision ctrlSetPosition ctrlSetScale ctrlSetStructuredText ctrlSetText ctrlSetTextColor ctrlSetTooltip ctrlSetTooltipColorBox ctrlSetTooltipColorShade ctrlSetTooltipColorText ctrlShow ctrlShown ctrlText ctrlTextHeight ctrlTextWidth ctrlType ctrlVisible ctRowControls ctRowCount ctSetCurSel ctSetData ctSetHeaderTemplate ctSetRowTemplate ctSetValue ctValue curatorAddons curatorCamera curatorCameraArea curatorCameraAreaCeiling curatorCoef curatorEditableObjects curatorEditingArea curatorEditingAreaType curatorMouseOver curatorPoints curatorRegisteredObjects curatorSelected curatorWaypointCost current3DENOperation currentChannel currentCommand currentMagazine currentMagazineDetail currentMagazineDetailTurret currentMagazineTurret currentMuzzle currentNamespace currentTask currentTasks currentThrowable currentVisionMode currentWaypoint currentWeapon currentWeaponMode currentWeaponTurret currentZeroing cursorObject cursorTarget customChat customRadio cutFadeOut cutObj cutRsc cutText damage date dateToNumber daytime deActivateKey debriefingText debugFSM debugLog deg delete3DENEntities deleteAt deleteCenter deleteCollection deleteEditorObject deleteGroup deleteGroupWhenEmpty deleteIdentity deleteLocation deleteMarker deleteMarkerLocal deleteRange deleteResources deleteSite deleteStatus deleteTeam deleteVehicle deleteVehicleCrew deleteWaypoint detach detectedMines diag_activeMissionFSMs diag_activeScripts diag_activeSQFScripts diag_activeSQSScripts diag_captureFrame diag_captureFrameToFile diag_captureSlowFrame diag_codePerformance diag_drawMode diag_enable diag_enabled diag_fps diag_fpsMin diag_frameNo diag_lightNewLoad diag_list diag_log diag_logSlowFrame diag_mergeConfigFile diag_recordTurretLimits diag_setLightNew diag_tickTime diag_toggle dialog diarySubjectExists didJIP didJIPOwner difficulty difficultyEnabled difficultyEnabledRTD difficultyOption direction directSay disableAI disableCollisionWith disableConversation disableDebriefingStats disableMapIndicators disableNVGEquipment disableRemoteSensors disableSerialization disableTIEquipment disableUAVConnectability disableUserInput displayAddEventHandler displayCtrl displayParent displayRemoveAllEventHandlers displayRemoveEventHandler displaySetEventHandler dissolveTeam distance distance2D distanceSqr distributionRegion do3DENAction doArtilleryFire doFire doFollow doFSM doGetOut doMove doorPhase doStop doSuppressiveFire doTarget doWatch drawArrow drawEllipse drawIcon drawIcon3D drawLine drawLine3D drawLink drawLocation drawPolygon drawRectangle drawTriangle driver drop dynamicSimulationDistance dynamicSimulationDistanceCoef dynamicSimulationEnabled dynamicSimulationSystemEnabled echo edit3DENMissionAttributes editObject editorSetEventHandler effectiveCommander emptyPositions enableAI enableAIFeature enableAimPrecision enableAttack enableAudioFeature enableAutoStartUpRTD enableAutoTrimRTD enableCamShake enableCaustics enableChannel enableCollisionWith enableCopilot enableDebriefingStats enableDiagLegend enableDynamicSimulation enableDynamicSimulationSystem enableEndDialog enableEngineArtillery enableEnvironment enableFatigue enableGunLights enableInfoPanelComponent enableIRLasers enableMimics enablePersonTurret enableRadio enableReload enableRopeAttach enableSatNormalOnDetail enableSaving enableSentences enableSimulation enableSimulationGlobal enableStamina enableTeamSwitch enableTraffic enableUAVConnectability enableUAVWaypoints enableVehicleCargo enableVehicleSensor enableWeaponDisassembly endLoadingScreen endMission engineOn enginesIsOnRTD enginesRpmRTD enginesTorqueRTD entities environmentEnabled estimatedEndServerTime estimatedTimeLeft evalObjectArgument everyBackpack everyContainer exec execEditorScript execFSM execVM exp expectedDestination exportJIPMessages eyeDirection eyePos face faction fadeMusic fadeRadio fadeSound fadeSpeech failMission fillWeaponsFromPool find findCover findDisplay findEditorObject findEmptyPosition findEmptyPositionReady findIf findNearestEnemy finishMissionInit finite fire fireAtTarget firstBackpack flag flagAnimationPhase flagOwner flagSide flagTexture fleeing floor flyInHeight flyInHeightASL fog fogForecast fogParams forceAddUniform forcedMap forceEnd forceFlagTexture forceFollowRoad forceMap forceRespawn forceSpeed forceWalk forceWeaponFire forceWeatherChange forEachMember forEachMemberAgent forEachMemberTeam forgetTarget format formation formationDirection formationLeader formationMembers formationPosition formationTask formatText formLeader freeLook fromEditor fuel fullCrew gearIDCAmmoCount gearSlotAmmoCount gearSlotData get3DENActionState get3DENAttribute get3DENCamera get3DENConnections get3DENEntity get3DENEntityID get3DENGrid get3DENIconsVisible get3DENLayerEntities get3DENLinesVisible get3DENMissionAttribute get3DENMouseOver get3DENSelected getAimingCoef getAllEnvSoundControllers getAllHitPointsDamage getAllOwnedMines getAllSoundControllers getAmmoCargo getAnimAimPrecision getAnimSpeedCoef getArray getArtilleryAmmo getArtilleryComputerSettings getArtilleryETA getAssignedCuratorLogic getAssignedCuratorUnit getBackpackCargo getBleedingRemaining getBurningValue getCameraViewDirection getCargoIndex getCenterOfMass getClientState getClientStateNumber getCompatiblePylonMagazines getConnectedUAV getContainerMaxLoad getCursorObjectParams getCustomAimCoef getDammage getDescription getDir getDirVisual getDLCAssetsUsage getDLCAssetsUsageByName getDLCs getEditorCamera getEditorMode getEditorObjectScope getElevationOffset getEnvSoundController getFatigue getForcedFlagTexture getFriend getFSMVariable getFuelCargo getGroupIcon getGroupIconParams getGroupIcons getHideFrom getHit getHitIndex getHitPointDamage getItemCargo getMagazineCargo getMarkerColor getMarkerPos getMarkerSize getMarkerType getMass getMissionConfig getMissionConfigValue getMissionDLCs getMissionLayerEntities getModelInfo getMousePosition getMusicPlayedTime getNumber getObjectArgument getObjectChildren getObjectDLC getObjectMaterials getObjectProxy getObjectTextures getObjectType getObjectViewDistance getOxygenRemaining getPersonUsedDLCs getPilotCameraDirection getPilotCameraPosition getPilotCameraRotation getPilotCameraTarget getPlateNumber getPlayerChannel getPlayerScores getPlayerUID getPos getPosASL getPosASLVisual getPosASLW getPosATL getPosATLVisual getPosVisual getPosWorld getPylonMagazines getRelDir getRelPos getRemoteSensorsDisabled getRepairCargo getResolution getShadowDistance getShotParents getSlingLoad getSoundController getSoundControllerResult getSpeed getStamina getStatValue getSuppression getTerrainGrid getTerrainHeightASL getText getTotalDLCUsageTime getUnitLoadout getUnitTrait getUserMFDText getUserMFDvalue getVariable getVehicleCargo getWeaponCargo getWeaponSway getWingsOrientationRTD getWingsPositionRTD getWPPos glanceAt globalChat globalRadio goggles goto group groupChat groupFromNetId groupIconSelectable groupIconsVisible groupId groupOwner groupRadio groupSelectedUnits groupSelectUnit gunner gusts halt handgunItems handgunMagazine handgunWeapon handsHit hasInterface hasPilotCamera hasWeapon hcAllGroups hcGroupParams hcLeader hcRemoveAllGroups hcRemoveGroup hcSelected hcSelectGroup hcSetGroup hcShowBar hcShownBar headgear hideBody hideObject hideObjectGlobal hideSelection hint hintC hintCadet hintSilent hmd hostMission htmlLoad HUDMovementLevels humidity image importAllGroups importance in inArea inAreaArray incapacitatedState inflame inflamed infoPanel infoPanelComponentEnabled infoPanelComponents infoPanels inGameUISetEventHandler inheritsFrom initAmbientLife inPolygon inputAction inRangeOfArtillery insertEditorObject intersect is3DEN is3DENMultiplayer isAbleToBreathe isAgent isArray isAutoHoverOn isAutonomous isAutotest isBleeding isBurning isClass isCollisionLightOn isCopilotEnabled isDamageAllowed isDedicated isDLCAvailable isEngineOn isEqualTo isEqualType isEqualTypeAll isEqualTypeAny isEqualTypeArray isEqualTypeParams isFilePatchingEnabled isFlashlightOn isFlatEmpty isForcedWalk isFormationLeader isGroupDeletedWhenEmpty isHidden isInRemainsCollector isInstructorFigureEnabled isIRLaserOn isKeyActive isKindOf isLaserOn isLightOn isLocalized isManualFire isMarkedForCollection isMultiplayer isMultiplayerSolo isNil isNull isNumber isObjectHidden isObjectRTD isOnRoad isPipEnabled isPlayer isRealTime isRemoteExecuted isRemoteExecutedJIP isServer isShowing3DIcons isSimpleObject isSprintAllowed isStaminaEnabled isSteamMission isStreamFriendlyUIEnabled isText isTouchingGround isTurnedOut isTutHintsEnabled isUAVConnectable isUAVConnected isUIContext isUniformAllowed isVehicleCargo isVehicleRadarOn isVehicleSensorEnabled isWalking isWeaponDeployed isWeaponRested itemCargo items itemsWithMagazines join joinAs joinAsSilent joinSilent joinString kbAddDatabase kbAddDatabaseTargets kbAddTopic kbHasTopic kbReact kbRemoveTopic kbTell kbWasSaid keyImage keyName knowsAbout land landAt landResult language laserTarget lbAdd lbClear lbColor lbColorRight lbCurSel lbData lbDelete lbIsSelected lbPicture lbPictureRight lbSelection lbSetColor lbSetColorRight lbSetCurSel lbSetData lbSetPicture lbSetPictureColor lbSetPictureColorDisabled lbSetPictureColorSelected lbSetPictureRight lbSetPictureRightColor lbSetPictureRightColorDisabled lbSetPictureRightColorSelected lbSetSelectColor lbSetSelectColorRight lbSetSelected lbSetText lbSetTextRight lbSetTooltip lbSetValue lbSize lbSort lbSortByValue lbText lbTextRight lbValue leader leaderboardDeInit leaderboardGetRows leaderboardInit leaderboardRequestRowsFriends leaderboardsRequestUploadScore leaderboardsRequestUploadScoreKeepBest leaderboardState leaveVehicle libraryCredits libraryDisclaimers lifeState lightAttachObject lightDetachObject lightIsOn lightnings limitSpeed linearConversion lineIntersects lineIntersectsObjs lineIntersectsSurfaces lineIntersectsWith linkItem list listObjects listRemoteTargets listVehicleSensors ln lnbAddArray lnbAddColumn lnbAddRow lnbClear lnbColor lnbCurSelRow lnbData lnbDeleteColumn lnbDeleteRow lnbGetColumnsPosition lnbPicture lnbSetColor lnbSetColumnsPos lnbSetCurSelRow lnbSetData lnbSetPicture lnbSetText lnbSetValue lnbSize lnbSort lnbSortByValue lnbText lnbValue load loadAbs loadBackpack loadFile loadGame loadIdentity loadMagazine loadOverlay loadStatus loadUniform loadVest local localize locationPosition lock lockCameraTo lockCargo lockDriver locked lockedCargo lockedDriver lockedTurret lockIdentity lockTurret lockWP log logEntities logNetwork logNetworkTerminate lookAt lookAtPos magazineCargo magazines magazinesAllTurrets magazinesAmmo magazinesAmmoCargo magazinesAmmoFull magazinesDetail magazinesDetailBackpack magazinesDetailUniform magazinesDetailVest magazinesTurret magazineTurretAmmo mapAnimAdd mapAnimClear mapAnimCommit mapAnimDone mapCenterOnCamera mapGridPosition markAsFinishedOnSteam markerAlpha markerBrush markerColor markerDir markerPos markerShape markerSize markerText markerType max members menuAction menuAdd menuChecked menuClear menuCollapse menuData menuDelete menuEnable menuEnabled menuExpand menuHover menuPicture menuSetAction menuSetCheck menuSetData menuSetPicture menuSetValue menuShortcut menuShortcutText menuSize menuSort menuText menuURL menuValue min mineActive mineDetectedBy missionConfigFile missionDifficulty missionName missionNamespace missionStart missionVersion mod modelToWorld modelToWorldVisual modelToWorldVisualWorld modelToWorldWorld modParams moonIntensity moonPhase morale move move3DENCamera moveInAny moveInCargo moveInCommander moveInDriver moveInGunner moveInTurret moveObjectToEnd moveOut moveTime moveTo moveToCompleted moveToFailed musicVolume name nameSound nearEntities nearestBuilding nearestLocation nearestLocations nearestLocationWithDubbing nearestObject nearestObjects nearestTerrainObjects nearObjects nearObjectsReady nearRoads nearSupplies nearTargets needReload netId netObjNull newOverlay nextMenuItemIndex nextWeatherChange nMenuItems not numberOfEnginesRTD numberToDate objectCurators objectFromNetId objectParent objStatus onBriefingGroup onBriefingNotes onBriefingPlan onBriefingTeamSwitch onCommandModeChanged onDoubleClick onEachFrame onGroupIconClick onGroupIconOverEnter onGroupIconOverLeave onHCGroupSelectionChanged onMapSingleClick onPlayerConnected onPlayerDisconnected onPreloadFinished onPreloadStarted onShowNewObject onTeamSwitch openCuratorInterface openDLCPage openMap openSteamApp openYoutubeVideo or orderGetIn overcast overcastForecast owner param params parseNumber parseSimpleArray parseText parsingNamespace particlesQuality pickWeaponPool pitch pixelGrid pixelGridBase pixelGridNoUIScale pixelH pixelW playableSlotsNumber playableUnits playAction playActionNow player playerRespawnTime playerSide playersNumber playGesture playMission playMove playMoveNow playMusic playScriptedMission playSound playSound3D position positionCameraToWorld posScreenToWorld posWorldToScreen ppEffectAdjust ppEffectCommit ppEffectCommitted ppEffectCreate ppEffectDestroy ppEffectEnable ppEffectEnabled ppEffectForceInNVG precision preloadCamera preloadObject preloadSound preloadTitleObj preloadTitleRsc preprocessFile preprocessFileLineNumbers primaryWeapon primaryWeaponItems primaryWeaponMagazine priority processDiaryLink productVersion profileName profileNamespace profileNameSteam progressLoadingScreen progressPosition progressSetPosition publicVariable publicVariableClient publicVariableServer pushBack pushBackUnique putWeaponPool queryItemsPool queryMagazinePool queryWeaponPool rad radioChannelAdd radioChannelCreate radioChannelRemove radioChannelSetCallSign radioChannelSetLabel radioVolume rain rainbow random rank rankId rating rectangular registeredTasks registerTask reload reloadEnabled remoteControl remoteExec remoteExecCall remoteExecutedOwner remove3DENConnection remove3DENEventHandler remove3DENLayer removeAction removeAll3DENEventHandlers removeAllActions removeAllAssignedItems removeAllContainers removeAllCuratorAddons removeAllCuratorCameraAreas removeAllCuratorEditingAreas removeAllEventHandlers removeAllHandgunItems removeAllItems removeAllItemsWithMagazines removeAllMissionEventHandlers removeAllMPEventHandlers removeAllMusicEventHandlers removeAllOwnedMines removeAllPrimaryWeaponItems removeAllWeapons removeBackpack removeBackpackGlobal removeCuratorAddons removeCuratorCameraArea removeCuratorEditableObjects removeCuratorEditingArea removeDrawIcon removeDrawLinks removeEventHandler removeFromRemainsCollector removeGoggles removeGroupIcon removeHandgunItem removeHeadgear removeItem removeItemFromBackpack removeItemFromUniform removeItemFromVest removeItems removeMagazine removeMagazineGlobal removeMagazines removeMagazinesTurret removeMagazineTurret removeMenuItem removeMissionEventHandler removeMPEventHandler removeMusicEventHandler removeOwnedMine removePrimaryWeaponItem removeSecondaryWeaponItem removeSimpleTask removeSwitchableUnit removeTeamMember removeUniform removeVest removeWeapon removeWeaponAttachmentCargo removeWeaponCargo removeWeaponGlobal removeWeaponTurret reportRemoteTarget requiredVersion resetCamShake resetSubgroupDirection resize resources respawnVehicle restartEditorCamera reveal revealMine reverse reversedMouseY roadAt roadsConnectedTo roleDescription ropeAttachedObjects ropeAttachedTo ropeAttachEnabled ropeAttachTo ropeCreate ropeCut ropeDestroy ropeDetach ropeEndPosition ropeLength ropes ropeUnwind ropeUnwound rotorsForcesRTD rotorsRpmRTD round runInitScript safeZoneH safeZoneW safeZoneWAbs safeZoneX safeZoneXAbs safeZoneY save3DENInventory saveGame saveIdentity saveJoysticks saveOverlay saveProfileNamespace saveStatus saveVar savingEnabled say say2D say3D scopeName score scoreSide screenshot screenToWorld scriptDone scriptName scudState secondaryWeapon secondaryWeaponItems secondaryWeaponMagazine select selectBestPlaces selectDiarySubject selectedEditorObjects selectEditorObject selectionNames selectionPosition selectLeader selectMax selectMin selectNoPlayer selectPlayer selectRandom selectRandomWeighted selectWeapon selectWeaponTurret sendAUMessage sendSimpleCommand sendTask sendTaskResult sendUDPMessage serverCommand serverCommandAvailable serverCommandExecutable serverName serverTime set set3DENAttribute set3DENAttributes set3DENGrid set3DENIconsVisible set3DENLayer set3DENLinesVisible set3DENLogicType set3DENMissionAttribute set3DENMissionAttributes set3DENModelsVisible set3DENObjectType set3DENSelected setAccTime setActualCollectiveRTD setAirplaneThrottle setAirportSide setAmmo setAmmoCargo setAmmoOnPylon setAnimSpeedCoef setAperture setApertureNew setArmoryPoints setAttributes setAutonomous setBehaviour setBleedingRemaining setBrakesRTD setCameraInterest setCamShakeDefParams setCamShakeParams setCamUseTI setCaptive setCenterOfMass setCollisionLight setCombatMode setCompassOscillation setConvoySeparation setCuratorCameraAreaCeiling setCuratorCoef setCuratorEditingAreaType setCuratorWaypointCost setCurrentChannel setCurrentTask setCurrentWaypoint setCustomAimCoef setCustomWeightRTD setDamage setDammage setDate setDebriefingText setDefaultCamera setDestination setDetailMapBlendPars setDir setDirection setDrawIcon setDriveOnPath setDropInterval setDynamicSimulationDistance setDynamicSimulationDistanceCoef setEditorMode setEditorObjectScope setEffectCondition setEngineRPMRTD setFace setFaceAnimation setFatigue setFeatureType setFlagAnimationPhase setFlagOwner setFlagSide setFlagTexture setFog setFormation setFormationTask setFormDir setFriend setFromEditor setFSMVariable setFuel setFuelCargo setGroupIcon setGroupIconParams setGroupIconsSelectable setGroupIconsVisible setGroupId setGroupIdGlobal setGroupOwner setGusts setHideBehind setHit setHitIndex setHitPointDamage setHorizonParallaxCoef setHUDMovementLevels setIdentity setImportance setInfoPanel setLeader setLightAmbient setLightAttenuation setLightBrightness setLightColor setLightDayLight setLightFlareMaxDistance setLightFlareSize setLightIntensity setLightnings setLightUseFlare setLocalWindParams setMagazineTurretAmmo setMarkerAlpha setMarkerAlphaLocal setMarkerBrush setMarkerBrushLocal setMarkerColor setMarkerColorLocal setMarkerDir setMarkerDirLocal setMarkerPos setMarkerPosLocal setMarkerShape setMarkerShapeLocal setMarkerSize setMarkerSizeLocal setMarkerText setMarkerTextLocal setMarkerType setMarkerTypeLocal setMass setMimic setMousePosition setMusicEffect setMusicEventHandler setName setNameSound setObjectArguments setObjectMaterial setObjectMaterialGlobal setObjectProxy setObjectTexture setObjectTextureGlobal setObjectViewDistance setOvercast setOwner setOxygenRemaining setParticleCircle setParticleClass setParticleFire setParticleParams setParticleRandom setPilotCameraDirection setPilotCameraRotation setPilotCameraTarget setPilotLight setPiPEffect setPitch setPlateNumber setPlayable setPlayerRespawnTime setPos setPosASL setPosASL2 setPosASLW setPosATL setPosition setPosWorld setPylonLoadOut setPylonsPriority setRadioMsg setRain setRainbow setRandomLip setRank setRectangular setRepairCargo setRotorBrakeRTD setShadowDistance setShotParents setSide setSimpleTaskAlwaysVisible setSimpleTaskCustomData setSimpleTaskDescription setSimpleTaskDestination setSimpleTaskTarget setSimpleTaskType setSimulWeatherLayers setSize setSkill setSlingLoad setSoundEffect setSpeaker setSpeech setSpeedMode setStamina setStaminaScheme setStatValue setSuppression setSystemOfUnits setTargetAge setTaskMarkerOffset setTaskResult setTaskState setTerrainGrid setText setTimeMultiplier setTitleEffect setTrafficDensity setTrafficDistance setTrafficGap setTrafficSpeed setTriggerActivation setTriggerArea setTriggerStatements setTriggerText setTriggerTimeout setTriggerType setType setUnconscious setUnitAbility setUnitLoadout setUnitPos setUnitPosWeak setUnitRank setUnitRecoilCoefficient setUnitTrait setUnloadInCombat setUserActionText setUserMFDText setUserMFDvalue setVariable setVectorDir setVectorDirAndUp setVectorUp setVehicleAmmo setVehicleAmmoDef setVehicleArmor setVehicleCargo setVehicleId setVehicleLock setVehiclePosition setVehicleRadar setVehicleReceiveRemoteTargets setVehicleReportOwnPosition setVehicleReportRemoteTargets setVehicleTIPars setVehicleVarName setVelocity setVelocityModelSpace setVelocityTransformation setViewDistance setVisibleIfTreeCollapsed setWantedRPMRTD setWaves setWaypointBehaviour setWaypointCombatMode setWaypointCompletionRadius setWaypointDescription setWaypointForceBehaviour setWaypointFormation setWaypointHousePosition setWaypointLoiterRadius setWaypointLoiterType setWaypointName setWaypointPosition setWaypointScript setWaypointSpeed setWaypointStatements setWaypointTimeout setWaypointType setWaypointVisible setWeaponReloadingTime setWind setWindDir setWindForce setWindStr setWingForceScaleRTD setWPPos show3DIcons showChat showCinemaBorder showCommandingMenu showCompass showCuratorCompass showGPS showHUD showLegend showMap shownArtilleryComputer shownChat shownCompass shownCuratorCompass showNewEditorObject shownGPS shownHUD shownMap shownPad shownRadio shownScoretable shownUAVFeed shownWarrant shownWatch showPad showRadio showScoretable showSubtitles showUAVFeed showWarrant showWatch showWaypoint showWaypoints side sideChat sideEnemy sideFriendly sideRadio simpleTasks simulationEnabled simulCloudDensity simulCloudOcclusion simulInClouds simulWeatherSync sin size sizeOf skill skillFinal skipTime sleep sliderPosition sliderRange sliderSetPosition sliderSetRange sliderSetSpeed sliderSpeed slingLoadAssistantShown soldierMagazines someAmmo sort soundVolume spawn speaker speed speedMode splitString sqrt squadParams stance startLoadingScreen step stop stopEngineRTD stopped str sunOrMoon supportInfo suppressFor surfaceIsWater surfaceNormal surfaceType swimInDepth switchableUnits switchAction switchCamera switchGesture switchLight switchMove synchronizedObjects synchronizedTriggers synchronizedWaypoints synchronizeObjectsAdd synchronizeObjectsRemove synchronizeTrigger synchronizeWaypoint systemChat systemOfUnits tan targetKnowledge targets targetsAggregate targetsQuery taskAlwaysVisible taskChildren taskCompleted taskCustomData taskDescription taskDestination taskHint taskMarkerOffset taskParent taskResult taskState taskType teamMember teamName teams teamSwitch teamSwitchEnabled teamType terminate terrainIntersect terrainIntersectASL terrainIntersectAtASL text textLog textLogFormat tg time timeMultiplier titleCut titleFadeOut titleObj titleRsc titleText toArray toFixed toLower toString toUpper triggerActivated triggerActivation triggerArea triggerAttachedVehicle triggerAttachObject triggerAttachVehicle triggerDynamicSimulation triggerStatements triggerText triggerTimeout triggerTimeoutCurrent triggerType turretLocal turretOwner turretUnit tvAdd tvClear tvCollapse tvCollapseAll tvCount tvCurSel tvData tvDelete tvExpand tvExpandAll tvPicture tvSetColor tvSetCurSel tvSetData tvSetPicture tvSetPictureColor tvSetPictureColorDisabled tvSetPictureColorSelected tvSetPictureRight tvSetPictureRightColor tvSetPictureRightColorDisabled tvSetPictureRightColorSelected tvSetText tvSetTooltip tvSetValue tvSort tvSortByValue tvText tvTooltip tvValue type typeName typeOf UAVControl uiNamespace uiSleep unassignCurator unassignItem unassignTeam unassignVehicle underwater uniform uniformContainer uniformItems uniformMagazines unitAddons unitAimPosition unitAimPositionVisual unitBackpack unitIsUAV unitPos unitReady unitRecoilCoefficient units unitsBelowHeight unlinkItem unlockAchievement unregisterTask updateDrawIcon updateMenuItem updateObjectTree useAISteeringComponent useAudioTimeForMoves userInputDisabled vectorAdd vectorCos vectorCrossProduct vectorDiff vectorDir vectorDirVisual vectorDistance vectorDistanceSqr vectorDotProduct vectorFromTo vectorMagnitude vectorMagnitudeSqr vectorModelToWorld vectorModelToWorldVisual vectorMultiply vectorNormalized vectorUp vectorUpVisual vectorWorldToModel vectorWorldToModelVisual vehicle vehicleCargoEnabled vehicleChat vehicleRadio vehicleReceiveRemoteTargets vehicleReportOwnPosition vehicleReportRemoteTargets vehicles vehicleVarName velocity velocityModelSpace verifySignature vest vestContainer vestItems vestMagazines viewDistance visibleCompass visibleGPS visibleMap visiblePosition visiblePositionASL visibleScoretable visibleWatch waves waypointAttachedObject waypointAttachedVehicle waypointAttachObject waypointAttachVehicle waypointBehaviour waypointCombatMode waypointCompletionRadius waypointDescription waypointForceBehaviour waypointFormation waypointHousePosition waypointLoiterRadius waypointLoiterType waypointName waypointPosition waypoints waypointScript waypointsEnabledUAV waypointShow waypointSpeed waypointStatements waypointTimeout waypointTimeoutCurrent waypointType waypointVisible weaponAccessories weaponAccessoriesCargo weaponCargo weaponDirection weaponInertia weaponLowered weapons weaponsItems weaponsItemsCargo weaponState weaponsTurret weightRTD WFSideText wind ",
-literal:"blufor civilian configNull controlNull displayNull east endl false grpNull independent lineBreak locationNull nil objNull opfor pi resistance scriptNull sideAmbientLife sideEmpty sideLogic sideUnknown taskNull teamMemberNull true west"
-},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.NUMBER_MODE,{
-className:"variable",begin:/\b_+[a-zA-Z]\w*/},{className:"title",
-begin:/[a-zA-Z][a-zA-Z0-9]+_fnc_\w*/},t,a],illegal:/#|^\$ /}}})());
-hljs.registerLanguage("sql_more",(()=>{"use strict";return e=>{
-var t=e.COMMENT("--","$");return{name:"SQL (more)",aliases:["mysql","oracle"],
-disableAutodetect:!0,case_insensitive:!0,illegal:/[<>{}*]/,contains:[{
-beginKeywords:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup revoke comment values with",
-end:/;/,endsWithParent:!0,keywords:{$pattern:/[\w\.]+/,
-keyword:"as abort abs absolute acc acce accep accept access accessed accessible account acos action activate add addtime admin administer advanced advise aes_decrypt aes_encrypt after agent aggregate ali alia alias all allocate allow alter always analyze ancillary and anti any anydata anydataset anyschema anytype apply archive archived archivelog are as asc ascii asin assembly assertion associate asynchronous at atan atn2 attr attri attrib attribu attribut attribute attributes audit authenticated authentication authid authors auto autoallocate autodblink autoextend automatic availability avg backup badfile basicfile before begin beginning benchmark between bfile bfile_base big bigfile bin binary_double binary_float binlog bit_and bit_count bit_length bit_or bit_xor bitmap blob_base block blocksize body both bound bucket buffer_cache buffer_pool build bulk by byte byteordermark bytes cache caching call calling cancel capacity cascade cascaded case cast catalog category ceil ceiling chain change changed char_base char_length character_length characters characterset charindex charset charsetform charsetid check checksum checksum_agg child choose chr chunk class cleanup clear client clob clob_base clone close cluster_id cluster_probability cluster_set clustering coalesce coercibility col collate collation collect colu colum column column_value columns columns_updated comment commit compact compatibility compiled complete composite_limit compound compress compute concat concat_ws concurrent confirm conn connec connect connect_by_iscycle connect_by_isleaf connect_by_root connect_time connection consider consistent constant constraint constraints constructor container content contents context contributors controlfile conv convert convert_tz corr corr_k corr_s corresponding corruption cos cost count count_big counted covar_pop covar_samp cpu_per_call cpu_per_session crc32 create creation critical cross cube cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime customdatum cycle data database databases datafile datafiles datalength date_add date_cache date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts day day_to_second dayname dayofmonth dayofweek dayofyear days db_role_change dbtimezone ddl deallocate declare decode decompose decrement decrypt deduplicate def defa defau defaul default defaults deferred defi defin define degrees delayed delegate delete delete_all delimited demand dense_rank depth dequeue des_decrypt des_encrypt des_key_file desc descr descri describ describe descriptor deterministic diagnostics difference dimension direct_load directory disable disable_all disallow disassociate discardfile disconnect diskgroup distinct distinctrow distribute distributed div do document domain dotnet double downgrade drop dumpfile duplicate duration each edition editionable editions element ellipsis else elsif elt empty enable enable_all enclosed encode encoding encrypt end end-exec endian enforced engine engines enqueue enterprise entityescaping eomonth error errors escaped evalname evaluate event eventdata events except exception exceptions exchange exclude excluding execu execut execute exempt exists exit exp expire explain explode export export_set extended extent external external_1 external_2 externally extract failed failed_login_attempts failover failure far fast feature_set feature_value fetch field fields file file_name_convert filesystem_like_logging final finish first first_value fixed flash_cache flashback floor flush following follows for forall force foreign form forma format found found_rows freelist freelists freepools fresh from from_base64 from_days ftp full function general generated get get_format get_lock getdate getutcdate global global_name globally go goto grant grants greatest group group_concat group_id grouping grouping_id groups gtid_subtract guarantee guard handler hash hashkeys having hea head headi headin heading heap help hex hierarchy high high_priority hosts hour hours http id ident_current ident_incr ident_seed identified identity idle_time if ifnull ignore iif ilike ilm immediate import in include including increment index indexes indexing indextype indicator indices inet6_aton inet6_ntoa inet_aton inet_ntoa infile initial initialized initially initrans inmemory inner innodb input insert install instance instantiable instr interface interleaved intersect into invalidate invisible is is_free_lock is_ipv4 is_ipv4_compat is_not is_not_null is_used_lock isdate isnull isolation iterate java join json json_exists keep keep_duplicates key keys kill language large last last_day last_insert_id last_value lateral lax lcase lead leading least leaves left len lenght length less level levels library like like2 like4 likec limit lines link list listagg little ln load load_file lob lobs local localtime localtimestamp locate locator lock locked log log10 log2 logfile logfiles logging logical logical_reads_per_call logoff logon logs long loop low low_priority lower lpad lrtrim ltrim main make_set makedate maketime managed management manual map mapping mask master master_pos_wait match matched materialized max maxextents maximize maxinstances maxlen maxlogfiles maxloghistory maxlogmembers maxsize maxtrans md5 measures median medium member memcompress memory merge microsecond mid migration min minextents minimum mining minus minute minutes minvalue missing mod mode model modification modify module monitoring month months mount move movement multiset mutex name name_const names nan national native natural nav nchar nclob nested never new newline next nextval no no_write_to_binlog noarchivelog noaudit nobadfile nocheck nocompress nocopy nocycle nodelay nodiscardfile noentityescaping noguarantee nokeep nologfile nomapping nomaxvalue nominimize nominvalue nomonitoring none noneditionable nonschema noorder nopr nopro noprom nopromp noprompt norely noresetlogs noreverse normal norowdependencies noschemacheck noswitch not nothing notice notnull notrim novalidate now nowait nth_value nullif nulls num numb numbe nvarchar nvarchar2 object ocicoll ocidate ocidatetime ociduration ociinterval ociloblocator ocinumber ociref ocirefcursor ocirowid ocistring ocitype oct octet_length of off offline offset oid oidindex old on online only opaque open operations operator optimal optimize option optionally or oracle oracle_date oradata ord ordaudio orddicom orddoc order ordimage ordinality ordvideo organization orlany orlvary out outer outfile outline output over overflow overriding package pad parallel parallel_enable parameters parent parse partial partition partitions pascal passing password password_grace_time password_lock_time password_reuse_max password_reuse_time password_verify_function patch path patindex pctincrease pctthreshold pctused pctversion percent percent_rank percentile_cont percentile_disc performance period period_add period_diff permanent physical pi pipe pipelined pivot pluggable plugin policy position post_transaction pow power pragma prebuilt precedes preceding precision prediction prediction_cost prediction_details prediction_probability prediction_set prepare present preserve prior priority private private_sga privileges procedural procedure procedure_analyze processlist profiles project prompt protection public publishingservername purge quarter query quick quiesce quota quotename radians raise rand range rank raw read reads readsize rebuild record records recover recovery recursive recycle redo reduced ref reference referenced references referencing refresh regexp_like register regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy reject rekey relational relative relaylog release release_lock relies_on relocate rely rem remainder rename repair repeat replace replicate replication required reset resetlogs resize resource respect restore restricted result result_cache resumable resume retention return returning returns reuse reverse revoke right rlike role roles rollback rolling rollup round row row_count rowdependencies rowid rownum rows rtrim rules safe salt sample save savepoint sb1 sb2 sb4 scan schema schemacheck scn scope scroll sdo_georaster sdo_topo_geometry search sec_to_time second seconds section securefile security seed segment select self semi sequence sequential serializable server servererror session session_user sessions_per_user set sets settings sha sha1 sha2 share shared shared_pool short show shrink shutdown si_averagecolor si_colorhistogram si_featurelist si_positionalcolor si_stillimage si_texture siblings sid sign sin size size_t sizes skip slave sleep smalldatetimefromparts smallfile snapshot some soname sort soundex source space sparse spfile split sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_small_result sql_variant_property sqlcode sqldata sqlerror sqlname sqlstate sqrt square standalone standby start starting startup statement static statistics stats_binomial_test stats_crosstab stats_ks_test stats_mode stats_mw_test stats_one_way_anova stats_t_test_ stats_t_test_indep stats_t_test_one stats_t_test_paired stats_wsr_test status std stddev stddev_pop stddev_samp stdev stop storage store stored str str_to_date straight_join strcmp strict string struct stuff style subdate subpartition subpartitions substitutable substr substring subtime subtring_index subtype success sum suspend switch switchoffset switchover sync synchronous synonym sys sys_xmlagg sysasm sysaux sysdate sysdatetimeoffset sysdba sysoper system system_user sysutcdatetime table tables tablespace tablesample tan tdo template temporary terminated tertiary_weights test than then thread through tier ties time time_format time_zone timediff timefromparts timeout timestamp timestampadd timestampdiff timezone_abbr timezone_minute timezone_region to to_base64 to_date to_days to_seconds todatetimeoffset trace tracking transaction transactional translate translation treat trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse type ub1 ub2 ub4 ucase unarchived unbounded uncompress under undo unhex unicode uniform uninstall union unique unix_timestamp unknown unlimited unlock unnest unpivot unrecoverable unsafe unsigned until untrusted unusable unused update updated upgrade upped upper upsert url urowid usable usage use use_stored_outlines user user_data user_resources users using utc_date utc_timestamp uuid uuid_short validate validate_password_strength validation valist value values var var_samp varcharc vari varia variab variabl variable variables variance varp varraw varrawc varray verify version versions view virtual visible void wait wallet warning warnings week weekday weekofyear wellformed when whene whenev wheneve whenever where while whitespace window with within without work wrapped xdb xml xmlagg xmlattributes xmlcast xmlcolattval xmlelement xmlexists xmlforest xmlindex xmlnamespaces xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltype xor year year_to_month years yearweek",
-literal:"true false null unknown",
-built_in:"array bigint binary bit blob bool boolean char character date dec decimal float int int8 integer interval number numeric real record serial serial8 smallint text time timestamp tinyint varchar varchar2 varying void"
-},contains:[{className:"string",begin:"'",end:"'",contains:[{begin:"''"}]},{
-className:"string",begin:'"',end:'"',contains:[{begin:'""'}]},{
-className:"string",begin:"`",end:"`"
-},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,e.HASH_COMMENT_MODE]
-},e.C_BLOCK_COMMENT_MODE,t,e.HASH_COMMENT_MODE]}}})());
-hljs.registerLanguage("sql",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function r(...r){
-return r.map((r=>e(r))).join("")}function t(...r){
-return"("+r.map((r=>e(r))).join("|")+")"}return e=>{
-const n=e.COMMENT("--","$"),a=["true","false","unknown"],i=["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],s=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],o=["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"],c=s,l=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update   ","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!s.includes(e))),u={
-begin:r(/\b/,t(...c),/\s*\(/),keywords:{built_in:c}};return{name:"SQL",
-case_insensitive:!0,illegal:/[{}]|<\//,keywords:{$pattern:/\b[\w\.]+/,
-keyword:((e,{exceptions:r,when:t}={})=>{const n=t
-;return r=r||[],e.map((e=>e.match(/\|\d+$/)||r.includes(e)?e:n(e)?e+"|0":e))
-})(l,{when:e=>e.length<3}),literal:a,type:i,
-built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"]
-},contains:[{begin:t(...o),keywords:{$pattern:/[\w\.]+/,keyword:l.concat(o),
-literal:a,type:i}},{className:"type",
-begin:t("double precision","large object","with timezone","without timezone")
-},u,{className:"variable",begin:/@[a-z0-9]+/},{className:"string",variants:[{
-begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/,contains:[{
-begin:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,n,{className:"operator",
-begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}}})());
-hljs.registerLanguage("stan",(()=>{"use strict";return _=>({name:"Stan",
-aliases:["stanfuncs"],keywords:{$pattern:_.IDENT_RE,
-title:["functions","model","data","parameters","quantities","transformed","generated"],
-keyword:["for","in","if","else","while","break","continue","return"].concat(["int","real","vector","ordered","positive_ordered","simplex","unit_vector","row_vector","matrix","cholesky_factor_corr|10","cholesky_factor_cov|10","corr_matrix|10","cov_matrix|10","void"]).concat(["print","reject","increment_log_prob|10","integrate_ode|10","integrate_ode_rk45|10","integrate_ode_bdf|10","algebra_solver"]),
-built_in:["Phi","Phi_approx","abs","acos","acosh","algebra_solver","append_array","append_col","append_row","asin","asinh","atan","atan2","atanh","bernoulli_cdf","bernoulli_lccdf","bernoulli_lcdf","bernoulli_logit_lpmf","bernoulli_logit_rng","bernoulli_lpmf","bernoulli_rng","bessel_first_kind","bessel_second_kind","beta_binomial_cdf","beta_binomial_lccdf","beta_binomial_lcdf","beta_binomial_lpmf","beta_binomial_rng","beta_cdf","beta_lccdf","beta_lcdf","beta_lpdf","beta_rng","binary_log_loss","binomial_cdf","binomial_coefficient_log","binomial_lccdf","binomial_lcdf","binomial_logit_lpmf","binomial_lpmf","binomial_rng","block","categorical_logit_lpmf","categorical_logit_rng","categorical_lpmf","categorical_rng","cauchy_cdf","cauchy_lccdf","cauchy_lcdf","cauchy_lpdf","cauchy_rng","cbrt","ceil","chi_square_cdf","chi_square_lccdf","chi_square_lcdf","chi_square_lpdf","chi_square_rng","cholesky_decompose","choose","col","cols","columns_dot_product","columns_dot_self","cos","cosh","cov_exp_quad","crossprod","csr_extract_u","csr_extract_v","csr_extract_w","csr_matrix_times_vector","csr_to_dense_matrix","cumulative_sum","determinant","diag_matrix","diag_post_multiply","diag_pre_multiply","diagonal","digamma","dims","dirichlet_lpdf","dirichlet_rng","distance","dot_product","dot_self","double_exponential_cdf","double_exponential_lccdf","double_exponential_lcdf","double_exponential_lpdf","double_exponential_rng","e","eigenvalues_sym","eigenvectors_sym","erf","erfc","exp","exp2","exp_mod_normal_cdf","exp_mod_normal_lccdf","exp_mod_normal_lcdf","exp_mod_normal_lpdf","exp_mod_normal_rng","expm1","exponential_cdf","exponential_lccdf","exponential_lcdf","exponential_lpdf","exponential_rng","fabs","falling_factorial","fdim","floor","fma","fmax","fmin","fmod","frechet_cdf","frechet_lccdf","frechet_lcdf","frechet_lpdf","frechet_rng","gamma_cdf","gamma_lccdf","gamma_lcdf","gamma_lpdf","gamma_p","gamma_q","gamma_rng","gaussian_dlm_obs_lpdf","get_lp","gumbel_cdf","gumbel_lccdf","gumbel_lcdf","gumbel_lpdf","gumbel_rng","head","hypergeometric_lpmf","hypergeometric_rng","hypot","inc_beta","int_step","integrate_ode","integrate_ode_bdf","integrate_ode_rk45","inv","inv_Phi","inv_chi_square_cdf","inv_chi_square_lccdf","inv_chi_square_lcdf","inv_chi_square_lpdf","inv_chi_square_rng","inv_cloglog","inv_gamma_cdf","inv_gamma_lccdf","inv_gamma_lcdf","inv_gamma_lpdf","inv_gamma_rng","inv_logit","inv_sqrt","inv_square","inv_wishart_lpdf","inv_wishart_rng","inverse","inverse_spd","is_inf","is_nan","lbeta","lchoose","lgamma","lkj_corr_cholesky_lpdf","lkj_corr_cholesky_rng","lkj_corr_lpdf","lkj_corr_rng","lmgamma","lmultiply","log","log10","log1m","log1m_exp","log1m_inv_logit","log1p","log1p_exp","log2","log_determinant","log_diff_exp","log_falling_factorial","log_inv_logit","log_mix","log_rising_factorial","log_softmax","log_sum_exp","logistic_cdf","logistic_lccdf","logistic_lcdf","logistic_lpdf","logistic_rng","logit","lognormal_cdf","lognormal_lccdf","lognormal_lcdf","lognormal_lpdf","lognormal_rng","machine_precision","matrix_exp","max","mdivide_left_spd","mdivide_left_tri_low","mdivide_right_spd","mdivide_right_tri_low","mean","min","modified_bessel_first_kind","modified_bessel_second_kind","multi_gp_cholesky_lpdf","multi_gp_lpdf","multi_normal_cholesky_lpdf","multi_normal_cholesky_rng","multi_normal_lpdf","multi_normal_prec_lpdf","multi_normal_rng","multi_student_t_lpdf","multi_student_t_rng","multinomial_lpmf","multinomial_rng","multiply_log","multiply_lower_tri_self_transpose","neg_binomial_2_cdf","neg_binomial_2_lccdf","neg_binomial_2_lcdf","neg_binomial_2_log_lpmf","neg_binomial_2_log_rng","neg_binomial_2_lpmf","neg_binomial_2_rng","neg_binomial_cdf","neg_binomial_lccdf","neg_binomial_lcdf","neg_binomial_lpmf","neg_binomial_rng","negative_infinity","normal_cdf","normal_lccdf","normal_lcdf","normal_lpdf","normal_rng","not_a_number","num_elements","ordered_logistic_lpmf","ordered_logistic_rng","owens_t","pareto_cdf","pareto_lccdf","pareto_lcdf","pareto_lpdf","pareto_rng","pareto_type_2_cdf","pareto_type_2_lccdf","pareto_type_2_lcdf","pareto_type_2_lpdf","pareto_type_2_rng","pi","poisson_cdf","poisson_lccdf","poisson_lcdf","poisson_log_lpmf","poisson_log_rng","poisson_lpmf","poisson_rng","positive_infinity","pow","print","prod","qr_Q","qr_R","quad_form","quad_form_diag","quad_form_sym","rank","rayleigh_cdf","rayleigh_lccdf","rayleigh_lcdf","rayleigh_lpdf","rayleigh_rng","reject","rep_array","rep_matrix","rep_row_vector","rep_vector","rising_factorial","round","row","rows","rows_dot_product","rows_dot_self","scaled_inv_chi_square_cdf","scaled_inv_chi_square_lccdf","scaled_inv_chi_square_lcdf","scaled_inv_chi_square_lpdf","scaled_inv_chi_square_rng","sd","segment","sin","singular_values","sinh","size","skew_normal_cdf","skew_normal_lccdf","skew_normal_lcdf","skew_normal_lpdf","skew_normal_rng","softmax","sort_asc","sort_desc","sort_indices_asc","sort_indices_desc","sqrt","sqrt2","square","squared_distance","step","student_t_cdf","student_t_lccdf","student_t_lcdf","student_t_lpdf","student_t_rng","sub_col","sub_row","sum","tail","tan","tanh","target","tcrossprod","tgamma","to_array_1d","to_array_2d","to_matrix","to_row_vector","to_vector","trace","trace_gen_quad_form","trace_quad_form","trigamma","trunc","uniform_cdf","uniform_lccdf","uniform_lcdf","uniform_lpdf","uniform_rng","variance","von_mises_lpdf","von_mises_rng","weibull_cdf","weibull_lccdf","weibull_lcdf","weibull_lpdf","weibull_rng","wiener_lpdf","wishart_lpdf","wishart_rng"]
-},contains:[_.C_LINE_COMMENT_MODE,_.COMMENT(/#/,/$/,{relevance:0,keywords:{
-"meta-keyword":"include"}}),_.COMMENT(/\/\*/,/\*\//,{relevance:0,contains:[{
-className:"doctag",begin:/@(return|param)/}]}),{begin:/<\s*lower\s*=/,
-keywords:"lower"},{begin:/[<,]\s*upper\s*=/,keywords:"upper"},{
-className:"keyword",begin:/\btarget\s*\+=/,relevance:10},{
-begin:"~\\s*("+_.IDENT_RE+")\\s*\\(",
-keywords:["bernoulli","bernoulli_logit","beta","beta_binomial","binomial","binomial_logit","categorical","categorical_logit","cauchy","chi_square","dirichlet","double_exponential","exp_mod_normal","exponential","frechet","gamma","gaussian_dlm_obs","gumbel","hypergeometric","inv_chi_square","inv_gamma","inv_wishart","lkj_corr","lkj_corr_cholesky","logistic","lognormal","multi_gp","multi_gp_cholesky","multi_normal","multi_normal_cholesky","multi_normal_prec","multi_student_t","multinomial","neg_binomial","neg_binomial_2","neg_binomial_2_log","normal","ordered_logistic","pareto","pareto_type_2","poisson","poisson_log","rayleigh","scaled_inv_chi_square","skew_normal","student_t","uniform","von_mises","weibull","wiener","wishart"]
-},{className:"number",variants:[{begin:/\b\d+(?:\.\d*)?(?:[eE][+-]?\d+)?/},{
-begin:/\.\d+(?:[eE][+-]?\d+)?\b/}],relevance:0},{className:"string",begin:'"',
-end:'"',relevance:0}]})})());
-hljs.registerLanguage("stata",(()=>{"use strict";return e=>({name:"Stata",
-aliases:["do","ado"],case_insensitive:!0,
-keywords:"if else in foreach for forv forva forval forvalu forvalue forvalues by bys bysort xi quietly qui capture about ac ac_7 acprplot acprplot_7 adjust ado adopath adoupdate alpha ameans an ano anov anova anova_estat anova_terms anovadef aorder ap app appe appen append arch arch_dr arch_estat arch_p archlm areg areg_p args arima arima_dr arima_estat arima_p as asmprobit asmprobit_estat asmprobit_lf asmprobit_mfx__dlg asmprobit_p ass asse asser assert avplot avplot_7 avplots avplots_7 bcskew0 bgodfrey bias binreg bip0_lf biplot bipp_lf bipr_lf bipr_p biprobit bitest bitesti bitowt blogit bmemsize boot bootsamp bootstrap bootstrap_8 boxco_l boxco_p boxcox boxcox_6 boxcox_p bprobit br break brier bro brow brows browse brr brrstat bs bs_7 bsampl_w bsample bsample_7 bsqreg bstat bstat_7 bstat_8 bstrap bstrap_7 bubble bubbleplot ca ca_estat ca_p cabiplot camat canon canon_8 canon_8_p canon_estat canon_p cap caprojection capt captu captur capture cat cc cchart cchart_7 cci cd censobs_table centile cf char chdir checkdlgfiles checkestimationsample checkhlpfiles checksum chelp ci cii cl class classutil clear cli clis clist clo clog clog_lf clog_p clogi clogi_sw clogit clogit_lf clogit_p clogitp clogl_sw cloglog clonevar clslistarray cluster cluster_measures cluster_stop cluster_tree cluster_tree_8 clustermat cmdlog cnr cnre cnreg cnreg_p cnreg_sw cnsreg codebook collaps4 collapse colormult_nb colormult_nw compare compress conf confi confir confirm conren cons const constr constra constrai constrain constraint continue contract copy copyright copysource cor corc corr corr2data corr_anti corr_kmo corr_smc corre correl correla correlat correlate corrgram cou coun count cox cox_p cox_sw coxbase coxhaz coxvar cprplot cprplot_7 crc cret cretu cretur creturn cross cs cscript cscript_log csi ct ct_is ctset ctst_5 ctst_st cttost cumsp cumsp_7 cumul cusum cusum_7 cutil d|0 datasig datasign datasigna datasignat datasignatu datasignatur datasignature datetof db dbeta de dec deco decod decode deff des desc descr descri describ describe destring dfbeta dfgls dfuller di di_g dir dirstats dis discard disp disp_res disp_s displ displa display distinct do doe doed doedi doedit dotplot dotplot_7 dprobit drawnorm drop ds ds_util dstdize duplicates durbina dwstat dydx e|0 ed edi edit egen eivreg emdef en enc enco encod encode eq erase ereg ereg_lf ereg_p ereg_sw ereghet ereghet_glf ereghet_glf_sh ereghet_gp ereghet_ilf ereghet_ilf_sh ereghet_ip eret eretu eretur ereturn err erro error esize est est_cfexist est_cfname est_clickable est_expand est_hold est_table est_unhold est_unholdok estat estat_default estat_summ estat_vce_only esti estimates etodow etof etomdy ex exi exit expand expandcl fac fact facto factor factor_estat factor_p factor_pca_rotated factor_rotate factormat fcast fcast_compute fcast_graph fdades fdadesc fdadescr fdadescri fdadescrib fdadescribe fdasav fdasave fdause fh_st file open file read file close file filefilter fillin find_hlp_file findfile findit findit_7 fit fl fli flis flist for5_0 forest forestplot form forma format fpredict frac_154 frac_adj frac_chk frac_cox frac_ddp frac_dis frac_dv frac_in frac_mun frac_pp frac_pq frac_pv frac_wgt frac_xo fracgen fracplot fracplot_7 fracpoly fracpred fron_ex fron_hn fron_p fron_tn fron_tn2 frontier ftodate ftoe ftomdy ftowdate funnel funnelplot g|0 gamhet_glf gamhet_gp gamhet_ilf gamhet_ip gamma gamma_d2 gamma_p gamma_sw gammahet gdi_hexagon gdi_spokes ge gen gene gener genera generat generate genrank genstd genvmean gettoken gl gladder gladder_7 glim_l01 glim_l02 glim_l03 glim_l04 glim_l05 glim_l06 glim_l07 glim_l08 glim_l09 glim_l10 glim_l11 glim_l12 glim_lf glim_mu glim_nw1 glim_nw2 glim_nw3 glim_p glim_v1 glim_v2 glim_v3 glim_v4 glim_v5 glim_v6 glim_v7 glm glm_6 glm_p glm_sw glmpred glo glob globa global glogit glogit_8 glogit_p gmeans gnbre_lf gnbreg gnbreg_5 gnbreg_p gomp_lf gompe_sw gomper_p gompertz gompertzhet gomphet_glf gomphet_glf_sh gomphet_gp gomphet_ilf gomphet_ilf_sh gomphet_ip gphdot gphpen gphprint gprefs gprobi_p gprobit gprobit_8 gr gr7 gr_copy gr_current gr_db gr_describe gr_dir gr_draw gr_draw_replay gr_drop gr_edit gr_editviewopts gr_example gr_example2 gr_export gr_print gr_qscheme gr_query gr_read gr_rename gr_replay gr_save gr_set gr_setscheme gr_table gr_undo gr_use graph graph7 grebar greigen greigen_7 greigen_8 grmeanby grmeanby_7 gs_fileinfo gs_filetype gs_graphinfo gs_stat gsort gwood h|0 hadimvo hareg hausman haver he heck_d2 heckma_p heckman heckp_lf heckpr_p heckprob hel help hereg hetpr_lf hetpr_p hetprob hettest hexdump hilite hist hist_7 histogram hlogit hlu hmeans hotel hotelling hprobit hreg hsearch icd9 icd9_ff icd9p iis impute imtest inbase include inf infi infil infile infix inp inpu input ins insheet insp inspe inspec inspect integ inten intreg intreg_7 intreg_p intrg2_ll intrg_ll intrg_ll2 ipolate iqreg ir irf irf_create irfm iri is_svy is_svysum isid istdize ivprob_1_lf ivprob_lf ivprobit ivprobit_p ivreg ivreg_footnote ivtob_1_lf ivtob_lf ivtobit ivtobit_p jackknife jacknife jknife jknife_6 jknife_8 jkstat joinby kalarma1 kap kap_3 kapmeier kappa kapwgt kdensity kdensity_7 keep ksm ksmirnov ktau kwallis l|0 la lab labbe labbeplot labe label labelbook ladder levels levelsof leverage lfit lfit_p li lincom line linktest lis list lloghet_glf lloghet_glf_sh lloghet_gp lloghet_ilf lloghet_ilf_sh lloghet_ip llogi_sw llogis_p llogist llogistic llogistichet lnorm_lf lnorm_sw lnorma_p lnormal lnormalhet lnormhet_glf lnormhet_glf_sh lnormhet_gp lnormhet_ilf lnormhet_ilf_sh lnormhet_ip lnskew0 loadingplot loc loca local log logi logis_lf logistic logistic_p logit logit_estat logit_p loglogs logrank loneway lookfor lookup lowess lowess_7 lpredict lrecomp lroc lroc_7 lrtest ls lsens lsens_7 lsens_x lstat ltable ltable_7 ltriang lv lvr2plot lvr2plot_7 m|0 ma mac macr macro makecns man manova manova_estat manova_p manovatest mantel mark markin markout marksample mat mat_capp mat_order mat_put_rr mat_rapp mata mata_clear mata_describe mata_drop mata_matdescribe mata_matsave mata_matuse mata_memory mata_mlib mata_mosave mata_rename mata_which matalabel matcproc matlist matname matr matri matrix matrix_input__dlg matstrik mcc mcci md0_ md1_ md1debug_ md2_ md2debug_ mds mds_estat mds_p mdsconfig mdslong mdsmat mdsshepard mdytoe mdytof me_derd mean means median memory memsize menl meqparse mer merg merge meta mfp mfx mhelp mhodds minbound mixed_ll mixed_ll_reparm mkassert mkdir mkmat mkspline ml ml_5 ml_adjs ml_bhhhs ml_c_d ml_check ml_clear ml_cnt ml_debug ml_defd ml_e0 ml_e0_bfgs ml_e0_cycle ml_e0_dfp ml_e0i ml_e1 ml_e1_bfgs ml_e1_bhhh ml_e1_cycle ml_e1_dfp ml_e2 ml_e2_cycle ml_ebfg0 ml_ebfr0 ml_ebfr1 ml_ebh0q ml_ebhh0 ml_ebhr0 ml_ebr0i ml_ecr0i ml_edfp0 ml_edfr0 ml_edfr1 ml_edr0i ml_eds ml_eer0i ml_egr0i ml_elf ml_elf_bfgs ml_elf_bhhh ml_elf_cycle ml_elf_dfp ml_elfi ml_elfs ml_enr0i ml_enrr0 ml_erdu0 ml_erdu0_bfgs ml_erdu0_bhhh ml_erdu0_bhhhq ml_erdu0_cycle ml_erdu0_dfp ml_erdu0_nrbfgs ml_exde ml_footnote ml_geqnr ml_grad0 ml_graph ml_hbhhh ml_hd0 ml_hold ml_init ml_inv ml_log ml_max ml_mlout ml_mlout_8 ml_model ml_nb0 ml_opt ml_p ml_plot ml_query ml_rdgrd ml_repor ml_s_e ml_score ml_searc ml_technique ml_unhold mleval mlf_ mlmatbysum mlmatsum mlog mlogi mlogit mlogit_footnote mlogit_p mlopts mlsum mlvecsum mnl0_ mor more mov move mprobit mprobit_lf mprobit_p mrdu0_ mrdu1_ mvdecode mvencode mvreg mvreg_estat n|0 nbreg nbreg_al nbreg_lf nbreg_p nbreg_sw nestreg net newey newey_7 newey_p news nl nl_7 nl_9 nl_9_p nl_p nl_p_7 nlcom nlcom_p nlexp2 nlexp2_7 nlexp2a nlexp2a_7 nlexp3 nlexp3_7 nlgom3 nlgom3_7 nlgom4 nlgom4_7 nlinit nllog3 nllog3_7 nllog4 nllog4_7 nlog_rd nlogit nlogit_p nlogitgen nlogittree nlpred no nobreak noi nois noisi noisil noisily note notes notes_dlg nptrend numlabel numlist odbc old_ver olo olog ologi ologi_sw ologit ologit_p ologitp on one onew onewa oneway op_colnm op_comp op_diff op_inv op_str opr opro oprob oprob_sw oprobi oprobi_p oprobit oprobitp opts_exclusive order orthog orthpoly ou out outf outfi outfil outfile outs outsh outshe outshee outsheet ovtest pac pac_7 palette parse parse_dissim pause pca pca_8 pca_display pca_estat pca_p pca_rotate pcamat pchart pchart_7 pchi pchi_7 pcorr pctile pentium pergram pergram_7 permute permute_8 personal peto_st pkcollapse pkcross pkequiv pkexamine pkexamine_7 pkshape pksumm pksumm_7 pl plo plot plugin pnorm pnorm_7 poisgof poiss_lf poiss_sw poisso_p poisson poisson_estat post postclose postfile postutil pperron pr prais prais_e prais_e2 prais_p predict predictnl preserve print pro prob probi probit probit_estat probit_p proc_time procoverlay procrustes procrustes_estat procrustes_p profiler prog progr progra program prop proportion prtest prtesti pwcorr pwd q\\s qby qbys qchi qchi_7 qladder qladder_7 qnorm qnorm_7 qqplot qqplot_7 qreg qreg_c qreg_p qreg_sw qu quadchk quantile quantile_7 que quer query range ranksum ratio rchart rchart_7 rcof recast reclink recode reg reg3 reg3_p regdw regr regre regre_p2 regres regres_p regress regress_estat regriv_p remap ren rena renam rename renpfix repeat replace report reshape restore ret retu retur return rm rmdir robvar roccomp roccomp_7 roccomp_8 rocf_lf rocfit rocfit_8 rocgold rocplot rocplot_7 roctab roctab_7 rolling rologit rologit_p rot rota rotat rotate rotatemat rreg rreg_p ru run runtest rvfplot rvfplot_7 rvpplot rvpplot_7 sa safesum sample sampsi sav save savedresults saveold sc sca scal scala scalar scatter scm_mine sco scob_lf scob_p scobi_sw scobit scor score scoreplot scoreplot_help scree screeplot screeplot_help sdtest sdtesti se search separate seperate serrbar serrbar_7 serset set set_defaults sfrancia sh she shel shell shewhart shewhart_7 signestimationsample signrank signtest simul simul_7 simulate simulate_8 sktest sleep slogit slogit_d2 slogit_p smooth snapspan so sor sort spearman spikeplot spikeplot_7 spikeplt spline_x split sqreg sqreg_p sret sretu sretur sreturn ssc st st_ct st_hc st_hcd st_hcd_sh st_is st_issys st_note st_promo st_set st_show st_smpl st_subid stack statsby statsby_8 stbase stci stci_7 stcox stcox_estat stcox_fr stcox_fr_ll stcox_p stcox_sw stcoxkm stcoxkm_7 stcstat stcurv stcurve stcurve_7 stdes stem stepwise stereg stfill stgen stir stjoin stmc stmh stphplot stphplot_7 stphtest stphtest_7 stptime strate strate_7 streg streg_sw streset sts sts_7 stset stsplit stsum sttocc sttoct stvary stweib su suest suest_8 sum summ summa summar summari summariz summarize sunflower sureg survcurv survsum svar svar_p svmat svy svy_disp svy_dreg svy_est svy_est_7 svy_estat svy_get svy_gnbreg_p svy_head svy_header svy_heckman_p svy_heckprob_p svy_intreg_p svy_ivreg_p svy_logistic_p svy_logit_p svy_mlogit_p svy_nbreg_p svy_ologit_p svy_oprobit_p svy_poisson_p svy_probit_p svy_regress_p svy_sub svy_sub_7 svy_x svy_x_7 svy_x_p svydes svydes_8 svygen svygnbreg svyheckman svyheckprob svyintreg svyintreg_7 svyintrg svyivreg svylc svylog_p svylogit svymarkout svymarkout_8 svymean svymlog svymlogit svynbreg svyolog svyologit svyoprob svyoprobit svyopts svypois svypois_7 svypoisson svyprobit svyprobt svyprop svyprop_7 svyratio svyreg svyreg_p svyregress svyset svyset_7 svyset_8 svytab svytab_7 svytest svytotal sw sw_8 swcnreg swcox swereg swilk swlogis swlogit swologit swoprbt swpois swprobit swqreg swtobit swweib symmetry symmi symplot symplot_7 syntax sysdescribe sysdir sysuse szroeter ta tab tab1 tab2 tab_or tabd tabdi tabdis tabdisp tabi table tabodds tabodds_7 tabstat tabu tabul tabula tabulat tabulate te tempfile tempname tempvar tes test testnl testparm teststd tetrachoric time_it timer tis tob tobi tobit tobit_p tobit_sw token tokeni tokeniz tokenize tostring total translate translator transmap treat_ll treatr_p treatreg trim trimfill trnb_cons trnb_mean trpoiss_d2 trunc_ll truncr_p truncreg tsappend tset tsfill tsline tsline_ex tsreport tsrevar tsrline tsset tssmooth tsunab ttest ttesti tut_chk tut_wait tutorial tw tware_st two twoway twoway__fpfit_serset twoway__function_gen twoway__histogram_gen twoway__ipoint_serset twoway__ipoints_serset twoway__kdensity_gen twoway__lfit_serset twoway__normgen_gen twoway__pci_serset twoway__qfit_serset twoway__scatteri_serset twoway__sunflower_gen twoway_ksm_serset ty typ type typeof u|0 unab unabbrev unabcmd update us use uselabel var var_mkcompanion var_p varbasic varfcast vargranger varirf varirf_add varirf_cgraph varirf_create varirf_ctable varirf_describe varirf_dir varirf_drop varirf_erase varirf_graph varirf_ograph varirf_rename varirf_set varirf_table varlist varlmar varnorm varsoc varstable varstable_w varstable_w2 varwle vce vec vec_fevd vec_mkphi vec_p vec_p_w vecirf_create veclmar veclmar_w vecnorm vecnorm_w vecrank vecstable verinst vers versi versio version view viewsource vif vwls wdatetof webdescribe webseek webuse weib1_lf weib2_lf weib_lf weib_lf0 weibhet_glf weibhet_glf_sh weibhet_glfa weibhet_glfa_sh weibhet_gp weibhet_ilf weibhet_ilf_sh weibhet_ilfa weibhet_ilfa_sh weibhet_ip weibu_sw weibul_p weibull weibull_c weibull_s weibullhet wh whelp whi which whil while wilc_st wilcoxon win wind windo window winexec wntestb wntestb_7 wntestq xchart xchart_7 xcorr xcorr_7 xi xi_6 xmlsav xmlsave xmluse xpose xsh xshe xshel xshell xt_iis xt_tis xtab_p xtabond xtbin_p xtclog xtcloglog xtcloglog_8 xtcloglog_d2 xtcloglog_pa_p xtcloglog_re_p xtcnt_p xtcorr xtdata xtdes xtfront_p xtfrontier xtgee xtgee_elink xtgee_estat xtgee_makeivar xtgee_p xtgee_plink xtgls xtgls_p xthaus xthausman xtht_p xthtaylor xtile xtint_p xtintreg xtintreg_8 xtintreg_d2 xtintreg_p xtivp_1 xtivp_2 xtivreg xtline xtline_ex xtlogit xtlogit_8 xtlogit_d2 xtlogit_fe_p xtlogit_pa_p xtlogit_re_p xtmixed xtmixed_estat xtmixed_p xtnb_fe xtnb_lf xtnbreg xtnbreg_pa_p xtnbreg_refe_p xtpcse xtpcse_p xtpois xtpoisson xtpoisson_d2 xtpoisson_pa_p xtpoisson_refe_p xtpred xtprobit xtprobit_8 xtprobit_d2 xtprobit_re_p xtps_fe xtps_lf xtps_ren xtps_ren_8 xtrar_p xtrc xtrc_p xtrchh xtrefe_p xtreg xtreg_be xtreg_fe xtreg_ml xtreg_pa_p xtreg_re xtregar xtrere_p xtset xtsf_ll xtsf_llti xtsum xttab xttest0 xttobit xttobit_8 xttobit_p xttrans yx yxview__barlike_draw yxview_area_draw yxview_bar_draw yxview_dot_draw yxview_dropline_draw yxview_function_draw yxview_iarrow_draw yxview_ilabels_draw yxview_normal_draw yxview_pcarrow_draw yxview_pcbarrow_draw yxview_pccapsym_draw yxview_pcscatter_draw yxview_pcspike_draw yxview_rarea_draw yxview_rbar_draw yxview_rbarm_draw yxview_rcap_draw yxview_rcapsym_draw yxview_rconnected_draw yxview_rline_draw yxview_rscatter_draw yxview_rspike_draw yxview_spike_draw yxview_sunflower_draw zap_s zinb zinb_llf zinb_plf zip zip_llf zip_p zip_plf zt_ct_5 zt_hc_5 zt_hcd_5 zt_is_5 zt_iss_5 zt_sho_5 zt_smp_5 ztbase_5 ztcox_5 ztdes_5 ztereg_5 ztfill_5 ztgen_5 ztir_5 ztjoin_5 ztnb ztnb_p ztp ztp_p zts_5 ztset_5 ztspli_5 ztsum_5 zttoct_5 ztvary_5 ztweib_5",
-contains:[{className:"symbol",begin:/`[a-zA-Z0-9_]+'/},{className:"variable",
-begin:/\$\{?[a-zA-Z0-9_]+\}?/},{className:"string",variants:[{
-begin:'`"[^\r\n]*?"\''},{begin:'"[^\r\n"]*"'}]},{className:"built_in",
-variants:[{
-begin:"\\b(abs|acos|asin|atan|atan2|atanh|ceil|cloglog|comb|cos|digamma|exp|floor|invcloglog|invlogit|ln|lnfact|lnfactorial|lngamma|log|log10|max|min|mod|reldif|round|sign|sin|sqrt|sum|tan|tanh|trigamma|trunc|betaden|Binomial|binorm|binormal|chi2|chi2tail|dgammapda|dgammapdada|dgammapdadx|dgammapdx|dgammapdxdx|F|Fden|Ftail|gammaden|gammap|ibeta|invbinomial|invchi2|invchi2tail|invF|invFtail|invgammap|invibeta|invnchi2|invnFtail|invnibeta|invnorm|invnormal|invttail|nbetaden|nchi2|nFden|nFtail|nibeta|norm|normal|normalden|normd|npnchi2|tden|ttail|uniform|abbrev|char|index|indexnot|length|lower|ltrim|match|plural|proper|real|regexm|regexr|regexs|reverse|rtrim|string|strlen|strlower|strltrim|strmatch|strofreal|strpos|strproper|strreverse|strrtrim|strtrim|strupper|subinstr|subinword|substr|trim|upper|word|wordcount|_caller|autocode|byteorder|chop|clip|cond|e|epsdouble|epsfloat|group|inlist|inrange|irecode|matrix|maxbyte|maxdouble|maxfloat|maxint|maxlong|mi|minbyte|mindouble|minfloat|minint|minlong|missing|r|recode|replay|return|s|scalar|d|date|day|dow|doy|halfyear|mdy|month|quarter|week|year|d|daily|dofd|dofh|dofm|dofq|dofw|dofy|h|halfyearly|hofd|m|mofd|monthly|q|qofd|quarterly|tin|twithin|w|weekly|wofd|y|yearly|yh|ym|yofd|yq|yw|cholesky|colnumb|colsof|corr|det|diag|diag0cnt|el|get|hadamard|I|inv|invsym|issym|issymmetric|J|matmissing|matuniform|mreldif|nullmat|rownumb|rowsof|sweep|syminv|trace|vec|vecdiag)(?=\\()"
-}]},e.COMMENT("^[ \t]*\\*.*$",!1),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]
-})})());
-hljs.registerLanguage("step21",(()=>{"use strict";return e=>({
-name:"STEP Part 21",aliases:["p21","step","stp"],case_insensitive:!0,keywords:{
-$pattern:"[A-Z_][A-Z0-9_.]*",keyword:"HEADER ENDSEC DATA"},contains:[{
-className:"meta",begin:"ISO-10303-21;",relevance:10},{className:"meta",
-begin:"END-ISO-10303-21;",relevance:10
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.COMMENT("/\\*\\*!","\\*/"),e.C_NUMBER_MODE,e.inherit(e.APOS_STRING_MODE,{
-illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{
-className:"string",begin:"'",end:"'"},{className:"symbol",variants:[{begin:"#",
-end:"\\d+",illegal:"\\W"}]}]})})());
-hljs.registerLanguage("stylus",(()=>{"use strict"
-;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],o=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],i=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],r=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse()
-;return n=>{const a=(e=>({IMPORTANT:{className:"meta",begin:"!important"},
-HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"},
-ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/,
-illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}}))(n),s={
-className:"variable",begin:"\\$"+n.IDENT_RE},l="(?=[.\\s\\n[:,(])";return{
-name:"Stylus",aliases:["styl"],case_insensitive:!1,keywords:"if else for in",
-illegal:"(\\?|(\\bReturn\\b)|(\\bEnd\\b)|(\\bend\\b)|(\\bdef\\b)|;|#\\s|\\*\\s|===\\s|\\||%)",
-contains:[n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,a.HEXCOLOR,{
-begin:"\\.[a-zA-Z][a-zA-Z0-9_-]*(?=[.\\s\\n[:,(])",className:"selector-class"},{
-begin:"#[a-zA-Z][a-zA-Z0-9_-]*(?=[.\\s\\n[:,(])",className:"selector-id"},{
-begin:"\\b("+e.join("|")+")"+l,className:"selector-tag"},{
-className:"selector-pseudo",begin:"&?:("+o.join("|")+")"+l},{
-className:"selector-pseudo",begin:"&?::("+i.join("|")+")"+l
-},a.ATTRIBUTE_SELECTOR_MODE,{className:"keyword",begin:/@media/,starts:{
-end:/[{;}]/,keywords:{$pattern:/[a-z-]+/,keyword:"and or not only",
-attribute:t.join(" ")},contains:[n.CSS_NUMBER_MODE]}},{className:"keyword",
-begin:"@((-(o|moz|ms|webkit)-)?(charset|css|debug|extend|font-face|for|import|include|keyframes|media|mixin|page|warn|while))\\b"
-},s,n.CSS_NUMBER_MODE,{className:"function",
-begin:"^[a-zA-Z][a-zA-Z0-9_-]*\\(.*\\)",illegal:"[\\n]",returnBegin:!0,
-contains:[{className:"title",begin:"\\b[a-zA-Z][a-zA-Z0-9_-]*"},{
-className:"params",begin:/\(/,end:/\)/,
-contains:[a.HEXCOLOR,s,n.APOS_STRING_MODE,n.CSS_NUMBER_MODE,n.QUOTE_STRING_MODE]
-}]},{className:"attribute",begin:"\\b("+r.join("|")+")\\b",starts:{end:/;|$/,
-contains:[a.HEXCOLOR,s,n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,n.CSS_NUMBER_MODE,n.C_BLOCK_COMMENT_MODE,a.IMPORTANT],
-illegal:/\./,relevance:0}}]}}})());
-hljs.registerLanguage("subunit",(()=>{"use strict";return s=>({name:"SubUnit",
-case_insensitive:!0,contains:[{className:"string",begin:"\\[\n(multipart)?",
-end:"\\]\n"},{className:"string",
-begin:"\\d{4}-\\d{2}-\\d{2}(\\s+)\\d{2}:\\d{2}:\\d{2}.\\d+Z"},{
-className:"string",begin:"(\\+|-)\\d+"},{className:"keyword",relevance:10,
-variants:[{
-begin:"^(test|testing|success|successful|failure|error|skip|xfail|uxsuccess)(:?)\\s+(test)?"
-},{begin:"^progress(:?)(\\s+)?(pop|push)?"},{begin:"^tags:"},{begin:"^time:"}]}]
-})})());
-hljs.registerLanguage("swift",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(e){return a("(?=",e,")")}
-function a(...n){return n.map((n=>e(n))).join("")}function t(...n){
-return"("+n.map((n=>e(n))).join("|")+")"}
-const i=e=>a(/\b/,e,/\w$/.test(e)?/\b/:/\B/),s=["Protocol","Type"].map(i),u=["init","self"].map(i),c=["Any","Self"],r=["associatedtype","async","await",/as\?/,/as!/,"as","break","case","catch","class","continue","convenience","default","defer","deinit","didSet","do","dynamic","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","lazy","let","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],o=["false","nil","true"],l=["assignment","associativity","higherThan","left","lowerThan","none","right"],m=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warn_unqualified_access","#warning"],d=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],p=t(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),F=t(p,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),b=a(p,F,"*"),h=t(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),f=t(h,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),w=a(h,f,"*"),y=a(/[A-Z]/,f,"*"),g=["autoclosure",a(/convention\(/,t("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",a(/objc\(/,w,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","testable","UIApplicationMain","unknown","usableFromInline"],E=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"]
-;return e=>{const p={match:/\s+/,relevance:0},h=e.COMMENT("/\\*","\\*/",{
-contains:["self"]}),v=[e.C_LINE_COMMENT_MODE,h],N={className:"keyword",
-begin:a(/\./,n(t(...s,...u))),end:t(...s,...u),excludeBegin:!0},A={
-match:a(/\./,t(...r)),relevance:0
-},C=r.filter((e=>"string"==typeof e)).concat(["_|0"]),_={variants:[{
-className:"keyword",
-match:t(...r.filter((e=>"string"!=typeof e)).concat(c).map(i),...u)}]},D={
-$pattern:t(/\b\w+/,/#\w+/),keyword:C.concat(m),literal:o},B=[N,A,_],k=[{
-match:a(/\./,t(...d)),relevance:0},{className:"built_in",
-match:a(/\b/,t(...d),/(?=\()/)}],M={match:/->/,relevance:0},S=[M,{
-className:"operator",relevance:0,variants:[{match:b},{match:`\\.(\\.|${F})+`}]
-}],x="([0-9a-fA-F]_*)+",I={className:"number",relevance:0,variants:[{
-match:"\\b(([0-9]_*)+)(\\.(([0-9]_*)+))?([eE][+-]?(([0-9]_*)+))?\\b"},{
-match:`\\b0x(${x})(\\.(${x}))?([pP][+-]?(([0-9]_*)+))?\\b`},{
-match:/\b0o([0-7]_*)+\b/},{match:/\b0b([01]_*)+\b/}]},O=(e="")=>({
-className:"subst",variants:[{match:a(/\\/,e,/[0\\tnr"']/)},{
-match:a(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}]}),T=(e="")=>({className:"subst",
-match:a(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/)}),L=(e="")=>({className:"subst",
-label:"interpol",begin:a(/\\/,e,/\(/),end:/\)/}),P=(e="")=>({begin:a(e,/"""/),
-end:a(/"""/,e),contains:[O(e),T(e),L(e)]}),$=(e="")=>({begin:a(e,/"/),
-end:a(/"/,e),contains:[O(e),L(e)]}),K={className:"string",
-variants:[P(),P("#"),P("##"),P("###"),$(),$("#"),$("##"),$("###")]},j={
-match:a(/`/,w,/`/)},z=[j,{className:"variable",match:/\$\d+/},{
-className:"variable",match:`\\$${f}+`}],q=[{match:/(@|#)available/,
-className:"keyword",starts:{contains:[{begin:/\(/,end:/\)/,keywords:E,
-contains:[...S,I,K]}]}},{className:"keyword",match:a(/@/,t(...g))},{
-className:"meta",match:a(/@/,w)}],U={match:n(/\b[A-Z]/),relevance:0,contains:[{
-className:"type",
-match:a(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,f,"+")
-},{className:"type",match:y,relevance:0},{match:/[?!]+/,relevance:0},{
-match:/\.\.\./,relevance:0},{match:a(/\s+&\s+/,n(y)),relevance:0}]},Z={
-begin:/</,end:/>/,keywords:D,contains:[...v,...B,...q,M,U]};U.contains.push(Z)
-;const G={begin:/\(/,end:/\)/,relevance:0,keywords:D,contains:["self",{
-match:a(w,/\s*:/),keywords:"_|0",relevance:0
-},...v,...B,...k,...S,I,K,...z,...q,U]},H={beginKeywords:"func",contains:[{
-className:"title",match:t(j.match,w,b),endsParent:!0,relevance:0},p]},R={
-begin:/</,end:/>/,contains:[...v,U]},V={begin:/\(/,end:/\)/,keywords:D,
-contains:[{begin:t(n(a(w,/\s*:/)),n(a(w,/\s+/,w,/\s*:/))),end:/:/,relevance:0,
-contains:[{className:"keyword",match:/\b_\b/},{className:"params",match:w}]
-},...v,...B,...S,I,K,...q,U,G],endsParent:!0,illegal:/["']/},W={
-className:"function",match:n(/\bfunc\b/),contains:[H,R,V,p],illegal:[/\[/,/%/]
-},X={className:"function",match:/\b(subscript|init[?!]?)\s*(?=[<(])/,keywords:{
-keyword:"subscript init init? init!",$pattern:/\w+[?!]?/},contains:[R,V,p],
-illegal:/\[|%/},J={beginKeywords:"operator",end:e.MATCH_NOTHING_RE,contains:[{
-className:"title",match:b,endsParent:!0,relevance:0}]},Q={
-beginKeywords:"precedencegroup",end:e.MATCH_NOTHING_RE,contains:[{
-className:"title",match:y,relevance:0},{begin:/{/,end:/}/,relevance:0,
-endsParent:!0,keywords:[...l,...o],contains:[U]}]};for(const e of K.variants){
-const n=e.contains.find((e=>"interpol"===e.label));n.keywords=D
-;const a=[...B,...k,...S,I,K,...z];n.contains=[...a,{begin:/\(/,end:/\)/,
-contains:["self",...a]}]}return{name:"Swift",keywords:D,contains:[...v,W,X,{
-className:"class",beginKeywords:"struct protocol class extension enum",
-end:"\\{",excludeEnd:!0,keywords:D,contains:[e.inherit(e.TITLE_MODE,{
-begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/}),...B]},J,Q,{
-beginKeywords:"import",end:/$/,contains:[...v],relevance:0
-},...B,...k,...S,I,K,...z,...q,U,G]}}})());
-hljs.registerLanguage("taggerscript",(()=>{"use strict";return e=>({
-name:"Tagger Script",contains:[{className:"comment",begin:/\$noop\(/,end:/\)/,
-contains:[{begin:/\(/,end:/\)/,contains:["self",{begin:/\\./}]}],relevance:10},{
-className:"keyword",begin:/\$(?!noop)[a-zA-Z][_a-zA-Z0-9]*/,end:/\(/,
-excludeEnd:!0},{className:"variable",begin:/%[_a-zA-Z0-9:]*/,end:"%"},{
-className:"symbol",begin:/\\./}]})})());
-hljs.registerLanguage("yaml",(()=>{"use strict";return e=>{
-var n="true false yes no null",a="[\\w#;/?:@&=+$,.~*'()[\\]]+",s={
-className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/
-},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",
-variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(s,{
-variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),l={
-end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},t={begin:/\{/,
-end:/\}/,contains:[l],illegal:"\\n",relevance:0},g={begin:"\\[",end:"\\]",
-contains:[l],illegal:"\\n",relevance:0},b=[{className:"attr",variants:[{
-begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{
-begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$",
-relevance:10},{className:"string",
-begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{
-begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,
-relevance:0},{className:"type",begin:"!\\w+!"+a},{className:"type",
-begin:"!<"+a+">"},{className:"type",begin:"!"+a},{className:"type",begin:"!!"+a
-},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta",
-begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)",
-relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{
-className:"number",
-begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"
-},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},t,g,s],r=[...b]
-;return r.pop(),r.push(i),l.contains=r,{name:"YAML",case_insensitive:!0,
-aliases:["yml"],contains:b}}})());
-hljs.registerLanguage("tap",(()=>{"use strict";return e=>({
-name:"Test Anything Protocol",case_insensitive:!0,
-contains:[e.HASH_COMMENT_MODE,{className:"meta",variants:[{
-begin:"^TAP version (\\d+)$"},{begin:"^1\\.\\.(\\d+)$"}]},{begin:/---$/,
-end:"\\.\\.\\.$",subLanguage:"yaml",relevance:0},{className:"number",
-begin:" (\\d+) "},{className:"symbol",variants:[{begin:"^ok"},{begin:"^not ok"}]
-}]})})());
-hljs.registerLanguage("tcl",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(a=e)?"string"==typeof a?a:a.source:null;var a
-})).join("")}return a=>{const t=/[a-zA-Z_][a-zA-Z0-9_]*/,r={className:"number",
-variants:[a.BINARY_NUMBER_MODE,a.C_NUMBER_MODE]};return{name:"Tcl",
-aliases:["tk"],
-keywords:"after append apply array auto_execok auto_import auto_load auto_mkindex auto_mkindex_old auto_qualify auto_reset bgerror binary break catch cd chan clock close concat continue dde dict encoding eof error eval exec exit expr fblocked fconfigure fcopy file fileevent filename flush for foreach format gets glob global history http if incr info interp join lappend|10 lassign|10 lindex|10 linsert|10 list llength|10 load lrange|10 lrepeat|10 lreplace|10 lreverse|10 lsearch|10 lset|10 lsort|10 mathfunc mathop memory msgcat namespace open package parray pid pkg::create pkg_mkIndex platform platform::shell proc puts pwd read refchan regexp registry regsub|10 rename return safe scan seek set socket source split string subst switch tcl_endOfWord tcl_findLibrary tcl_startOfNextWord tcl_startOfPreviousWord tcl_wordBreakAfter tcl_wordBreakBefore tcltest tclvars tell time tm trace unknown unload unset update uplevel upvar variable vwait while",
-contains:[a.COMMENT(";[ \\t]*#","$"),a.COMMENT("^[ \\t]*#","$"),{
-beginKeywords:"proc",end:"[\\{]",excludeEnd:!0,contains:[{className:"title",
-begin:"[ \\t\\n\\r]+(::)?[a-zA-Z_]((::)?[a-zA-Z0-9_])*",end:"[ \\t\\n\\r]",
-endsWithParent:!0,excludeEnd:!0}]},{className:"variable",variants:[{
-begin:e(/\$/,(n=/::/,e("(",n,")?")),t,"(::",t,")*")},{
-begin:"\\$\\{(::)?[a-zA-Z_]((::)?[a-zA-Z0-9_])*",end:"\\}",contains:[r]}]},{
-className:"string",contains:[a.BACKSLASH_ESCAPE],
-variants:[a.inherit(a.QUOTE_STRING_MODE,{illegal:null})]},r]};var n}})());
-hljs.registerLanguage("thrift",(()=>{"use strict";return e=>{
-const t="bool byte i16 i32 i64 double string binary";return{name:"Thrift",
-keywords:{
-keyword:"namespace const typedef struct enum service exception void oneway set list map required optional",
-built_in:t,literal:"true false"},
-contains:[e.QUOTE_STRING_MODE,e.NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-className:"class",beginKeywords:"struct enum service exception",end:/\{/,
-illegal:/\n/,contains:[e.inherit(e.TITLE_MODE,{starts:{endsWithParent:!0,
-excludeEnd:!0}})]},{begin:"\\b(set|list|map)\\s*<",end:">",keywords:t,
-contains:["self"]}]}}})());
-hljs.registerLanguage("tp",(()=>{"use strict";return O=>{const e={
-className:"number",begin:"[1-9][0-9]*",relevance:0},R={className:"symbol",
-begin:":[^\\]]+"};return{name:"TP",keywords:{
-keyword:"ABORT ACC ADJUST AND AP_LD BREAK CALL CNT COL CONDITION CONFIG DA DB DIV DETECT ELSE END ENDFOR ERR_NUM ERROR_PROG FINE FOR GP GUARD INC IF JMP LINEAR_MAX_SPEED LOCK MOD MONITOR OFFSET Offset OR OVERRIDE PAUSE PREG PTH RT_LD RUN SELECT SKIP Skip TA TB TO TOOL_OFFSET Tool_Offset UF UT UFRAME_NUM UTOOL_NUM UNLOCK WAIT X Y Z W P R STRLEN SUBSTR FINDSTR VOFFSET PROG ATTR MN POS",
-literal:"ON OFF max_speed LPOS JPOS ENABLE DISABLE START STOP RESET"},
-contains:[{className:"built_in",
-begin:"(AR|P|PAYLOAD|PR|R|SR|RSR|LBL|VR|UALM|MESSAGE|UTOOL|UFRAME|TIMER|TIMER_OVERFLOW|JOINT_MAX_SPEED|RESUME_PROG|DIAG_REC)\\[",
-end:"\\]",contains:["self",e,R]},{className:"built_in",
-begin:"(AI|AO|DI|DO|F|RI|RO|UI|UO|GI|GO|SI|SO)\\[",end:"\\]",
-contains:["self",e,O.QUOTE_STRING_MODE,R]},{className:"keyword",
-begin:"/(PROG|ATTR|MN|POS|END)\\b"},{className:"keyword",
-begin:"(CALL|RUN|POINT_LOGIC|LBL)\\b"},{className:"keyword",
-begin:"\\b(ACC|CNT|Skip|Offset|PSPD|RT_LD|AP_LD|Tool_Offset)"},{
-className:"number",
-begin:"\\d+(sec|msec|mm/sec|cm/min|inch/min|deg/sec|mm|in|cm)?\\b",relevance:0
-},O.COMMENT("//","[;$]"),O.COMMENT("!","[;$]"),O.COMMENT("--eg:","$"),O.QUOTE_STRING_MODE,{
-className:"string",begin:"'",end:"'"},O.C_NUMBER_MODE,{className:"variable",
-begin:"\\$[A-Za-z0-9_]+"}]}}})());
-hljs.registerLanguage("twig",(()=>{"use strict";return e=>{
-var a="attribute block constant cycle date dump include max min parent random range source template_from_string",n={
-beginKeywords:a,keywords:{name:a},relevance:0,contains:[{className:"params",
-begin:"\\(",end:"\\)"}]},t={begin:/\|[A-Za-z_]+:?/,
-keywords:"abs batch capitalize column convert_encoding date date_modify default escape filter first format inky_to_html inline_css join json_encode keys last length lower map markdown merge nl2br number_format raw reduce replace reverse round slice sort spaceless split striptags title trim upper url_encode",
-contains:[n]
-},s="apply autoescape block deprecated do embed extends filter flush for from if import include macro sandbox set use verbatim with"
-;return s=s+" "+s.split(" ").map((e=>"end"+e)).join(" "),{name:"Twig",
-aliases:["craftcms"],case_insensitive:!0,subLanguage:"xml",
-contains:[e.COMMENT(/\{#/,/#\}/),{className:"template-tag",begin:/\{%/,
-end:/%\}/,contains:[{className:"name",begin:/\w+/,keywords:s,starts:{
-endsWithParent:!0,contains:[t,n],relevance:0}}]},{className:"template-variable",
-begin:/\{\{/,end:/\}\}/,contains:["self",t,n]}]}}})());
-hljs.registerLanguage("typescript",(()=>{"use strict"
-;const e="[A-Za-z$_][0-9A-Za-z$_]*",n=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],a=["true","false","null","undefined","NaN","Infinity"],s=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"])
-;function t(e){return r("(?=",e,")")}function r(...e){return e.map((e=>{
-return(n=e)?"string"==typeof n?n:n.source:null;var n})).join("")}return i=>{
-const c={$pattern:e,
-keyword:n.concat(["type","namespace","typedef","interface","public","private","protected","implements","declare","abstract","readonly"]),
-literal:a,
-built_in:s.concat(["any","void","number","boolean","string","object","never","enum"])
-},o={className:"meta",begin:"@[A-Za-z$_][0-9A-Za-z$_]*"},l=(e,n,a)=>{
-const s=e.contains.findIndex((e=>e.label===n))
-;if(-1===s)throw Error("can not find mode to replace");e.contains.splice(s,1,a)
-},b=(i=>{const c=e,o={begin:/<[A-Za-z0-9\\._:-]+/,
-end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{
-const a=e[0].length+e.index,s=e.input[a];"<"!==s?">"===s&&(((e,{after:n})=>{
-const a="</"+e[0].slice(1);return-1!==e.input.indexOf(a,n)})(e,{after:a
-})||n.ignoreMatch()):n.ignoreMatch()}},l={$pattern:e,keyword:n,literal:a,
-built_in:s},b="\\.([0-9](_?[0-9])*)",d="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",g={
-className:"number",variants:[{
-begin:`(\\b(${d})((${b})|\\.)?|(${b}))[eE][+-]?([0-9](_?[0-9])*)\\b`},{
-begin:`\\b(${d})\\b((${b})\\b|\\.)?|(${b})\\b`},{
-begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{
-begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{
-begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{
-begin:"\\b0[0-7]+n?\\b"}],relevance:0},u={className:"subst",begin:"\\$\\{",
-end:"\\}",keywords:l,contains:[]},E={begin:"html`",end:"",starts:{end:"`",
-returnEnd:!1,contains:[i.BACKSLASH_ESCAPE,u],subLanguage:"xml"}},m={
-begin:"css`",end:"",starts:{end:"`",returnEnd:!1,
-contains:[i.BACKSLASH_ESCAPE,u],subLanguage:"css"}},y={className:"string",
-begin:"`",end:"`",contains:[i.BACKSLASH_ESCAPE,u]},_={className:"comment",
-variants:[i.COMMENT(/\/\*\*(?!\/)/,"\\*/",{relevance:0,contains:[{
-className:"doctag",begin:"@[A-Za-z]+",contains:[{className:"type",begin:"\\{",
-end:"\\}",relevance:0},{className:"variable",begin:c+"(?=\\s*(-)|$)",
-endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}]
-}),i.C_BLOCK_COMMENT_MODE,i.C_LINE_COMMENT_MODE]
-},p=[i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,E,m,y,g,i.REGEXP_MODE]
-;u.contains=p.concat({begin:/\{/,end:/\}/,keywords:l,contains:["self"].concat(p)
-});const N=[].concat(_,u.contains),f=N.concat([{begin:/\(/,end:/\)/,keywords:l,
-contains:["self"].concat(N)}]),A={className:"params",begin:/\(/,end:/\)/,
-excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f};return{name:"Javascript",
-aliases:["js","jsx","mjs","cjs"],keywords:l,exports:{PARAMS_CONTAINS:f},
-illegal:/#(?![$_A-z])/,contains:[i.SHEBANG({label:"shebang",binary:"node",
-relevance:5}),{label:"use_strict",className:"meta",relevance:10,
-begin:/^\s*['"]use (strict|asm)['"]/
-},i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,E,m,y,_,g,{
-begin:r(/[{,\n]\s*/,t(r(/(((\/\/.*$)|(\/\*(\*[^/]|[^*])*\*\/))\s*)*/,c+"\\s*:"))),
-relevance:0,contains:[{className:"attr",begin:c+t("\\s*:"),relevance:0}]},{
-begin:"("+i.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",
-keywords:"return throw case",contains:[_,i.REGEXP_MODE,{className:"function",
-begin:"(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+i.UNDERSCORE_IDENT_RE+")\\s*=>",
-returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{
-begin:i.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0
-},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f}]}]
-},{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{
-variants:[{begin:"<>",end:"</>"},{begin:o.begin,"on:begin":o.isTrulyOpeningTag,
-end:o.end}],subLanguage:"xml",contains:[{begin:o.begin,end:o.end,skip:!0,
-contains:["self"]}]}],relevance:0},{className:"function",
-beginKeywords:"function",end:/[{;]/,excludeEnd:!0,keywords:l,
-contains:["self",i.inherit(i.TITLE_MODE,{begin:c}),A],illegal:/%/},{
-beginKeywords:"while if switch catch for"},{className:"function",
-begin:i.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",
-returnBegin:!0,contains:[A,i.inherit(i.TITLE_MODE,{begin:c})]},{variants:[{
-begin:"\\."+c},{begin:"\\$"+c}],relevance:0},{className:"class",
-beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"[\]]/,contains:[{
-beginKeywords:"extends"},i.UNDERSCORE_TITLE_MODE]},{begin:/\b(?=constructor)/,
-end:/[{;]/,excludeEnd:!0,contains:[i.inherit(i.TITLE_MODE,{begin:c}),"self",A]
-},{begin:"(get|set)\\s+(?="+c+"\\()",end:/\{/,keywords:"get set",
-contains:[i.inherit(i.TITLE_MODE,{begin:c}),{begin:/\(\)/},A]},{begin:/\$[(.]/}]
-}})(i)
-;return Object.assign(b.keywords,c),b.exports.PARAMS_CONTAINS.push(o),b.contains=b.contains.concat([o,{
-beginKeywords:"namespace",end:/\{/,excludeEnd:!0},{beginKeywords:"interface",
-end:/\{/,excludeEnd:!0,keywords:"interface extends"
-}]),l(b,"shebang",i.SHEBANG()),l(b,"use_strict",{className:"meta",relevance:10,
-begin:/^\s*['"]use strict['"]/
-}),b.contains.find((e=>"function"===e.className)).relevance=0,Object.assign(b,{
-name:"TypeScript",aliases:["ts","tsx"]}),b}})());
-hljs.registerLanguage("vala",(()=>{"use strict";return e=>({name:"Vala",
-keywords:{
-keyword:"char uchar unichar int uint long ulong short ushort int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double bool struct enum string void weak unowned owned async signal static abstract interface override virtual delegate if while do for foreach else switch case break default return try catch public private protected internal using new this get set const stdout stdin stderr var",
-built_in:"DBus GLib CCode Gee Object Gtk Posix",literal:"false true null"},
-contains:[{className:"class",beginKeywords:"class interface namespace",end:/\{/,
-excludeEnd:!0,illegal:"[^,:\\n\\s\\.]",contains:[e.UNDERSCORE_TITLE_MODE]
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",begin:'"""',
-end:'"""',relevance:5},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"^#",end:"$",relevance:2}]})})());
-hljs.registerLanguage("vbnet",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}function t(...n){
-return"("+n.map((n=>e(n))).join("|")+")"}return e=>{
-const a=/\d{1,2}\/\d{1,2}\/\d{4}/,i=/\d{4}-\d{1,2}-\d{1,2}/,s=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,r=/\d{1,2}(:\d{1,2}){1,2}/,o={
-className:"literal",variants:[{begin:n(/# */,t(i,a),/ *#/)},{
-begin:n(/# */,r,/ *#/)},{begin:n(/# */,s,/ *#/)},{
-begin:n(/# */,t(i,a),/ +/,t(s,r),/ *#/)}]},l=e.COMMENT(/'''/,/$/,{contains:[{
-className:"doctag",begin:/<\/?/,end:/>/}]}),c=e.COMMENT(null,/$/,{variants:[{
-begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]});return{name:"Visual Basic .NET",
-aliases:["vb"],case_insensitive:!0,classNameAliases:{label:"symbol"},keywords:{
-keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield",
-built_in:"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort",
-type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort",
-literal:"true false nothing"},
-illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{
-className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/,
-end:/"/,illegal:/\n/,contains:[{begin:/""/}]},o,{className:"number",relevance:0,
-variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/
-},{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{
-begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{
-className:"label",begin:/^\w+:/},l,c,{className:"meta",
-begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/,
-end:/$/,keywords:{
-"meta-keyword":"const disable else elseif enable end externalsource if region then"
-},contains:[c]}]}}})());
-hljs.registerLanguage("vbscript",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function t(...t){
-return t.map((t=>e(t))).join("")}function r(...t){
-return"("+t.map((t=>e(t))).join("|")+")"}return e=>{
-const i="lcase month vartype instrrev ubound setlocale getobject rgb getref string weekdayname rnd dateadd monthname now day minute isarray cbool round formatcurrency conversions csng timevalue second year space abs clng timeserial fixs len asc isempty maths dateserial atn timer isobject filter weekday datevalue ccur isdate instr datediff formatdatetime replace isnull right sgn array snumeric log cdbl hex chr lbound msgbox ucase getlocale cos cdate cbyte rtrim join hour oct typename trim strcomp int createobject loadpicture tan formatnumber mid split  cint sin datepart ltrim sqr time derived eval date formatpercent exp inputbox left ascw chrw regexp cstr err".split(" ")
-;return{name:"VBScript",aliases:["vbs"],case_insensitive:!0,keywords:{
-keyword:"call class const dim do loop erase execute executeglobal exit for each next function if then else on error option explicit new private property let get public randomize redim rem select case set stop sub while wend with end to elseif is or xor and not class_initialize class_terminate default preserve in me byval byref step resume goto",
-built_in:["server","response","request","scriptengine","scriptenginebuildversion","scriptengineminorversion","scriptenginemajorversion"],
-literal:"true false null nothing empty"},illegal:"//",contains:[{
-begin:t(r(...i),"\\s*\\("),relevance:0,keywords:{built_in:i}
-},e.inherit(e.QUOTE_STRING_MODE,{contains:[{begin:'""'}]}),e.COMMENT(/'/,/$/,{
-relevance:0}),e.C_NUMBER_MODE]}}})());
-hljs.registerLanguage("vbscript-html",(()=>{"use strict";return e=>({
-name:"VBScript in HTML",subLanguage:"xml",contains:[{begin:"<%",end:"%>",
-subLanguage:"vbscript"}]})})());
-hljs.registerLanguage("verilog",(()=>{"use strict";return e=>({name:"Verilog",
-aliases:["v","sv","svh"],case_insensitive:!1,keywords:{$pattern:/[\w\$]+/,
-keyword:"accept_on alias always always_comb always_ff always_latch and assert assign assume automatic before begin bind bins binsof bit break buf|0 bufif0 bufif1 byte case casex casez cell chandle checker class clocking cmos config const constraint context continue cover covergroup coverpoint cross deassign default defparam design disable dist do edge else end endcase endchecker endclass endclocking endconfig endfunction endgenerate endgroup endinterface endmodule endpackage endprimitive endprogram endproperty endspecify endsequence endtable endtask enum event eventually expect export extends extern final first_match for force foreach forever fork forkjoin function generate|5 genvar global highz0 highz1 if iff ifnone ignore_bins illegal_bins implements implies import incdir include initial inout input inside instance int integer interconnect interface intersect join join_any join_none large let liblist library local localparam logic longint macromodule matches medium modport module nand negedge nettype new nexttime nmos nor noshowcancelled not notif0 notif1 or output package packed parameter pmos posedge primitive priority program property protected pull0 pull1 pulldown pullup pulsestyle_ondetect pulsestyle_onevent pure rand randc randcase randsequence rcmos real realtime ref reg reject_on release repeat restrict return rnmos rpmos rtran rtranif0 rtranif1 s_always s_eventually s_nexttime s_until s_until_with scalared sequence shortint shortreal showcancelled signed small soft solve specify specparam static string strong strong0 strong1 struct super supply0 supply1 sync_accept_on sync_reject_on table tagged task this throughout time timeprecision timeunit tran tranif0 tranif1 tri tri0 tri1 triand trior trireg type typedef union unique unique0 unsigned until until_with untyped use uwire var vectored virtual void wait wait_order wand weak weak0 weak1 while wildcard wire with within wor xnor xor",
-literal:"null",
-built_in:"$finish $stop $exit $fatal $error $warning $info $realtime $time $printtimescale $bitstoreal $bitstoshortreal $itor $signed $cast $bits $stime $timeformat $realtobits $shortrealtobits $rtoi $unsigned $asserton $assertkill $assertpasson $assertfailon $assertnonvacuouson $assertoff $assertcontrol $assertpassoff $assertfailoff $assertvacuousoff $isunbounded $sampled $fell $changed $past_gclk $fell_gclk $changed_gclk $rising_gclk $steady_gclk $coverage_control $coverage_get $coverage_save $set_coverage_db_name $rose $stable $past $rose_gclk $stable_gclk $future_gclk $falling_gclk $changing_gclk $display $coverage_get_max $coverage_merge $get_coverage $load_coverage_db $typename $unpacked_dimensions $left $low $increment $clog2 $ln $log10 $exp $sqrt $pow $floor $ceil $sin $cos $tan $countbits $onehot $isunknown $fatal $warning $dimensions $right $high $size $asin $acos $atan $atan2 $hypot $sinh $cosh $tanh $asinh $acosh $atanh $countones $onehot0 $error $info $random $dist_chi_square $dist_erlang $dist_exponential $dist_normal $dist_poisson $dist_t $dist_uniform $q_initialize $q_remove $q_exam $async$and$array $async$nand$array $async$or$array $async$nor$array $sync$and$array $sync$nand$array $sync$or$array $sync$nor$array $q_add $q_full $psprintf $async$and$plane $async$nand$plane $async$or$plane $async$nor$plane $sync$and$plane $sync$nand$plane $sync$or$plane $sync$nor$plane $system $display $displayb $displayh $displayo $strobe $strobeb $strobeh $strobeo $write $readmemb $readmemh $writememh $value$plusargs $dumpvars $dumpon $dumplimit $dumpports $dumpportson $dumpportslimit $writeb $writeh $writeo $monitor $monitorb $monitorh $monitoro $writememb $dumpfile $dumpoff $dumpall $dumpflush $dumpportsoff $dumpportsall $dumpportsflush $fclose $fdisplay $fdisplayb $fdisplayh $fdisplayo $fstrobe $fstrobeb $fstrobeh $fstrobeo $swrite $swriteb $swriteh $swriteo $fscanf $fread $fseek $fflush $feof $fopen $fwrite $fwriteb $fwriteh $fwriteo $fmonitor $fmonitorb $fmonitorh $fmonitoro $sformat $sformatf $fgetc $ungetc $fgets $sscanf $rewind $ftell $ferror"
-},contains:[e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE,e.QUOTE_STRING_MODE,{
-className:"number",contains:[e.BACKSLASH_ESCAPE],variants:[{
-begin:"\\b((\\d+'(b|h|o|d|B|H|O|D))[0-9xzXZa-fA-F_]+)"},{
-begin:"\\B(('(b|h|o|d|B|H|O|D))[0-9xzXZa-fA-F_]+)"},{begin:"\\b([0-9_])+",
-relevance:0}]},{className:"variable",variants:[{begin:"#\\((?!parameter).+\\)"
-},{begin:"\\.\\w+",relevance:0}]},{className:"meta",begin:"`",end:"$",keywords:{
-"meta-keyword":"define __FILE__ __LINE__ begin_keywords celldefine default_nettype define else elsif end_keywords endcelldefine endif ifdef ifndef include line nounconnected_drive pragma resetall timescale unconnected_drive undef undefineall"
-},relevance:0}]})})());
-hljs.registerLanguage("vhdl",(()=>{"use strict";return e=>({name:"VHDL",
-case_insensitive:!0,keywords:{
-keyword:"abs access after alias all and architecture array assert assume assume_guarantee attribute begin block body buffer bus case component configuration constant context cover disconnect downto default else elsif end entity exit fairness file for force function generate generic group guarded if impure in inertial inout is label library linkage literal loop map mod nand new next nor not null of on open or others out package parameter port postponed procedure process property protected pure range record register reject release rem report restrict restrict_guarantee return rol ror select sequence severity shared signal sla sll sra srl strong subtype then to transport type unaffected units until use variable view vmode vprop vunit wait when while with xnor xor",
-built_in:"boolean bit character integer time delay_length natural positive string bit_vector file_open_kind file_open_status std_logic std_logic_vector unsigned signed boolean_vector integer_vector std_ulogic std_ulogic_vector unresolved_unsigned u_unsigned unresolved_signed u_signed real_vector time_vector",
-literal:"false true note warning error failure line text side width"},
-illegal:/\{/,
-contains:[e.C_BLOCK_COMMENT_MODE,e.COMMENT("--","$"),e.QUOTE_STRING_MODE,{
-className:"number",
-begin:"\\b(\\d(_|\\d)*#\\w+(\\.\\w+)?#([eE][-+]?\\d(_|\\d)*)?|\\d(_|\\d)*(\\.\\d(_|\\d)*)?([eE][-+]?\\d(_|\\d)*)?)",
-relevance:0},{className:"string",begin:"'(U|X|0|1|Z|W|L|H|-)'",
-contains:[e.BACKSLASH_ESCAPE]},{className:"symbol",
-begin:"'[A-Za-z](_?[A-Za-z0-9])*",contains:[e.BACKSLASH_ESCAPE]}]})})());
-hljs.registerLanguage("vim",(()=>{"use strict";return e=>({name:"Vim Script",
-keywords:{$pattern:/[!#@\w]+/,
-keyword:"N|0 P|0 X|0 a|0 ab abc abo al am an|0 ar arga argd arge argdo argg argl argu as au aug aun b|0 bN ba bad bd be bel bf bl bm bn bo bp br brea breaka breakd breakl bro bufdo buffers bun bw c|0 cN cNf ca cabc caddb cad caddf cal cat cb cc ccl cd ce cex cf cfir cgetb cgete cg changes chd che checkt cl cla clo cm cmapc cme cn cnew cnf cno cnorea cnoreme co col colo com comc comp con conf cope cp cpf cq cr cs cst cu cuna cunme cw delm deb debugg delc delf dif diffg diffo diffp diffpu diffs diffthis dig di dl dell dj dli do doautoa dp dr ds dsp e|0 ea ec echoe echoh echom echon el elsei em en endfo endf endt endw ene ex exe exi exu f|0 files filet fin fina fini fir fix fo foldc foldd folddoc foldo for fu go gr grepa gu gv ha helpf helpg helpt hi hid his ia iabc if ij il im imapc ime ino inorea inoreme int is isp iu iuna iunme j|0 ju k|0 keepa kee keepj lN lNf l|0 lad laddb laddf la lan lat lb lc lch lcl lcs le lefta let lex lf lfir lgetb lgete lg lgr lgrepa lh ll lla lli lmak lm lmapc lne lnew lnf ln loadk lo loc lockv lol lope lp lpf lr ls lt lu lua luad luaf lv lvimgrepa lw m|0 ma mak map mapc marks mat me menut mes mk mks mksp mkv mkvie mod mz mzf nbc nb nbs new nm nmapc nme nn nnoreme noa no noh norea noreme norm nu nun nunme ol o|0 om omapc ome on ono onoreme opt ou ounme ow p|0 profd prof pro promptr pc ped pe perld po popu pp pre prev ps pt ptN ptf ptj ptl ptn ptp ptr pts pu pw py3 python3 py3d py3f py pyd pyf quita qa rec red redi redr redraws reg res ret retu rew ri rightb rub rubyd rubyf rund ru rv sN san sa sal sav sb sbN sba sbf sbl sbm sbn sbp sbr scrip scripte scs se setf setg setl sf sfir sh sim sig sil sl sla sm smap smapc sme sn sni sno snor snoreme sor so spelld spe spelli spellr spellu spellw sp spr sre st sta startg startr star stopi stj sts sun sunm sunme sus sv sw sy synti sync tN tabN tabc tabdo tabe tabf tabfir tabl tabm tabnew tabn tabo tabp tabr tabs tab ta tags tc tcld tclf te tf th tj tl tm tn to tp tr try ts tu u|0 undoj undol una unh unl unlo unm unme uns up ve verb vert vim vimgrepa vi viu vie vm vmapc vme vne vn vnoreme vs vu vunme windo w|0 wN wa wh wi winc winp wn wp wq wqa ws wu wv x|0 xa xmapc xm xme xn xnoreme xu xunme y|0 z|0 ~ Next Print append abbreviate abclear aboveleft all amenu anoremenu args argadd argdelete argedit argglobal arglocal argument ascii autocmd augroup aunmenu buffer bNext ball badd bdelete behave belowright bfirst blast bmodified bnext botright bprevious brewind break breakadd breakdel breaklist browse bunload bwipeout change cNext cNfile cabbrev cabclear caddbuffer caddexpr caddfile call catch cbuffer cclose center cexpr cfile cfirst cgetbuffer cgetexpr cgetfile chdir checkpath checktime clist clast close cmap cmapclear cmenu cnext cnewer cnfile cnoremap cnoreabbrev cnoremenu copy colder colorscheme command comclear compiler continue confirm copen cprevious cpfile cquit crewind cscope cstag cunmap cunabbrev cunmenu cwindow delete delmarks debug debuggreedy delcommand delfunction diffupdate diffget diffoff diffpatch diffput diffsplit digraphs display deletel djump dlist doautocmd doautoall deletep drop dsearch dsplit edit earlier echo echoerr echohl echomsg else elseif emenu endif endfor endfunction endtry endwhile enew execute exit exusage file filetype find finally finish first fixdel fold foldclose folddoopen folddoclosed foldopen function global goto grep grepadd gui gvim hardcopy help helpfind helpgrep helptags highlight hide history insert iabbrev iabclear ijump ilist imap imapclear imenu inoremap inoreabbrev inoremenu intro isearch isplit iunmap iunabbrev iunmenu join jumps keepalt keepmarks keepjumps lNext lNfile list laddexpr laddbuffer laddfile last language later lbuffer lcd lchdir lclose lcscope left leftabove lexpr lfile lfirst lgetbuffer lgetexpr lgetfile lgrep lgrepadd lhelpgrep llast llist lmake lmap lmapclear lnext lnewer lnfile lnoremap loadkeymap loadview lockmarks lockvar lolder lopen lprevious lpfile lrewind ltag lunmap luado luafile lvimgrep lvimgrepadd lwindow move mark make mapclear match menu menutranslate messages mkexrc mksession mkspell mkvimrc mkview mode mzscheme mzfile nbclose nbkey nbsart next nmap nmapclear nmenu nnoremap nnoremenu noautocmd noremap nohlsearch noreabbrev noremenu normal number nunmap nunmenu oldfiles open omap omapclear omenu only onoremap onoremenu options ounmap ounmenu ownsyntax print profdel profile promptfind promptrepl pclose pedit perl perldo pop popup ppop preserve previous psearch ptag ptNext ptfirst ptjump ptlast ptnext ptprevious ptrewind ptselect put pwd py3do py3file python pydo pyfile quit quitall qall read recover redo redir redraw redrawstatus registers resize retab return rewind right rightbelow ruby rubydo rubyfile rundo runtime rviminfo substitute sNext sandbox sargument sall saveas sbuffer sbNext sball sbfirst sblast sbmodified sbnext sbprevious sbrewind scriptnames scriptencoding scscope set setfiletype setglobal setlocal sfind sfirst shell simalt sign silent sleep slast smagic smapclear smenu snext sniff snomagic snoremap snoremenu sort source spelldump spellgood spellinfo spellrepall spellundo spellwrong split sprevious srewind stop stag startgreplace startreplace startinsert stopinsert stjump stselect sunhide sunmap sunmenu suspend sview swapname syntax syntime syncbind tNext tabNext tabclose tabedit tabfind tabfirst tablast tabmove tabnext tabonly tabprevious tabrewind tag tcl tcldo tclfile tearoff tfirst throw tjump tlast tmenu tnext topleft tprevious trewind tselect tunmenu undo undojoin undolist unabbreviate unhide unlet unlockvar unmap unmenu unsilent update vglobal version verbose vertical vimgrep vimgrepadd visual viusage view vmap vmapclear vmenu vnew vnoremap vnoremenu vsplit vunmap vunmenu write wNext wall while winsize wincmd winpos wnext wprevious wqall wsverb wundo wviminfo xit xall xmapclear xmap xmenu xnoremap xnoremenu xunmap xunmenu yank",
-built_in:"synIDtrans atan2 range matcharg did_filetype asin feedkeys xor argv complete_check add getwinposx getqflist getwinposy screencol clearmatches empty extend getcmdpos mzeval garbagecollect setreg ceil sqrt diff_hlID inputsecret get getfperm getpid filewritable shiftwidth max sinh isdirectory synID system inputrestore winline atan visualmode inputlist tabpagewinnr round getregtype mapcheck hasmapto histdel argidx findfile sha256 exists toupper getcmdline taglist string getmatches bufnr strftime winwidth bufexists strtrans tabpagebuflist setcmdpos remote_read printf setloclist getpos getline bufwinnr float2nr len getcmdtype diff_filler luaeval resolve libcallnr foldclosedend reverse filter has_key bufname str2float strlen setline getcharmod setbufvar index searchpos shellescape undofile foldclosed setqflist buflisted strchars str2nr virtcol floor remove undotree remote_expr winheight gettabwinvar reltime cursor tabpagenr finddir localtime acos getloclist search tanh matchend rename gettabvar strdisplaywidth type abs py3eval setwinvar tolower wildmenumode log10 spellsuggest bufloaded synconcealed nextnonblank server2client complete settabwinvar executable input wincol setmatches getftype hlID inputsave searchpair or screenrow line settabvar histadd deepcopy strpart remote_peek and eval getftime submatch screenchar winsaveview matchadd mkdir screenattr getfontname libcall reltimestr getfsize winnr invert pow getbufline byte2line soundfold repeat fnameescape tagfiles sin strwidth spellbadword trunc maparg log lispindent hostname setpos globpath remote_foreground getchar synIDattr fnamemodify cscope_connection stridx winbufnr indent min complete_add nr2char searchpairpos inputdialog values matchlist items hlexists strridx browsedir expand fmod pathshorten line2byte argc count getwinvar glob foldtextresult getreg foreground cosh matchdelete has char2nr simplify histget searchdecl iconv winrestcmd pumvisible writefile foldlevel haslocaldir keys cos matchstr foldtext histnr tan tempname getcwd byteidx getbufvar islocked escape eventhandler remote_send serverlist winrestview synstack pyeval prevnonblank readfile cindent filereadable changenr exp"
-},illegal:/;/,contains:[e.NUMBER_MODE,{className:"string",begin:"'",end:"'",
-illegal:"\\n"},{className:"string",begin:/"(\\"|\n\\|[^"\n])*"/
-},e.COMMENT('"',"$"),{className:"variable",begin:/[bwtglsav]:[\w\d_]*/},{
-className:"function",beginKeywords:"function function!",end:"$",relevance:0,
-contains:[e.TITLE_MODE,{className:"params",begin:"\\(",end:"\\)"}]},{
-className:"symbol",begin:/<[\w-]+>/}]})})());
-hljs.registerLanguage("x86asm",(()=>{"use strict";return s=>({
-name:"Intel x86 Assembly",case_insensitive:!0,keywords:{
-$pattern:"[.%]?"+s.IDENT_RE,
-keyword:"lock rep repe repz repne repnz xaquire xrelease bnd nobnd aaa aad aam aas adc add and arpl bb0_reset bb1_reset bound bsf bsr bswap bt btc btr bts call cbw cdq cdqe clc cld cli clts cmc cmp cmpsb cmpsd cmpsq cmpsw cmpxchg cmpxchg486 cmpxchg8b cmpxchg16b cpuid cpu_read cpu_write cqo cwd cwde daa das dec div dmint emms enter equ f2xm1 fabs fadd faddp fbld fbstp fchs fclex fcmovb fcmovbe fcmove fcmovnb fcmovnbe fcmovne fcmovnu fcmovu fcom fcomi fcomip fcomp fcompp fcos fdecstp fdisi fdiv fdivp fdivr fdivrp femms feni ffree ffreep fiadd ficom ficomp fidiv fidivr fild fimul fincstp finit fist fistp fisttp fisub fisubr fld fld1 fldcw fldenv fldl2e fldl2t fldlg2 fldln2 fldpi fldz fmul fmulp fnclex fndisi fneni fninit fnop fnsave fnstcw fnstenv fnstsw fpatan fprem fprem1 fptan frndint frstor fsave fscale fsetpm fsin fsincos fsqrt fst fstcw fstenv fstp fstsw fsub fsubp fsubr fsubrp ftst fucom fucomi fucomip fucomp fucompp fxam fxch fxtract fyl2x fyl2xp1 hlt ibts icebp idiv imul in inc incbin insb insd insw int int01 int1 int03 int3 into invd invpcid invlpg invlpga iret iretd iretq iretw jcxz jecxz jrcxz jmp jmpe lahf lar lds lea leave les lfence lfs lgdt lgs lidt lldt lmsw loadall loadall286 lodsb lodsd lodsq lodsw loop loope loopne loopnz loopz lsl lss ltr mfence monitor mov movd movq movsb movsd movsq movsw movsx movsxd movzx mul mwait neg nop not or out outsb outsd outsw packssdw packsswb packuswb paddb paddd paddsb paddsiw paddsw paddusb paddusw paddw pand pandn pause paveb pavgusb pcmpeqb pcmpeqd pcmpeqw pcmpgtb pcmpgtd pcmpgtw pdistib pf2id pfacc pfadd pfcmpeq pfcmpge pfcmpgt pfmax pfmin pfmul pfrcp pfrcpit1 pfrcpit2 pfrsqit1 pfrsqrt pfsub pfsubr pi2fd pmachriw pmaddwd pmagw pmulhriw pmulhrwa pmulhrwc pmulhw pmullw pmvgezb pmvlzb pmvnzb pmvzb pop popa popad popaw popf popfd popfq popfw por prefetch prefetchw pslld psllq psllw psrad psraw psrld psrlq psrlw psubb psubd psubsb psubsiw psubsw psubusb psubusw psubw punpckhbw punpckhdq punpckhwd punpcklbw punpckldq punpcklwd push pusha pushad pushaw pushf pushfd pushfq pushfw pxor rcl rcr rdshr rdmsr rdpmc rdtsc rdtscp ret retf retn rol ror rdm rsdc rsldt rsm rsts sahf sal salc sar sbb scasb scasd scasq scasw sfence sgdt shl shld shr shrd sidt sldt skinit smi smint smintold smsw stc std sti stosb stosd stosq stosw str sub svdc svldt svts swapgs syscall sysenter sysexit sysret test ud0 ud1 ud2b ud2 ud2a umov verr verw fwait wbinvd wrshr wrmsr xadd xbts xchg xlatb xlat xor cmove cmovz cmovne cmovnz cmova cmovnbe cmovae cmovnb cmovb cmovnae cmovbe cmovna cmovg cmovnle cmovge cmovnl cmovl cmovnge cmovle cmovng cmovc cmovnc cmovo cmovno cmovs cmovns cmovp cmovpe cmovnp cmovpo je jz jne jnz ja jnbe jae jnb jb jnae jbe jna jg jnle jge jnl jl jnge jle jng jc jnc jo jno js jns jpo jnp jpe jp sete setz setne setnz seta setnbe setae setnb setnc setb setnae setcset setbe setna setg setnle setge setnl setl setnge setle setng sets setns seto setno setpe setp setpo setnp addps addss andnps andps cmpeqps cmpeqss cmpleps cmpless cmpltps cmpltss cmpneqps cmpneqss cmpnleps cmpnless cmpnltps cmpnltss cmpordps cmpordss cmpunordps cmpunordss cmpps cmpss comiss cvtpi2ps cvtps2pi cvtsi2ss cvtss2si cvttps2pi cvttss2si divps divss ldmxcsr maxps maxss minps minss movaps movhps movlhps movlps movhlps movmskps movntps movss movups mulps mulss orps rcpps rcpss rsqrtps rsqrtss shufps sqrtps sqrtss stmxcsr subps subss ucomiss unpckhps unpcklps xorps fxrstor fxrstor64 fxsave fxsave64 xgetbv xsetbv xsave xsave64 xsaveopt xsaveopt64 xrstor xrstor64 prefetchnta prefetcht0 prefetcht1 prefetcht2 maskmovq movntq pavgb pavgw pextrw pinsrw pmaxsw pmaxub pminsw pminub pmovmskb pmulhuw psadbw pshufw pf2iw pfnacc pfpnacc pi2fw pswapd maskmovdqu clflush movntdq movnti movntpd movdqa movdqu movdq2q movq2dq paddq pmuludq pshufd pshufhw pshuflw pslldq psrldq psubq punpckhqdq punpcklqdq addpd addsd andnpd andpd cmpeqpd cmpeqsd cmplepd cmplesd cmpltpd cmpltsd cmpneqpd cmpneqsd cmpnlepd cmpnlesd cmpnltpd cmpnltsd cmpordpd cmpordsd cmpunordpd cmpunordsd cmppd comisd cvtdq2pd cvtdq2ps cvtpd2dq cvtpd2pi cvtpd2ps cvtpi2pd cvtps2dq cvtps2pd cvtsd2si cvtsd2ss cvtsi2sd cvtss2sd cvttpd2pi cvttpd2dq cvttps2dq cvttsd2si divpd divsd maxpd maxsd minpd minsd movapd movhpd movlpd movmskpd movupd mulpd mulsd orpd shufpd sqrtpd sqrtsd subpd subsd ucomisd unpckhpd unpcklpd xorpd addsubpd addsubps haddpd haddps hsubpd hsubps lddqu movddup movshdup movsldup clgi stgi vmcall vmclear vmfunc vmlaunch vmload vmmcall vmptrld vmptrst vmread vmresume vmrun vmsave vmwrite vmxoff vmxon invept invvpid pabsb pabsw pabsd palignr phaddw phaddd phaddsw phsubw phsubd phsubsw pmaddubsw pmulhrsw pshufb psignb psignw psignd extrq insertq movntsd movntss lzcnt blendpd blendps blendvpd blendvps dppd dpps extractps insertps movntdqa mpsadbw packusdw pblendvb pblendw pcmpeqq pextrb pextrd pextrq phminposuw pinsrb pinsrd pinsrq pmaxsb pmaxsd pmaxud pmaxuw pminsb pminsd pminud pminuw pmovsxbw pmovsxbd pmovsxbq pmovsxwd pmovsxwq pmovsxdq pmovzxbw pmovzxbd pmovzxbq pmovzxwd pmovzxwq pmovzxdq pmuldq pmulld ptest roundpd roundps roundsd roundss crc32 pcmpestri pcmpestrm pcmpistri pcmpistrm pcmpgtq popcnt getsec pfrcpv pfrsqrtv movbe aesenc aesenclast aesdec aesdeclast aesimc aeskeygenassist vaesenc vaesenclast vaesdec vaesdeclast vaesimc vaeskeygenassist vaddpd vaddps vaddsd vaddss vaddsubpd vaddsubps vandpd vandps vandnpd vandnps vblendpd vblendps vblendvpd vblendvps vbroadcastss vbroadcastsd vbroadcastf128 vcmpeq_ospd vcmpeqpd vcmplt_ospd vcmpltpd vcmple_ospd vcmplepd vcmpunord_qpd vcmpunordpd vcmpneq_uqpd vcmpneqpd vcmpnlt_uspd vcmpnltpd vcmpnle_uspd vcmpnlepd vcmpord_qpd vcmpordpd vcmpeq_uqpd vcmpnge_uspd vcmpngepd vcmpngt_uspd vcmpngtpd vcmpfalse_oqpd vcmpfalsepd vcmpneq_oqpd vcmpge_ospd vcmpgepd vcmpgt_ospd vcmpgtpd vcmptrue_uqpd vcmptruepd vcmplt_oqpd vcmple_oqpd vcmpunord_spd vcmpneq_uspd vcmpnlt_uqpd vcmpnle_uqpd vcmpord_spd vcmpeq_uspd vcmpnge_uqpd vcmpngt_uqpd vcmpfalse_ospd vcmpneq_ospd vcmpge_oqpd vcmpgt_oqpd vcmptrue_uspd vcmppd vcmpeq_osps vcmpeqps vcmplt_osps vcmpltps vcmple_osps vcmpleps vcmpunord_qps vcmpunordps vcmpneq_uqps vcmpneqps vcmpnlt_usps vcmpnltps vcmpnle_usps vcmpnleps vcmpord_qps vcmpordps vcmpeq_uqps vcmpnge_usps vcmpngeps vcmpngt_usps vcmpngtps vcmpfalse_oqps vcmpfalseps vcmpneq_oqps vcmpge_osps vcmpgeps vcmpgt_osps vcmpgtps vcmptrue_uqps vcmptrueps vcmplt_oqps vcmple_oqps vcmpunord_sps vcmpneq_usps vcmpnlt_uqps vcmpnle_uqps vcmpord_sps vcmpeq_usps vcmpnge_uqps vcmpngt_uqps vcmpfalse_osps vcmpneq_osps vcmpge_oqps vcmpgt_oqps vcmptrue_usps vcmpps vcmpeq_ossd vcmpeqsd vcmplt_ossd vcmpltsd vcmple_ossd vcmplesd vcmpunord_qsd vcmpunordsd vcmpneq_uqsd vcmpneqsd vcmpnlt_ussd vcmpnltsd vcmpnle_ussd vcmpnlesd vcmpord_qsd vcmpordsd vcmpeq_uqsd vcmpnge_ussd vcmpngesd vcmpngt_ussd vcmpngtsd vcmpfalse_oqsd vcmpfalsesd vcmpneq_oqsd vcmpge_ossd vcmpgesd vcmpgt_ossd vcmpgtsd vcmptrue_uqsd vcmptruesd vcmplt_oqsd vcmple_oqsd vcmpunord_ssd vcmpneq_ussd vcmpnlt_uqsd vcmpnle_uqsd vcmpord_ssd vcmpeq_ussd vcmpnge_uqsd vcmpngt_uqsd vcmpfalse_ossd vcmpneq_ossd vcmpge_oqsd vcmpgt_oqsd vcmptrue_ussd vcmpsd vcmpeq_osss vcmpeqss vcmplt_osss vcmpltss vcmple_osss vcmpless vcmpunord_qss vcmpunordss vcmpneq_uqss vcmpneqss vcmpnlt_usss vcmpnltss vcmpnle_usss vcmpnless vcmpord_qss vcmpordss vcmpeq_uqss vcmpnge_usss vcmpngess vcmpngt_usss vcmpngtss vcmpfalse_oqss vcmpfalsess vcmpneq_oqss vcmpge_osss vcmpgess vcmpgt_osss vcmpgtss vcmptrue_uqss vcmptruess vcmplt_oqss vcmple_oqss vcmpunord_sss vcmpneq_usss vcmpnlt_uqss vcmpnle_uqss vcmpord_sss vcmpeq_usss vcmpnge_uqss vcmpngt_uqss vcmpfalse_osss vcmpneq_osss vcmpge_oqss vcmpgt_oqss vcmptrue_usss vcmpss vcomisd vcomiss vcvtdq2pd vcvtdq2ps vcvtpd2dq vcvtpd2ps vcvtps2dq vcvtps2pd vcvtsd2si vcvtsd2ss vcvtsi2sd vcvtsi2ss vcvtss2sd vcvtss2si vcvttpd2dq vcvttps2dq vcvttsd2si vcvttss2si vdivpd vdivps vdivsd vdivss vdppd vdpps vextractf128 vextractps vhaddpd vhaddps vhsubpd vhsubps vinsertf128 vinsertps vlddqu vldqqu vldmxcsr vmaskmovdqu vmaskmovps vmaskmovpd vmaxpd vmaxps vmaxsd vmaxss vminpd vminps vminsd vminss vmovapd vmovaps vmovd vmovq vmovddup vmovdqa vmovqqa vmovdqu vmovqqu vmovhlps vmovhpd vmovhps vmovlhps vmovlpd vmovlps vmovmskpd vmovmskps vmovntdq vmovntqq vmovntdqa vmovntpd vmovntps vmovsd vmovshdup vmovsldup vmovss vmovupd vmovups vmpsadbw vmulpd vmulps vmulsd vmulss vorpd vorps vpabsb vpabsw vpabsd vpacksswb vpackssdw vpackuswb vpackusdw vpaddb vpaddw vpaddd vpaddq vpaddsb vpaddsw vpaddusb vpaddusw vpalignr vpand vpandn vpavgb vpavgw vpblendvb vpblendw vpcmpestri vpcmpestrm vpcmpistri vpcmpistrm vpcmpeqb vpcmpeqw vpcmpeqd vpcmpeqq vpcmpgtb vpcmpgtw vpcmpgtd vpcmpgtq vpermilpd vpermilps vperm2f128 vpextrb vpextrw vpextrd vpextrq vphaddw vphaddd vphaddsw vphminposuw vphsubw vphsubd vphsubsw vpinsrb vpinsrw vpinsrd vpinsrq vpmaddwd vpmaddubsw vpmaxsb vpmaxsw vpmaxsd vpmaxub vpmaxuw vpmaxud vpminsb vpminsw vpminsd vpminub vpminuw vpminud vpmovmskb vpmovsxbw vpmovsxbd vpmovsxbq vpmovsxwd vpmovsxwq vpmovsxdq vpmovzxbw vpmovzxbd vpmovzxbq vpmovzxwd vpmovzxwq vpmovzxdq vpmulhuw vpmulhrsw vpmulhw vpmullw vpmulld vpmuludq vpmuldq vpor vpsadbw vpshufb vpshufd vpshufhw vpshuflw vpsignb vpsignw vpsignd vpslldq vpsrldq vpsllw vpslld vpsllq vpsraw vpsrad vpsrlw vpsrld vpsrlq vptest vpsubb vpsubw vpsubd vpsubq vpsubsb vpsubsw vpsubusb vpsubusw vpunpckhbw vpunpckhwd vpunpckhdq vpunpckhqdq vpunpcklbw vpunpcklwd vpunpckldq vpunpcklqdq vpxor vrcpps vrcpss vrsqrtps vrsqrtss vroundpd vroundps vroundsd vroundss vshufpd vshufps vsqrtpd vsqrtps vsqrtsd vsqrtss vstmxcsr vsubpd vsubps vsubsd vsubss vtestps vtestpd vucomisd vucomiss vunpckhpd vunpckhps vunpcklpd vunpcklps vxorpd vxorps vzeroall vzeroupper pclmullqlqdq pclmulhqlqdq pclmullqhqdq pclmulhqhqdq pclmulqdq vpclmullqlqdq vpclmulhqlqdq vpclmullqhqdq vpclmulhqhqdq vpclmulqdq vfmadd132ps vfmadd132pd vfmadd312ps vfmadd312pd vfmadd213ps vfmadd213pd vfmadd123ps vfmadd123pd vfmadd231ps vfmadd231pd vfmadd321ps vfmadd321pd vfmaddsub132ps vfmaddsub132pd vfmaddsub312ps vfmaddsub312pd vfmaddsub213ps vfmaddsub213pd vfmaddsub123ps vfmaddsub123pd vfmaddsub231ps vfmaddsub231pd vfmaddsub321ps vfmaddsub321pd vfmsub132ps vfmsub132pd vfmsub312ps vfmsub312pd vfmsub213ps vfmsub213pd vfmsub123ps vfmsub123pd vfmsub231ps vfmsub231pd vfmsub321ps vfmsub321pd vfmsubadd132ps vfmsubadd132pd vfmsubadd312ps vfmsubadd312pd vfmsubadd213ps vfmsubadd213pd vfmsubadd123ps vfmsubadd123pd vfmsubadd231ps vfmsubadd231pd vfmsubadd321ps vfmsubadd321pd vfnmadd132ps vfnmadd132pd vfnmadd312ps vfnmadd312pd vfnmadd213ps vfnmadd213pd vfnmadd123ps vfnmadd123pd vfnmadd231ps vfnmadd231pd vfnmadd321ps vfnmadd321pd vfnmsub132ps vfnmsub132pd vfnmsub312ps vfnmsub312pd vfnmsub213ps vfnmsub213pd vfnmsub123ps vfnmsub123pd vfnmsub231ps vfnmsub231pd vfnmsub321ps vfnmsub321pd vfmadd132ss vfmadd132sd vfmadd312ss vfmadd312sd vfmadd213ss vfmadd213sd vfmadd123ss vfmadd123sd vfmadd231ss vfmadd231sd vfmadd321ss vfmadd321sd vfmsub132ss vfmsub132sd vfmsub312ss vfmsub312sd vfmsub213ss vfmsub213sd vfmsub123ss vfmsub123sd vfmsub231ss vfmsub231sd vfmsub321ss vfmsub321sd vfnmadd132ss vfnmadd132sd vfnmadd312ss vfnmadd312sd vfnmadd213ss vfnmadd213sd vfnmadd123ss vfnmadd123sd vfnmadd231ss vfnmadd231sd vfnmadd321ss vfnmadd321sd vfnmsub132ss vfnmsub132sd vfnmsub312ss vfnmsub312sd vfnmsub213ss vfnmsub213sd vfnmsub123ss vfnmsub123sd vfnmsub231ss vfnmsub231sd vfnmsub321ss vfnmsub321sd rdfsbase rdgsbase rdrand wrfsbase wrgsbase vcvtph2ps vcvtps2ph adcx adox rdseed clac stac xstore xcryptecb xcryptcbc xcryptctr xcryptcfb xcryptofb montmul xsha1 xsha256 llwpcb slwpcb lwpval lwpins vfmaddpd vfmaddps vfmaddsd vfmaddss vfmaddsubpd vfmaddsubps vfmsubaddpd vfmsubaddps vfmsubpd vfmsubps vfmsubsd vfmsubss vfnmaddpd vfnmaddps vfnmaddsd vfnmaddss vfnmsubpd vfnmsubps vfnmsubsd vfnmsubss vfrczpd vfrczps vfrczsd vfrczss vpcmov vpcomb vpcomd vpcomq vpcomub vpcomud vpcomuq vpcomuw vpcomw vphaddbd vphaddbq vphaddbw vphadddq vphaddubd vphaddubq vphaddubw vphaddudq vphadduwd vphadduwq vphaddwd vphaddwq vphsubbw vphsubdq vphsubwd vpmacsdd vpmacsdqh vpmacsdql vpmacssdd vpmacssdqh vpmacssdql vpmacsswd vpmacssww vpmacswd vpmacsww vpmadcsswd vpmadcswd vpperm vprotb vprotd vprotq vprotw vpshab vpshad vpshaq vpshaw vpshlb vpshld vpshlq vpshlw vbroadcasti128 vpblendd vpbroadcastb vpbroadcastw vpbroadcastd vpbroadcastq vpermd vpermpd vpermps vpermq vperm2i128 vextracti128 vinserti128 vpmaskmovd vpmaskmovq vpsllvd vpsllvq vpsravd vpsrlvd vpsrlvq vgatherdpd vgatherqpd vgatherdps vgatherqps vpgatherdd vpgatherqd vpgatherdq vpgatherqq xabort xbegin xend xtest andn bextr blci blcic blsi blsic blcfill blsfill blcmsk blsmsk blsr blcs bzhi mulx pdep pext rorx sarx shlx shrx tzcnt tzmsk t1mskc valignd valignq vblendmpd vblendmps vbroadcastf32x4 vbroadcastf64x4 vbroadcasti32x4 vbroadcasti64x4 vcompresspd vcompressps vcvtpd2udq vcvtps2udq vcvtsd2usi vcvtss2usi vcvttpd2udq vcvttps2udq vcvttsd2usi vcvttss2usi vcvtudq2pd vcvtudq2ps vcvtusi2sd vcvtusi2ss vexpandpd vexpandps vextractf32x4 vextractf64x4 vextracti32x4 vextracti64x4 vfixupimmpd vfixupimmps vfixupimmsd vfixupimmss vgetexppd vgetexpps vgetexpsd vgetexpss vgetmantpd vgetmantps vgetmantsd vgetmantss vinsertf32x4 vinsertf64x4 vinserti32x4 vinserti64x4 vmovdqa32 vmovdqa64 vmovdqu32 vmovdqu64 vpabsq vpandd vpandnd vpandnq vpandq vpblendmd vpblendmq vpcmpltd vpcmpled vpcmpneqd vpcmpnltd vpcmpnled vpcmpd vpcmpltq vpcmpleq vpcmpneqq vpcmpnltq vpcmpnleq vpcmpq vpcmpequd vpcmpltud vpcmpleud vpcmpnequd vpcmpnltud vpcmpnleud vpcmpud vpcmpequq vpcmpltuq vpcmpleuq vpcmpnequq vpcmpnltuq vpcmpnleuq vpcmpuq vpcompressd vpcompressq vpermi2d vpermi2pd vpermi2ps vpermi2q vpermt2d vpermt2pd vpermt2ps vpermt2q vpexpandd vpexpandq vpmaxsq vpmaxuq vpminsq vpminuq vpmovdb vpmovdw vpmovqb vpmovqd vpmovqw vpmovsdb vpmovsdw vpmovsqb vpmovsqd vpmovsqw vpmovusdb vpmovusdw vpmovusqb vpmovusqd vpmovusqw vpord vporq vprold vprolq vprolvd vprolvq vprord vprorq vprorvd vprorvq vpscatterdd vpscatterdq vpscatterqd vpscatterqq vpsraq vpsravq vpternlogd vpternlogq vptestmd vptestmq vptestnmd vptestnmq vpxord vpxorq vrcp14pd vrcp14ps vrcp14sd vrcp14ss vrndscalepd vrndscaleps vrndscalesd vrndscaless vrsqrt14pd vrsqrt14ps vrsqrt14sd vrsqrt14ss vscalefpd vscalefps vscalefsd vscalefss vscatterdpd vscatterdps vscatterqpd vscatterqps vshuff32x4 vshuff64x2 vshufi32x4 vshufi64x2 kandnw kandw kmovw knotw kortestw korw kshiftlw kshiftrw kunpckbw kxnorw kxorw vpbroadcastmb2q vpbroadcastmw2d vpconflictd vpconflictq vplzcntd vplzcntq vexp2pd vexp2ps vrcp28pd vrcp28ps vrcp28sd vrcp28ss vrsqrt28pd vrsqrt28ps vrsqrt28sd vrsqrt28ss vgatherpf0dpd vgatherpf0dps vgatherpf0qpd vgatherpf0qps vgatherpf1dpd vgatherpf1dps vgatherpf1qpd vgatherpf1qps vscatterpf0dpd vscatterpf0dps vscatterpf0qpd vscatterpf0qps vscatterpf1dpd vscatterpf1dps vscatterpf1qpd vscatterpf1qps prefetchwt1 bndmk bndcl bndcu bndcn bndmov bndldx bndstx sha1rnds4 sha1nexte sha1msg1 sha1msg2 sha256rnds2 sha256msg1 sha256msg2 hint_nop0 hint_nop1 hint_nop2 hint_nop3 hint_nop4 hint_nop5 hint_nop6 hint_nop7 hint_nop8 hint_nop9 hint_nop10 hint_nop11 hint_nop12 hint_nop13 hint_nop14 hint_nop15 hint_nop16 hint_nop17 hint_nop18 hint_nop19 hint_nop20 hint_nop21 hint_nop22 hint_nop23 hint_nop24 hint_nop25 hint_nop26 hint_nop27 hint_nop28 hint_nop29 hint_nop30 hint_nop31 hint_nop32 hint_nop33 hint_nop34 hint_nop35 hint_nop36 hint_nop37 hint_nop38 hint_nop39 hint_nop40 hint_nop41 hint_nop42 hint_nop43 hint_nop44 hint_nop45 hint_nop46 hint_nop47 hint_nop48 hint_nop49 hint_nop50 hint_nop51 hint_nop52 hint_nop53 hint_nop54 hint_nop55 hint_nop56 hint_nop57 hint_nop58 hint_nop59 hint_nop60 hint_nop61 hint_nop62 hint_nop63",
-built_in:"ip eip rip al ah bl bh cl ch dl dh sil dil bpl spl r8b r9b r10b r11b r12b r13b r14b r15b ax bx cx dx si di bp sp r8w r9w r10w r11w r12w r13w r14w r15w eax ebx ecx edx esi edi ebp esp eip r8d r9d r10d r11d r12d r13d r14d r15d rax rbx rcx rdx rsi rdi rbp rsp r8 r9 r10 r11 r12 r13 r14 r15 cs ds es fs gs ss st st0 st1 st2 st3 st4 st5 st6 st7 mm0 mm1 mm2 mm3 mm4 mm5 mm6 mm7 xmm0  xmm1  xmm2  xmm3  xmm4  xmm5  xmm6  xmm7  xmm8  xmm9 xmm10  xmm11 xmm12 xmm13 xmm14 xmm15 xmm16 xmm17 xmm18 xmm19 xmm20 xmm21 xmm22 xmm23 xmm24 xmm25 xmm26 xmm27 xmm28 xmm29 xmm30 xmm31 ymm0  ymm1  ymm2  ymm3  ymm4  ymm5  ymm6  ymm7  ymm8  ymm9 ymm10  ymm11 ymm12 ymm13 ymm14 ymm15 ymm16 ymm17 ymm18 ymm19 ymm20 ymm21 ymm22 ymm23 ymm24 ymm25 ymm26 ymm27 ymm28 ymm29 ymm30 ymm31 zmm0  zmm1  zmm2  zmm3  zmm4  zmm5  zmm6  zmm7  zmm8  zmm9 zmm10  zmm11 zmm12 zmm13 zmm14 zmm15 zmm16 zmm17 zmm18 zmm19 zmm20 zmm21 zmm22 zmm23 zmm24 zmm25 zmm26 zmm27 zmm28 zmm29 zmm30 zmm31 k0 k1 k2 k3 k4 k5 k6 k7 bnd0 bnd1 bnd2 bnd3 cr0 cr1 cr2 cr3 cr4 cr8 dr0 dr1 dr2 dr3 dr8 tr3 tr4 tr5 tr6 tr7 r0 r1 r2 r3 r4 r5 r6 r7 r0b r1b r2b r3b r4b r5b r6b r7b r0w r1w r2w r3w r4w r5w r6w r7w r0d r1d r2d r3d r4d r5d r6d r7d r0h r1h r2h r3h r0l r1l r2l r3l r4l r5l r6l r7l r8l r9l r10l r11l r12l r13l r14l r15l db dw dd dq dt ddq do dy dz resb resw resd resq rest resdq reso resy resz incbin equ times byte word dword qword nosplit rel abs seg wrt strict near far a32 ptr",
-meta:"%define %xdefine %+ %undef %defstr %deftok %assign %strcat %strlen %substr %rotate %elif %else %endif %if %ifmacro %ifctx %ifidn %ifidni %ifid %ifnum %ifstr %iftoken %ifempty %ifenv %error %warning %fatal %rep %endrep %include %push %pop %repl %pathsearch %depend %use %arg %stacksize %local %line %comment %endcomment .nolist __FILE__ __LINE__ __SECT__  __BITS__ __OUTPUT_FORMAT__ __DATE__ __TIME__ __DATE_NUM__ __TIME_NUM__ __UTC_DATE__ __UTC_TIME__ __UTC_DATE_NUM__ __UTC_TIME_NUM__  __PASS__ struc endstruc istruc at iend align alignb sectalign daz nodaz up down zero default option assume public bits use16 use32 use64 default section segment absolute extern global common cpu float __utf16__ __utf16le__ __utf16be__ __utf32__ __utf32le__ __utf32be__ __float8__ __float16__ __float32__ __float64__ __float80m__ __float80e__ __float128l__ __float128h__ __Infinity__ __QNaN__ __SNaN__ Inf NaN QNaN SNaN float8 float16 float32 float64 float80m float80e float128l float128h __FLOAT_DAZ__ __FLOAT_ROUND__ __FLOAT__"
-},contains:[s.COMMENT(";","$",{relevance:0}),{className:"number",variants:[{
-begin:"\\b(?:([0-9][0-9_]*)?\\.[0-9_]*(?:[eE][+-]?[0-9_]+)?|(0[Xx])?[0-9][0-9_]*(\\.[0-9_]*)?(?:[pP](?:[+-]?[0-9_]+)?)?)\\b",
-relevance:0},{begin:"\\$[0-9][0-9A-Fa-f]*",relevance:0},{
-begin:"\\b(?:[0-9A-Fa-f][0-9A-Fa-f_]*[Hh]|[0-9][0-9_]*[DdTt]?|[0-7][0-7_]*[QqOo]|[0-1][0-1_]*[BbYy])\\b"
-},{
-begin:"\\b(?:0[Xx][0-9A-Fa-f_]+|0[DdTt][0-9_]+|0[QqOo][0-7_]+|0[BbYy][0-1_]+)\\b"
-}]},s.QUOTE_STRING_MODE,{className:"string",variants:[{begin:"'",end:"[^\\\\]'"
-},{begin:"`",end:"[^\\\\]`"}],relevance:0},{className:"symbol",variants:[{
-begin:"^\\s*[A-Za-z._?][A-Za-z0-9_$#@~.?]*(:|\\s+label)"},{
-begin:"^\\s*%%[A-Za-z0-9_$#@~.?]*:"}],relevance:0},{className:"subst",
-begin:"%[0-9]+",relevance:0},{className:"subst",begin:"%!S+",relevance:0},{
-className:"meta",begin:/^\s*\.[\w_-]+/}]})})());
-hljs.registerLanguage("xl",(()=>{"use strict";return e=>{const t={
-$pattern:/[a-zA-Z][a-zA-Z0-9_?]*/,
-keyword:"if then else do while until for loop import with is as where when by data constant integer real text name boolean symbol infix prefix postfix block tree",
-literal:"true false nil",
-built_in:"in mod rem and or xor not abs sign floor ceil sqrt sin cos tan asin acos atan exp expm1 log log2 log10 log1p pi at text_length text_range text_find text_replace contains page slide basic_slide title_slide title subtitle fade_in fade_out fade_at clear_color color line_color line_width texture_wrap texture_transform texture scale_?x scale_?y scale_?z? translate_?x translate_?y translate_?z? rotate_?x rotate_?y rotate_?z? rectangle circle ellipse sphere path line_to move_to quad_to curve_to theme background contents locally time mouse_?x mouse_?y mouse_buttons ObjectLoader Animate MovieCredits Slides Filters Shading Materials LensFlare Mapping VLCAudioVideo StereoDecoder PointCloud NetworkAccess RemoteControl RegExp ChromaKey Snowfall NodeJS Speech Charts"
-},a={className:"string",begin:'"',end:'"',illegal:"\\n"},n={
-beginKeywords:"import",end:"$",keywords:t,contains:[a]},o={className:"function",
-begin:/[a-z][^\n]*->/,returnBegin:!0,end:/->/,contains:[e.inherit(e.TITLE_MODE,{
-starts:{endsWithParent:!0,keywords:t}})]};return{name:"XL",aliases:["tao"],
-keywords:t,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,{
-className:"string",begin:"'",end:"'",illegal:"\\n"},{className:"string",
-begin:"<<",end:">>"},o,n,{className:"number",
-begin:"[0-9]+#[0-9A-Z_]+(\\.[0-9-A-Z_]+)?#?([Ee][+-]?[0-9]+)?"},e.NUMBER_MODE]}}
-})());
-hljs.registerLanguage("xquery",(()=>{"use strict";return e=>({name:"XQuery",
-aliases:["xpath","xq"],case_insensitive:!1,
-illegal:/(proc)|(abstract)|(extends)|(until)|(#)/,keywords:{
-$pattern:/[a-zA-Z$][a-zA-Z0-9_:-]*/,
-keyword:"module schema namespace boundary-space preserve no-preserve strip default collation base-uri ordering context decimal-format decimal-separator copy-namespaces empty-sequence except exponent-separator external grouping-separator inherit no-inherit lax minus-sign per-mille percent schema-attribute schema-element strict unordered zero-digit declare import option function validate variable for at in let where order group by return if then else tumbling sliding window start when only end previous next stable ascending descending allowing empty greatest least some every satisfies switch case typeswitch try catch and or to union intersect instance of treat as castable cast map array delete insert into replace value rename copy modify update",
-type:"item document-node node attribute document element comment namespace namespace-node processing-instruction text construction xs:anyAtomicType xs:untypedAtomic xs:duration xs:time xs:decimal xs:float xs:double xs:gYearMonth xs:gYear xs:gMonthDay xs:gMonth xs:gDay xs:boolean xs:base64Binary xs:hexBinary xs:anyURI xs:QName xs:NOTATION xs:dateTime xs:dateTimeStamp xs:date xs:string xs:normalizedString xs:token xs:language xs:NMTOKEN xs:Name xs:NCName xs:ID xs:IDREF xs:ENTITY xs:integer xs:nonPositiveInteger xs:negativeInteger xs:long xs:int xs:short xs:byte xs:nonNegativeInteger xs:unisignedLong xs:unsignedInt xs:unsignedShort xs:unsignedByte xs:positiveInteger xs:yearMonthDuration xs:dayTimeDuration",
-literal:"eq ne lt le gt ge is self:: child:: descendant:: descendant-or-self:: attribute:: following:: following-sibling:: parent:: ancestor:: ancestor-or-self:: preceding:: preceding-sibling:: NaN"
-},contains:[{className:"variable",begin:/[$][\w\-:]+/},{className:"built_in",
-variants:[{begin:/\barray:/,
-end:/(?:append|filter|flatten|fold-(?:left|right)|for-each(?:-pair)?|get|head|insert-before|join|put|remove|reverse|size|sort|subarray|tail)\b/
-},{begin:/\bmap:/,
-end:/(?:contains|entry|find|for-each|get|keys|merge|put|remove|size)\b/},{
-begin:/\bmath:/,
-end:/(?:a(?:cos|sin|tan[2]?)|cos|exp(?:10)?|log(?:10)?|pi|pow|sin|sqrt|tan)\b/
-},{begin:/\bop:/,end:/\(/,excludeEnd:!0},{begin:/\bfn:/,end:/\(/,excludeEnd:!0
-},{
-begin:/[^</$:'"-]\b(?:abs|accumulator-(?:after|before)|adjust-(?:date(?:Time)?|time)-to-timezone|analyze-string|apply|available-(?:environment-variables|system-properties)|avg|base-uri|boolean|ceiling|codepoints?-(?:equal|to-string)|collation-key|collection|compare|concat|contains(?:-token)?|copy-of|count|current(?:-)?(?:date(?:Time)?|time|group(?:ing-key)?|output-uri|merge-(?:group|key))?data|dateTime|days?-from-(?:date(?:Time)?|duration)|deep-equal|default-(?:collation|language)|distinct-values|document(?:-uri)?|doc(?:-available)?|element-(?:available|with-id)|empty|encode-for-uri|ends-with|environment-variable|error|escape-html-uri|exactly-one|exists|false|filter|floor|fold-(?:left|right)|for-each(?:-pair)?|format-(?:date(?:Time)?|time|integer|number)|function-(?:arity|available|lookup|name)|generate-id|has-children|head|hours-from-(?:dateTime|duration|time)|id(?:ref)?|implicit-timezone|in-scope-prefixes|index-of|innermost|insert-before|iri-to-uri|json-(?:doc|to-xml)|key|lang|last|load-xquery-module|local-name(?:-from-QName)?|(?:lower|upper)-case|matches|max|minutes-from-(?:dateTime|duration|time)|min|months?-from-(?:date(?:Time)?|duration)|name(?:space-uri-?(?:for-prefix|from-QName)?)?|nilled|node-name|normalize-(?:space|unicode)|not|number|one-or-more|outermost|parse-(?:ietf-date|json)|path|position|(?:prefix-from-)?QName|random-number-generator|regex-group|remove|replace|resolve-(?:QName|uri)|reverse|root|round(?:-half-to-even)?|seconds-from-(?:dateTime|duration|time)|snapshot|sort|starts-with|static-base-uri|stream-available|string-?(?:join|length|to-codepoints)?|subsequence|substring-?(?:after|before)?|sum|system-property|tail|timezone-from-(?:date(?:Time)?|time)|tokenize|trace|trans(?:form|late)|true|type-available|unordered|unparsed-(?:entity|text)?-?(?:public-id|uri|available|lines)?|uri-collection|xml-to-json|years?-from-(?:date(?:Time)?|duration)|zero-or-one)\b/
-},{begin:/\blocal:/,end:/\(/,excludeEnd:!0},{begin:/\bzip:/,
-end:/(?:zip-file|(?:xml|html|text|binary)-entry| (?:update-)?entries)\b/},{
-begin:/\b(?:util|db|functx|app|xdmp|xmldb):/,end:/\(/,excludeEnd:!0}]},{
-className:"string",variants:[{begin:/"/,end:/"/,contains:[{begin:/""/,
-relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]}]},{
-className:"number",
-begin:/(\b0[0-7_]+)|(\b0x[0-9a-fA-F_]+)|(\b[1-9][0-9_]*(\.[0-9_]+)?)|[0_]\b/,
-relevance:0},{className:"comment",begin:/\(:/,end:/:\)/,relevance:10,contains:[{
-className:"doctag",begin:/@\w+/}]},{className:"meta",begin:/%[\w\-:]+/},{
-className:"title",begin:/\bxquery version "[13]\.[01]"\s?(?:encoding ".+")?/,
-end:/;/},{
-beginKeywords:"element attribute comment document processing-instruction",
-end:/\{/,excludeEnd:!0},{begin:/<([\w._:-]+)(\s+\S*=('|").*('|"))?>/,
-end:/(\/[\w._:-]+>)/,subLanguage:"xml",contains:[{begin:/\{/,end:/\}/,
-subLanguage:"xquery"},"self"]}]})})());
-hljs.registerLanguage("zephir",(()=>{"use strict";return e=>{const n={
-className:"string",contains:[e.BACKSLASH_ESCAPE],
-variants:[e.inherit(e.APOS_STRING_MODE,{illegal:null
-}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null})]},s=e.UNDERSCORE_TITLE_MODE,a={
-variants:[e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE]
-},i="namespace class interface use extends function return abstract final public protected private static deprecated throw try catch Exception echo empty isset instanceof unset let var new const self require if else elseif switch case default do while loop for continue break likely unlikely __LINE__ __FILE__ __DIR__ __FUNCTION__ __CLASS__ __TRAIT__ __METHOD__ __NAMESPACE__ array boolean float double integer object resource string char long unsigned bool int uint ulong uchar true false null undefined"
-;return{name:"Zephir",aliases:["zep"],keywords:i,
-contains:[e.C_LINE_COMMENT_MODE,e.COMMENT(/\/\*/,/\*\//,{contains:[{
-className:"doctag",begin:/@[A-Za-z]+/}]}),{className:"string",
-begin:/<<<['"]?\w+['"]?$/,end:/^\w+;/,contains:[e.BACKSLASH_ESCAPE]},{
-begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{className:"function",
-beginKeywords:"function fn",end:/[;{]/,excludeEnd:!0,illegal:/\$|\[|%/,
-contains:[s,{className:"params",begin:/\(/,end:/\)/,keywords:i,
-contains:["self",e.C_BLOCK_COMMENT_MODE,n,a]}]},{className:"class",
-beginKeywords:"class interface",end:/\{/,excludeEnd:!0,illegal:/[:($"]/,
-contains:[{beginKeywords:"extends implements"},s]},{beginKeywords:"namespace",
-end:/;/,illegal:/[.']/,contains:[s]},{beginKeywords:"use",end:/;/,contains:[s]
-},{begin:/=>/},n,a]}}})());
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/gr-app_html.ts b/lib/highlightjs/index.js
similarity index 66%
rename from polygerrit-ui/app/elements/gr-app_html.ts
rename to lib/highlightjs/index.js
index f6172c9..c2d048d 100644
--- a/polygerrit-ui/app/elements/gr-app_html.ts
+++ b/lib/highlightjs/index.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2020 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.
@@ -14,8 +14,12 @@
  * 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-app-element id="app-element"></gr-app-element>
-`;
+import hljs from 'highlight.js';
+import soy from 'highlightjs-closure-templates';
+import iecst from 'highlightjs-structured-text';
+
+hljs.registerLanguage('soy', soy);
+hljs.registerLanguage('iecst', iecst);
+
+export default hljs;
diff --git a/lib/highlightjs/rollup.config.js b/lib/highlightjs/rollup.config.js
new file mode 100644
index 0000000..c3a1340
--- /dev/null
+++ b/lib/highlightjs/rollup.config.js
@@ -0,0 +1,86 @@
+/**
+ * @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.
+ */
+
+const path = require('path');
+
+// In this file word "plugin" refers to rollup plugin, not Gerrit plugin.
+// By default, require(plugin_name) tries to find module plugin_name starting
+// from the folder where this file (rollup.config.js) is located
+// (see https://www.typescriptlang.org/docs/handbook/module-resolution.html#node
+// and https://nodejs.org/api/modules.html#modules_all_together).
+// So, rollup.config.js can't be in polygerrit-ui/app dir and it should be in
+// tools/node_tools directory (where all plugins are installed).
+// But rollup_bundle rule copy this .config.js file to another directory,
+// so require(plugin_name) can't find a plugin.
+// To fix it, requirePlugin tries:
+// 1. resolve module id using default behavior, i.e. it starts from __dirname
+// 2. if module not found - it tries to resolve module starting from rollupBin
+//    location.
+// This workaround also gives us additional power - we can place .config.js
+// file anywhere in a source tree and add all plugins in the same package.json
+// file as rollup node module.
+function requirePlugin(id) {
+  const rollupBinDir = path.dirname(process.argv[1]);
+  const pluginPath = require.resolve(id, {paths: [__dirname, rollupBinDir] });
+  return require(pluginPath);
+}
+
+const cjs = requirePlugin('rollup-plugin-commonjs');
+const nodeResolve = requirePlugin('rollup-plugin-node-resolve');
+const {terser} = requirePlugin('rollup-plugin-terser');
+
+export default {
+  onwarn: warning => {
+    // No warnings from rollupjs are allowed.
+    // Most of the warnings are real error in our code (for example,
+    // if some import couldn't be resolved we can't continue, but rollup
+    // reports it as a warning)
+    throw new Error(warning.message);
+  },
+  output: {
+    format: 'iife',
+    compact: true,
+    strict: true,
+    exports: 'auto',
+    name: 'hljs',
+    footer: '',
+    plugins: [
+      terser({
+        output: {
+          comments: false
+        },
+        compress: {
+          ecma: 2015,
+          unsafe_arrows: true,
+          passes: 2,
+          unsafe: true,
+          warnings: true,
+          dead_code: true,
+          toplevel: "funcs"
+        }
+      })
+    ]
+  },
+  plugins: [
+    nodeResolve({
+      customResolveOptions: {
+        moduleDirectory: 'external/ui_npm/node_modules'
+      }
+    }),
+    cjs(),
+  ]
+};
diff --git a/lib/js/BUILD b/lib/js/BUILD
index be82540..746a28e 100644
--- a/lib/js/BUILD
+++ b/lib/js/BUILD
@@ -4,7 +4,7 @@
 
 js_component(
     name = "highlightjs",
-    srcs = ["//lib/highlightjs:highlight.min.js"],
+    srcs = ["//lib/highlightjs:highlight.min"],
     license = "//lib:LICENSE-highlightjs",
 )
 
@@ -13,6 +13,6 @@
 # double underscore are removed.
 filegroup(
     name = "highlightjs__files",
-    srcs = ["//lib/highlightjs:highlight.min.js"],
+    srcs = ["//lib/highlightjs:highlight.min"],
     data = ["//lib:LICENSE-highlightjs"],
 )
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 045fe53..51c50bf 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -21,6 +21,7 @@
 flogger-log4j-backend
 flogger-system-backend
 guava
+guava-testlib
 guice-assistedinject
 guice-library
 guice-servlet
diff --git a/package.json b/package.json
index af02301..895dd87 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
     "eslint-plugin-regex": "^1.8.0",
     "gts": "^3.1.0",
     "lit-analyzer": "^1.2.1",
+    "npm-run-all": "^4.1.5",
     "prettier": "2.3.1",
     "rollup": "^2.45.2",
     "terser": "^5.6.1",
@@ -40,8 +41,10 @@
     "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis",
     "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --browsers ChromeDev --no-single-run --test-files",
     "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --test-files",
+    "test:watch": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --auto-watch --no-single-run --test-files",
     "polylint": "npm run safe_bazelisk test //polygerrit-ui/app:polylint_test",
-    "polylint:dev": "rm -rf ./polygerrit-ui/app/tmpl_out && npm run safe_bazelisk build //polygerrit-ui/app:template_test_tar && mkdir ./polygerrit-ui/app/tmpl_out && tar -xf bazel-bin/polygerrit-ui/app/template_test_tar.tar -C ./polygerrit-ui/app/tmpl_out"
+    "polylint:dev": "rm -rf ./polygerrit-ui/app/tmpl_out && npm run safe_bazelisk build //polygerrit-ui/app:template_test_tar && mkdir ./polygerrit-ui/app/tmpl_out && tar -xf bazel-bin/polygerrit-ui/app/template_test_tar.tar -C ./polygerrit-ui/app/tmpl_out",
+    "watch": "npm run compile:local && run-p -r compile:watch \"test:watch -- {*}\" --"
   },
   "repository": {
     "type": "git",
diff --git a/plugins/BUILD b/plugins/BUILD
index aedcade..32efa3e 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -73,7 +73,6 @@
     "//lib/auto:auto-value-gson",
     "//lib/commons:compress",
     "//lib/commons:dbcp",
-    "//lib/commons:lang",
     "//lib/commons:lang3",
     "//lib/dropwizard:dropwizard-core",
     "//lib/flogger:api",
@@ -85,7 +84,6 @@
     "//lib/httpcomponents:httpcore",
     "//lib:jgit-servlet",
     "//lib:jgit",
-    "//lib:jgit-ssh-jsch",
     "//lib:jsr305",
     "//lib/log:api",
     "//lib/log:log4j",
@@ -100,7 +98,6 @@
     "//lib:guava-retrying",
     "//lib:gson",
     "//lib:icu4j",
-    "//lib:jsch",
     "//lib:mime-util",
     "//lib:protobuf",
     "//lib:servlet-api-without-neverlink",
diff --git a/plugins/delete-project b/plugins/delete-project
index 612f143..5717bad 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 612f143792652d571ecfcb19915ad5754a3ba1a7
+Subproject commit 5717badf4250dfe900c05fc00d0758a09ba77297
diff --git a/plugins/download-commands b/plugins/download-commands
index b4209a5..b90e523 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit b4209a5a4c334077b255002cbadc2ef659adee3c
+Subproject commit b90e523f589a0e2902823233010163f453243926
diff --git a/plugins/gitiles b/plugins/gitiles
index 406c99f..24529d2 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 406c99fc5c5a5b88d09767690e07a5488d7496b2
+Subproject commit 24529d232268ac51fd6850770f70dc0fcd732dd8
diff --git a/plugins/hooks b/plugins/hooks
index 4e07d16..3007362 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 4e07d16a644ea823f6538a176621acee466d865b
+Subproject commit 30073628612bce23826f4be71bfdd159da521cbc
diff --git a/plugins/package.json b/plugins/package.json
index e5d245c..6fdb0fc 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -3,10 +3,10 @@
     "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
     "browser": true,
     "dependencies": {
-      "@polymer/decorators": "^3.0.0",
-      "@polymer/polymer": "^3.4.1",
-      "@gerritcodereview/typescript-api": "3.4.4",
-      "lit": "^2.0.2"
+        "@gerritcodereview/typescript-api": "3.4.4",
+        "@polymer/decorators": "^3.0.0",
+        "@polymer/polymer": "^3.4.1",
+        "lit": "^2.2.3"
     },
     "license": "Apache-2.0",
     "private": true
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index e0664f6..ba74d49 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit e0664f668ab5bac96a1e105b80d886de66743b1b
+Subproject commit ba74d4969462c2592bcf97868dd76c33041d47b2
diff --git a/plugins/replication b/plugins/replication
index 694b340..47ee3da 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 694b34084ca623756a9ec7e6a60631ab6027d5c2
+Subproject commit 47ee3dab0dd96900e85662adf0d5f48a33d17733
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index a28ae59..10db2cf 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit a28ae590486934690e4e0a95d7eb75f8b60644a6
+Subproject commit 10db2cf772989d031c6f3558010c51fe07cf9722
diff --git a/plugins/webhooks b/plugins/webhooks
index ff779a3..1dc0a71 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit ff779a381b120869efced4cc5331e62ba18e6828
+Subproject commit 1dc0a718839f8872a59c189da7243ee77a4fe782
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 4cbe489..4cb70a6 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -7,10 +7,10 @@
   resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.4.4.tgz#9f09687038088dd7edd3b4e30d249502eb21bfbc"
   integrity sha512-MAiQwntcQ59b92yYDsVIXj3oBbAB4C7HELkLFFbYs4ZjzC43XqqtR9VF0dh5OUC8wzFZttgUiOmGehk9edpPuw==
 
-"@lit/reactive-element@^1.0.0":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.1.tgz#853cacd4d78d79059f33f66f8e7b0e5c34bee294"
-  integrity sha512-nSD5AA2AZkKuXuvGs8IK7K5ZczLAogfDd26zT9l6S7WzvqALdVWcW5vMUiTnZyj5SPcNwNNANj0koeV1ieqTFQ==
+"@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==
 
 "@polymer/decorators@^3.0.0":
   version "3.0.0"
@@ -36,26 +36,26 @@
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
   integrity sha512-L5O/+UPum8erOleNjKq6k58GVl3fNsEQdSOyh0EUhNmi7tHUyRuCJy1uqJiWydWcLARE5IPsMoPYMZmUGrz1JA==
 
-lit-element@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.1.tgz#3c545af17d8a46268bc1dd5623a47486e6ff76f4"
-  integrity sha512-vs9uybH9ORyK49CFjoNGN85HM9h5bmisU4TQ63phe/+GYlwvY/3SIFYKdjV6xNvzz8v2MnVC+9+QOkPqh+Q3Ew==
+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.0.0"
-    lit-html "^2.0.0"
+    "@lit/reactive-element" "^1.3.0"
+    lit-html "^2.2.0"
 
-lit-html@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.1.tgz#63241015efa07bc9259b6f96f04abd052d2a1f95"
-  integrity sha512-KF5znvFdXbxTYM/GjpdOOnMsjgRcFGusTnB54ixnCTya5zUR0XqrDRj29ybuLS+jLXv1jji6Y8+g4W7WP8uL4w==
+lit-html@^2.2.0:
+  version "2.2.6"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.2.6.tgz#e70679605420a34c4f3cbd0c483b2fb1fff781df"
+  integrity sha512-xOKsPmq/RAKJ6dUeOxhmOYFjcjf0Q7aSdfBJgdJkOfCUnkmmJPxNrlZpRBeVe1Gg50oYWMlgm6ccAE/SpJgSdw==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
-lit@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.2.tgz#5e6f422924e0732258629fb379556b6d23f7179c"
-  integrity sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==
+lit@^2.2.3:
+  version "2.2.6"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.2.6.tgz#4ef223e88517c000b0c01baf2e3535e61a75a5b5"
+  integrity sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==
   dependencies:
-    "@lit/reactive-element" "^1.0.0"
-    lit-element "^3.0.0"
-    lit-html "^2.0.0"
+    "@lit/reactive-element" "^1.3.0"
+    lit-element "^3.2.0"
+    lit-html "^2.2.0"
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
index a636119..6673cdf 100644
--- a/polygerrit-ui/FE_Style_Guide.md
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -103,29 +103,6 @@
         this._count++;
     }
 }
-
-// app-context.js
-export const appContext = {
-    //...
-    mouseClickCounterService: null,
-    keypressCounterService: null,
-};
-
-// services/app-context-init.js
-export function initAppContext() {
-    //...
-    // Add the following line before the Object.defineProperties(appContext, registeredServices);
-    addService('mouseClickCounterService', () => new CounterService());
-    addService('keypressCounterService', () => new CounterService());
-    // If a service depends on other services, pass dependencies as shown below
-    // If circular dependencies exist, app-init-context tests fail with timeout or stack overflow
-    // (we are  going to improve it in the future)
-    addService('analyticService', () =>
-        new CounterService(appContext.mouseClickCounterService, appContext.keypressCounterService));
-    //...
-    // This following line must remains the last one in the initAppContext
-    Object.defineProperties(appContext, registeredServices);
-}
 ```
 
 **Bad:**
@@ -146,7 +123,7 @@
 If a class/service depends on some other service (or multiple services), the class must accept all dependencies
 as parameters in the constructor.
 
-Do not use appContext anywhere else in a class.
+Do not use getAppContext() anywhere else in a class.
 
 **Note:** This rule doesn't apply for HTML/Polymer elements classes. A browser creates instances of such classes
 implicitly and calls the constructor without parameters. See
@@ -166,19 +143,19 @@
 
 **Bad:**
 ```Javascript
-import {appContext} from "./app-context";
+import {getAppContext} from "./app-context";
 
 export class UserService {
     constructor() {
         // Incorrect: you must pass all dependencies to a constructor
-        this._restApiService = appContext.restApiService;
+        this._restApiService = getAppContext().restApiService;
     }
 }
 
 export class AdminService {
     isAdmin() {
         // Incorrect: you must pass all dependencies to a constructor
-        return appContext.restApiService.sendRequest(...);
+        return getAppContext().restApiService.sendRequest(...);
     }
 }
 
@@ -210,11 +187,11 @@
 export class MyCustomElement extends ...{
     constructor() {
         super(); //This is mandatory to call parent constructor
-        this._userService = appContext.userService;
+        this._userModel = appContext.userModel;
     }
     //...
     _getUserName() {
-        return this._userService.activeUserName();
+        return this._userModel.activeUserName();
     }
 }
 ```
@@ -226,12 +203,12 @@
 export class MyCustomElement extends ...{
     created() {
         // Incorrect: assign all dependencies in the constructor
-        this._userService = appContext.userService;
+        this._userModel = appContext.userModel;
     }
     //...
     _getUserName() {
         // Incorrect: use appContext outside of a constructor
-        return appContext.userService.activeUserName();
+        return appContext.userModel.activeUserName();
     }
 }
 ```
@@ -260,7 +237,7 @@
     constructor() {
         super();
         // Assign services here
-        this._userService = appContext.userService;
+        this._userModel = appContext.userModel;
         // Code from the created method - put it before existing actions in constructor
         createdAction1();
         createdAction2();
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index cd88f52..c66884f 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -530,7 +530,9 @@
  
 ## 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).
+Our users report bugs / feature requests related to the UI through the Gerrit
+Tracker on the [WebFrontend](https://issues.gerritcodereview.com/issues?q=componentid:1369968)
+component.
 
 If you want to help, feel free to grab one from those `New` issues without
 assignees and send us a change.
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 14f9e8c..8eaff5c 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
@@ -293,6 +299,12 @@
       extends: [require.resolve('gts/.eslintrc.json')],
       rules: {
         'no-restricted-imports': ['error', {
+          name: 'lit-html/static',
+          message: 'Use lit instead',
+        }, {
+          name: '@lit/reactive-element',
+          message: 'Use lit instead',
+        }, {
           name: '@polymer/decorators/lib/decorators',
           message: 'Use @polymer/decorators instead',
         }],
@@ -301,6 +313,11 @@
         '@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',
+        '@typescript-eslint/no-confusing-void-expression': [
+          'error',
+          {ignoreArrowShorthand: true},
+        ],
         '@typescript-eslint/no-unused-vars': [
           'error',
           {argsIgnorePattern: '^_'},
@@ -419,15 +436,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'],
       },
     },
   ],
@@ -445,5 +464,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 56c5e62..a2626c2 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -15,12 +15,13 @@
     "embed",
     "gr-diff",
     "mixins",
-    "samples",
+    "models",
     "scripts",
     "services",
     "styles",
     "types",
     "utils",
+    "workers",
 ]
 
 ts_config(
@@ -94,19 +95,9 @@
 # so template tests pass.
 # TODO: fix problems reported by template checker in these files.
 ignore_templates_list = [
-    "elements/admin/gr-admin-view/gr-admin-view_html.ts",
-    "elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts",
-    "elements/admin/gr-group-members/gr-group-members_html.ts",
-    "elements/admin/gr-group/gr-group_html.ts",
     "elements/admin/gr-permission/gr-permission_html.ts",
-    "elements/admin/gr-plugin-list/gr-plugin-list_html.ts",
     "elements/admin/gr-repo-access/gr-repo-access_html.ts",
-    "elements/admin/gr-repo-commands/gr-repo-commands_html.ts",
-    "elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts",
-    "elements/admin/gr-repo/gr-repo_html.ts",
     "elements/admin/gr-rule-editor/gr-rule-editor_html.ts",
-    "elements/change-list/gr-change-list-view/gr-change-list-view_html.ts",
-    "elements/change-list/gr-change-list/gr-change-list_html.ts",
     "elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts",
     "elements/change/gr-change-actions/gr-change-actions_html.ts",
     "elements/change/gr-change-metadata/gr-change-metadata_html.ts",
@@ -120,15 +111,15 @@
     "elements/change/gr-reply-dialog/gr-reply-dialog_html.ts",
     "elements/change/gr-reviewer-list/gr-reviewer-list_html.ts",
     "elements/change/gr-thread-list/gr-thread-list_html.ts",
-    "elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts",
-    "elements/diff/gr-diff-host/gr-diff-host_html.ts",
     "elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts",
-    "elements/diff/gr-diff-view/gr-diff-view_html.ts",
-    "elements/diff/gr-diff/gr-diff_html.ts",
-    "elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts",
     "elements/gr-app-element_html.ts",
     "elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts",
     "elements/shared/gr-account-list/gr-account-list_html.ts",
+    "embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts",
+    "embed/diff/gr-diff-host/gr-diff-host_html.ts",
+    "embed/diff/gr-diff-view/gr-diff-view_html.ts",
+    "embed/diff/gr-diff/gr-diff_html.ts",
+    "models/dependency.ts",
 ]
 
 sources_for_template_checking = glob(
@@ -170,9 +161,9 @@
         "$(location tsconfig_template_test.json)",
     ],
     data = [
-        "tsconfig_template_test.json",
-        "tsconfig_bazel.json",
         "tsconfig.json",
+        "tsconfig_bazel.json",
+        "tsconfig_template_test.json",
         "//tools/node_tools:tsc-bin",
         "@ui_npm//:node_modules",
     ] + templates_srcs + sources_for_template_checking,
@@ -246,6 +237,7 @@
         "tsconfig_eslint.json",
         # tsconfig_eslint.json extends tsconfig.json, pass it as a dependency
         "tsconfig.json",
+        "@npm//typescript",
     ],
     extensions = [
         ".html",
@@ -275,22 +267,29 @@
             "**/*_test.ts",
         ],
     ) + [
+        "@npm//typescript",
         "@ui_dev_npm//:node_modules",
         "@ui_npm//:node_modules",
     ],
 )
 
-nodejs_binary(
+nodejs_test(
     name = "lit_analysis",
     data = [
         ":lit_analysis_src_code",
         "@npm//lit-analyzer",
     ],
     entry_point = "@npm//:node_modules/lit-analyzer/cli.js",
+    tags = [
+        "local",
+        "manual",
+    ],
     templated_args = [
         "**/elements/**/*.ts",
         "--strict",
         "--rules.no-property-visibility-mismatch off",
         "--rules.no-incompatible-property-type off",
+        "--rules.no-incompatible-type-binding off",
+        "--rules.no-unknown-attribute error",
     ],
 )
diff --git a/polygerrit-ui/app/api/change-actions.ts b/polygerrit-ui/app/api/change-actions.ts
index 4380195..e3143b8 100644
--- a/polygerrit-ui/app/api/change-actions.ts
+++ b/polygerrit-ui/app/api/change-actions.ts
@@ -42,7 +42,6 @@
   DELETE_EDIT = 'deleteEdit',
   EDIT = 'edit',
   FOLLOW_UP = 'followup',
-  IGNORE = 'ignore',
   MOVE = 'move',
   PRIVATE = 'private',
   PRIVATE_DELETE = 'private.delete',
@@ -56,7 +55,6 @@
   REVIEWED = 'reviewed',
   STOP_EDIT = 'stopEdit',
   SUBMIT = 'submit',
-  UNIGNORE = 'unignore',
   UNREVIEWED = 'unreviewed',
   WIP = 'wip',
   INCLUDED_IN = 'includedIn',
diff --git a/polygerrit-ui/app/api/change-reply.ts b/polygerrit-ui/app/api/change-reply.ts
index 0b00b10..3d652db 100644
--- a/polygerrit-ui/app/api/change-reply.ts
+++ b/polygerrit-ui/app/api/change-reply.ts
@@ -30,7 +30,7 @@
 ) => void;
 
 export declare interface ChangeReplyPluginApi {
-  getLabelValue(label: string): string;
+  getLabelValue(label: string): string | number | undefined;
 
   setLabelValue(label: string, value: string): void;
 
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index d52a555..3d2b1bd 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -97,6 +97,11 @@
   actions?: Action[];
 
   /**
+   * Shown prominently in the change summary below the run chips.
+   */
+  summaryMessage?: string;
+
+  /**
    * Top-level links that are not associated with a specific run or result.
    * Will be shown as icons in the header of the Checks tab.
    */
@@ -186,7 +191,11 @@
    * RUNNABLE:  Not run (yet). Mostly useful for runs that the user can trigger
    *            (see actions) and for indicating that a check was not run at a
    *            later attempt. Cannot contain results.
-   * RUNNING:   Subsumes "scheduled".
+   * RUNNING:   The run is in progress.
+   * SCHEDULED: Refinement of RUNNING: The run was triggered, but is not yet
+   *            running. It may have to wait for resources or for some other run
+   *            to finish. The UI treats this mostly identical to RUNNING, but
+   *            uses a differnt icon.
    * COMPLETED: The attempt of the run has finished. Does not indicate at all
    *            whether the run was successful or not. Outcomes can and should
    *            be modeled using the CheckResult entity.
@@ -221,12 +230,16 @@
    * each plugin. The most important actions (which get special UI treatment)
    * are:
    * "Run" for RUNNABLE and COMPLETED runs.
-   * "Cancel" for RUNNING runs.
+   * "Cancel" for RUNNING and SCHEDULED runs.
    */
   actions?: Action[];
 
   scheduledTimestamp?: Date;
   startedTimestamp?: Date;
+  /**
+   * For RUNNING runs this is considered to be an estimate of when the run will
+   * be finished.
+   */
   finishedTimestamp?: Date;
 
   /**
@@ -316,9 +329,11 @@
   errorMessage?: string;
 }
 
+/** See CheckRun.status for documentation. */
 export enum RunStatus {
   RUNNABLE = 'RUNNABLE',
   RUNNING = 'RUNNING',
+  SCHEDULED = 'SCHEDULED',
   COMPLETED = 'COMPLETED',
 }
 
@@ -372,9 +387,11 @@
    * responsible for not killing the browser. :-)
    *
    * For now this is just a plain unformatted string. The only formatting
-   * applied is the one that Gerrit also applies to human comments. TBD: Both
-   * human comments and check result messages should get richer formatting
-   * options.
+   * applied is the one that Gerrit also applies to human comments.
+   *
+   * To provide richer formatting to the check result messages you should use
+   * the `check-result-expanded` plugin endpoint to attach a Web Component.
+   * See `Documentation/pg-plugin-endpoints.txt`.
    */
   message?: string;
 
@@ -407,7 +424,11 @@
   links?: Link[];
 
   /**
-   * Links to lines of code. The referenced path must be part of this patchset.
+   * Link to lines of code. The referenced path must be part of this patchset.
+   *
+   * Only one code pointer is supported. If the array contains, more than one
+   * pointer, then all the other pointers will be ignored. Support for multiple
+   * code pointers will only added on demand.
    */
   codePointers?: CodePointer[];
 
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 905d6be..08e2e66 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -50,6 +50,8 @@
   content: DiffContent[];
   /** Whether the file is binary. */
   binary?: boolean;
+  /** A list of strings representing the patch set diff header. */
+  diff_header?: string[];
 }
 
 /**
@@ -245,6 +247,13 @@
   image_diff_prefs?: ImageDiffPreferences;
   responsive_mode?: DiffResponsiveMode;
   num_lines_rendered_at_once?: number;
+  /**
+   * If enabled, then a new (experimental) diff rendering is used that is
+   * based on Lit components and multiple rendering passes. This is planned to
+   * be a temporary setting until the experiment is concluded.
+   */
+  use_lit_components?: boolean;
+  show_sign_col?: boolean;
 }
 
 /**
@@ -284,7 +293,9 @@
 }
 
 export declare interface LineRange {
+  /** 1-based, inclusive. */
   start_line: number;
+  /** 1-based, inclusive. */
   end_line: number;
 }
 
@@ -323,6 +334,11 @@
   lineNum: LineNumber;
 }
 
+export declare interface DisplayLine {
+  side: Side;
+  lineNum: LineNumber;
+}
+
 /** All types of button for expanding diff sections */
 export enum ContextButtonType {
   ABOVE = 'above',
@@ -446,6 +462,14 @@
 /** An instance of the GrDiff Webcomponent */
 export declare interface GrDiff extends HTMLElement {
   /**
+   * A line that should not be collapsed, e.g. because it contains a
+   * search result, or is pointed to from the URL.
+   * This is considered during rendering, but changing this does not
+   * automatically trigger a re-render.
+   */
+  lineOfInterest?: DisplayLine;
+
+  /**
    * Return line number element for reading only,
    *
    * This is useful e.g. to determine where on screen certain lines are,
@@ -484,5 +508,20 @@
 
   createCommentInPlace(): void;
   resetScrollMode(): void;
-  moveToLineNumber(lineNum: number, side: Side, path?: string): void;
+
+  /**
+   * Moves to a specific line number in the diff
+   *
+   * @param lineNum which line number should be selected
+   * @param side which side should be selected
+   * @param path file path for the file that should be selected
+   * @param intentionalMove Defines if move-related controls should be applied
+   * (e.g. GrCursorManager.focusOnMove)
+   **/
+  moveToLineNumber(
+    lineNum: number,
+    side: Side,
+    path?: string,
+    intentionalMove?: boolean
+  ): void;
 }
diff --git a/polygerrit-ui/app/api/package.json b/polygerrit-ui/app/api/package.json
index 8af6832..ac1e0c8 100644
--- a/polygerrit-ui/app/api/package.json
+++ b/polygerrit-ui/app/api/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@gerritcodereview/typescript-api",
-  "version": "3.4.4",
+  "version": "3.4.5",
   "description": "Gerrit Code Review - TypeScript API",
   "homepage": "https://www.gerritcodereview.com/",
   "browser": true,
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 7a56ff7..b6fa7ee 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -49,7 +49,22 @@
 }
 
 export declare interface PluginApi {
+  /**
+   * The raw URL of the plugin's js bundle, e.g.:
+   * https://cdn.googlesource.com/polygerrit_assets/533.0/plugins/codemirror_editor/static/codemirror_editor.js'
+   */
   _url?: URL;
+  /**
+   * The base path of plugin related resources. Depends on whether the plugin
+   * was loaded from the same origin as the Gerrit web app itself.
+   *
+   * Same origin: The base path of all Gerrit URLs, e.g.:
+   * https://gerrit-review.googlesource.com/
+   *
+   * Different origin: The root path of plugin files, e.g.:
+   * https://cdn.googlesource.com/polygerrit_assets/533.0/plugins/codemirror_editor/'
+   */
+  url(): string;
   admin(): AdminPluginApi;
   annotationApi(): AnnotationPluginApi;
   attributeHelper(element: Element): AttributeHelperPluginApi;
@@ -77,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/reporting.ts b/polygerrit-ui/app/api/reporting.ts
index c3655bb..40474e1 100644
--- a/polygerrit-ui/app/api/reporting.ts
+++ b/polygerrit-ui/app/api/reporting.ts
@@ -18,6 +18,27 @@
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventDetails = any;
 
+export enum Deduping {
+  /**
+   * Only report the event once per session, even if the event details are
+   * different.
+   */
+  EVENT_ONCE_PER_SESSION = 'EVENT_ONCE_PER_SESSION',
+  /**
+   * Only report the event once per change, even if the event details are
+   * different.
+   */
+  EVENT_ONCE_PER_CHANGE = 'EVENT_ONCE_PER_CHANGE',
+  /** Only report these exact event details once per session. */
+  DETAILS_ONCE_PER_SESSION = 'DETAILS_ONCE_PER_SESSION',
+  /** Only report these exact event details once per change. */
+  DETAILS_ONCE_PER_CHANGE = 'DETAILS_ONCE_PER_CHANGE',
+}
+export declare interface ReportingOptions {
+  /** Set this, if you don't want to report *every* time. */
+  deduping?: Deduping;
+}
+
 export declare interface ReportingPluginApi {
   reportInteraction(eventName: string, details?: EventDetails): void;
 
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 3d1008c..8cbbcee 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',
@@ -286,7 +286,6 @@
   submit?: ActionInfo;
   topic?: ActionInfo;
   hashtags?: ActionInfo;
-  assignee?: ActionInfo;
   ready?: ActionInfo;
   includedIn?: ActionInfo;
 }
@@ -363,7 +362,7 @@
   submit_whole_topic?: boolean;
   disable_private_changes?: boolean;
   mergeability_computation_behavior: MergeabilityComputationBehavior;
-  enable_assignee: boolean;
+  conflicts_predicate_enabled?: boolean;
 }
 
 export type ChangeId = BrandType<string, '_changeId'>;
@@ -378,7 +377,6 @@
   branch: BranchName;
   topic?: TopicName;
   attention_set?: IdToAttentionSetMap;
-  assignee?: AccountInfo;
   hashtags?: Hashtag[];
   change_id: ChangeId;
   subject: string;
@@ -389,7 +387,6 @@
   submitter?: AccountInfo;
   starred?: boolean; // not set if false
   stars?: StarLabel[];
-  reviewed?: boolean; // not set if false
   submit_type?: SubmitType;
   mergeable?: boolean;
   submittable?: boolean;
@@ -402,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;
@@ -424,6 +421,7 @@
   contains_git_conflicts?: boolean;
   internalHost?: string; // TODO(TS): provide an explanation what is its
   submit_requirements?: SubmitRequirementResultInfo[];
+  submit_records?: SubmitRecordInfo[];
 }
 
 // The ID of the change in the format "'<project>~<branch>~<Change-Id>'"
@@ -524,6 +522,7 @@
   plugin_config?: PluginNameToPluginParametersMap;
   actions?: {[viewName: string]: ActionInfo};
   reject_empty_commit?: InheritedBooleanInfo;
+  enable_reviewer_by_email: InheritedBooleanInfo;
 }
 
 export declare interface ConfigListParameterInfo
@@ -559,7 +558,7 @@
 export declare interface ContributorAgreementInfo {
   name: string;
   description: string;
-  url: string;
+  url?: string;
   auto_verify_group?: GroupInfo;
 }
 
@@ -645,6 +644,7 @@
   report_bug_url?: string;
   // The following property is missed in doc
   primary_weblink_name?: string;
+  instance_id?: string;
 }
 
 export type GitRef = BrandType<string, '_gitRef'>;
@@ -728,12 +728,13 @@
 
 export declare interface LabelCommonInfo {
   optional?: boolean; // not set if false
+  description?: string;
 }
 
 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
@@ -951,9 +952,8 @@
   fetch?: {[protocol: string]: FetchInfo};
   commit?: CommitInfo;
   files?: {[filename: string]: FileInfo};
-  actions?: ActionNameToActionInfoMap;
   reviewed?: boolean;
-  commit_with_footers?: boolean;
+  commit_with_footers?: string;
   push_certificate?: PushCertificateInfo;
   description?: string;
   basePatchNum?: BasePatchSetNum;
@@ -981,6 +981,7 @@
   suggest: SuggestInfo;
   user: UserConfigInfo;
   default_theme?: string;
+  submit_requirement_dashboard_columns?: string[];
 }
 
 /**
@@ -1065,7 +1066,7 @@
   url: string;
   /** URL to the icon of the link. */
   image_url?: string;
-  /* The links target. */
+  /* Value of the "target" attribute for anchor elements. */
   target?: string;
 }
 
@@ -1091,9 +1092,18 @@
  */
 export declare interface SubmitRequirementExpressionInfo {
   expression: string;
-  fulfilled: boolean;
-  passing_atoms: string[];
-  failing_atoms: string[];
+  fulfilled?: boolean;
+  status?: SubmitRequirementExpressionInfoStatus;
+  passing_atoms?: string[];
+  failing_atoms?: string[];
+  error_message?: string;
+}
+
+export enum SubmitRequirementExpressionInfoStatus {
+  PASS = 'PASS',
+  FAIL = 'FAIL',
+  ERROR = 'ERROR',
+  NOT_EVALUATED = 'NOT_EVALUATED',
 }
 
 /**
@@ -1105,6 +1115,64 @@
   UNSATISFIED = 'UNSATISFIED',
   OVERRIDDEN = 'OVERRIDDEN',
   NOT_APPLICABLE = 'NOT_APPLICABLE',
+  ERROR = 'ERROR',
+  FORCED = 'FORCED',
 }
 
 export type UrlEncodedRepoName = BrandType<string, '_urlEncodedRepoName'>;
+
+/**
+ * The SubmitRecordInfo entity describes results from a submit_rule.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submit-record-info
+ */
+export declare interface SubmitRecordInfo {
+  rule_name: string;
+  status?: SubmitRecordInfoStatus;
+  labels?: SubmitRecordInfoLabel[];
+  requirements?: Requirement[];
+  error_message?: string;
+}
+
+export enum SubmitRecordInfoStatus {
+  OK = 'OK',
+  NOT_READY = 'NOT_READY',
+  CLOSED = 'CLOSED',
+  FORCED = 'FORCED',
+  RULE_ERROR = 'RULE_ERROR',
+}
+
+export enum LabelStatus {
+  /**
+   * This label provides what is necessary for submission.
+   */
+  OK = 'OK',
+  /**
+   * This label prevents the change from being submitted.
+   */
+  REJECT = 'REJECT',
+  /**
+   * The label may be set, but it's neither necessary for submission
+   * nor does it block submission if set.
+   */
+  MAY = 'MAY',
+  /**
+   * The label is required for submission, but has not been satisfied.
+   */
+  NEED = 'NEED',
+  /**
+   * The label is required for submission, but is impossible to complete.
+   * The likely cause is access has not been granted correctly by the
+   * project owner or site administrator.
+   */
+  IMPOSSIBLE = 'IMPOSSIBLE',
+  OPTIONAL = 'OPTIONAL',
+}
+
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submit-record-info
+ */
+export declare interface SubmitRecordInfoLabel {
+  label: string;
+  status: LabelStatus;
+  appliedBy: AccountInfo;
+}
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 645e770..8cdd765 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',
@@ -92,14 +92,23 @@
   TAG_UNSET_PRIVATE = 'autogenerated:gerrit:unsetPrivate',
   TAG_SET_READY = 'autogenerated:gerrit:setReadyForReview',
   TAG_SET_WIP = 'autogenerated:gerrit:setWorkInProgress',
-  TAG_SET_ASSIGNEE = 'autogenerated:gerrit:setAssignee',
-  TAG_UNSET_ASSIGNEE = 'autogenerated:gerrit:deleteAssignee',
   TAG_MERGED = 'autogenerated:gerrit:merged',
   TAG_REVERT = 'autogenerated:gerrit:revert',
 }
 
 /**
- * @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',
+}
+
+/**
+ * @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.
@@ -110,7 +119,7 @@
 }
 
 /**
- * @desc Special file paths
+ * Special file paths
  */
 export enum SpecialFilePath {
   PATCHSET_LEVEL_COMMENTS = '/PATCHSET_LEVEL',
@@ -251,15 +260,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,
-    default_diff_view: DiffViewMode.SIDE_BY_SIDE,
     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
@@ -308,3 +321,5 @@
 export const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
 
 export const SHOWN_ITEMS_COUNT = 25;
+
+export const WAITING = 'Waiting';
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 a10bdda..8818066 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -46,10 +46,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 +62,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,20 +78,48 @@
   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',
   // Time to render a batch of rows in the file list. If there are very many files, this may be the first batch of rows that are rendered by default. If there are many files and the user clicks [Show More], this may be the batch of additional files that appear as a result.
   FILE_RENDER = 'FileListRenderTime',
-  // This measures the same interval as FileListRenderTime, but the result is divided by the number of rows in the batch.
-  FILE_RENDER_AVG = 'FileListRenderTimePerFile',
   // The time to expand some number of diffs in the file list (i.e. render their diffs, including syntax highlighting).
   FILE_EXPAND_ALL = 'ExpandAllDiffs',
-  // This measures the same interval as ExpandAllDiffs, but the result is divided by the number of diffs expanded.
-  FILE_EXPAND_ALL_AVG = 'ExpandAllPerDiff',
+  // Time for making the REST API call of creating a draft comment.
+  DRAFT_CREATE = 'CreateDraftComment',
+  // Time for making the REST API call of update a draft comment.
+  DRAFT_UPDATE = 'UpdateDraftComment',
+  // Time for making the REST API call of deleting a draft comment.
+  DRAFT_DISCARD = 'DiscardDraftComment',
+  // Time to load checks from all providers for the first time.
+  CHECKS_LOAD = 'ChecksLoad',
 }
 
 export enum Interaction {
   TOGGLE_SHOW_ALL_BUTTON = 'toggle show all button',
   SHOW_TAB = 'show-tab',
   ATTENTION_SET_CHIP = 'attention-set-chip',
+  SAVE_COMMENT = 'save-comment',
+  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',
+  CHECKS_RESULT_ROW_TOGGLE = 'checks-result-row-toggle',
+  CHECKS_ACTION_TRIGGERED = 'checks-action-triggered',
+  CHECKS_TAG_CLICKED = 'checks-tag-clicked',
+  CHECKS_RESULT_FILTER_CHANGED = 'checks-result-filter-changed',
+  CHECKS_RESULT_SECTION_TOGGLE = 'checks-result-section-toggle',
+  CHECKS_RESULT_SECTION_SHOW_ALL = 'checks-result-section-show-all',
+  CHECKS_RUN_SELECTED = 'checks-run-selected',
+  CHECKS_RUN_LINK_CLICKED = 'checks-run-link-clicked',
+  CHECKS_RUN_FILTER_CHANGED = 'checks-run-filter-changed',
+  CHECKS_RUN_SECTION_TOGGLE = 'checks-run-section-toggle',
+  CHECKS_ATTEMPT_SELECTED = 'checks-attempt-selected',
+  CHECKS_RUNS_PANEL_TOGGLE = 'checks-runs-panel-toggle',
+  CHECKS_RUNS_SELECTED_TRIGGERED = 'checks-runs-selected-triggered',
+  CHECKS_STATS = 'checks-stats',
 }
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 67acf00f..2c83ed3 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -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 58d18fc..0000000
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
+++ /dev/null
@@ -1,528 +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);
-
-      permission = {
-        id: 'non-existent',
-        value: {},
-      };
-      assert.isUndefined(element._computePermissionName(name, permission,
-          element.capabilities));
-
-      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..1c4c437
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
@@ -0,0 +1,645 @@
+/**
+ * @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
+      );
+
+      permission = {
+        id: 'non-existent' as GitRef,
+        value: {rules: {}},
+      };
+      assert.isUndefined(element.computePermissionName(permission));
+
+      element.section.id = 'refs/for/*' as GitRef;
+      permission = {
+        id: 'abandon' as GitRef,
+        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-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index ea70d7e..ba737a7 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -15,23 +15,23 @@
  * limitations under the License.
  */
 
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-group-dialog/gr-create-group-dialog';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-admin-group-list_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, observe, computed} from '@polymer/decorators';
 import {AppElementAdminParams} from '../../gr-app-types';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
 import {fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, query, property, state} from 'lit/decorators';
+import {assertIsDefined} from '../../../utils/common-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -39,18 +39,13 @@
   }
 }
 
-export interface GrAdminGroupList {
-  $: {
-    createOverlay: GrOverlay;
-    createNewModal: GrCreateGroupDialog;
-  };
-}
-
 @customElement('gr-admin-group-list')
-export class GrAdminGroupList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrAdminGroupList extends LitElement {
+  readonly path = '/admin/groups';
+
+  @query('#createOverlay') private createOverlay?: GrOverlay;
+
+  @query('#createNewModal') private createNewModal?: GrCreateGroupDialog;
 
   @property({type: Object})
   params?: AppElementAdminParams;
@@ -58,131 +53,208 @@
   /**
    * Offset of currently visible query results.
    */
-  @property({type: Number})
-  _offset = 0;
+  @state() private offset = 0;
 
-  @property({type: String})
-  readonly _path = '/admin/groups';
+  @state() private hasNewGroupName = false;
 
-  @property({type: Boolean})
-  _hasNewGroupName = false;
+  @state() private createNewCapability = false;
 
-  @property({type: Boolean})
-  _createNewCapability = false;
+  // private but used in test
+  @state() groups: GroupInfo[] = [];
 
-  @property({type: Array})
-  _groups: GroupInfo[] = [];
+  @state() private groupsPerPage = 25;
 
-  /**
-   * Because  we request one more than the groupsPerPage, _shownGroups
-   * may be one less than _groups.
-   * */
-  @computed('_groups')
-  get _shownGroups() {
-    return this._groups.slice(0, SHOWN_ITEMS_COUNT);
-  }
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: Number})
-  _groupsPerPage = 25;
+  @state() private filter = '';
 
-  @property({type: Boolean})
-  _loading = true;
-
-  @property({type: String})
-  _filter = '';
-
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
-    this._getCreateGroupCapability();
+    this.getCreateGroupCapability();
     fireTitleChange(this, 'Groups');
-    this._maybeOpenCreateOverlay(this.params);
   }
 
-  @observe('params')
-  _paramsChanged(params: AppElementAdminParams) {
-    this._loading = true;
-    this._filter = params?.filter ?? '';
-    this._offset = Number(params?.offset ?? 0);
+  static override get styles() {
+    return [
+      tableStyles,
+      sharedStyles,
+      css`
+        gr-list-view {
+          --generic-list-description-width: 70%;
+        }
+      `,
+    ];
+  }
 
-    return this._getGroups(this._filter, this._groupsPerPage, this._offset);
+  override render() {
+    return html`
+      <gr-list-view
+        .createNew=${this.createNewCapability}
+        .filter=${this.filter}
+        .items=${this.groups}
+        .itemsPerPage=${this.groupsPerPage}
+        .loading=${this.loading}
+        .offset=${this.offset}
+        .path=${this.path}
+        @create-clicked=${() => this.handleCreateClicked()}
+      >
+        <table id="list" class="genericList">
+          <tbody>
+            <tr class="headerRow">
+              <th class="name topHeader">Group Name</th>
+              <th class="description topHeader">Group Description</th>
+              <th class="visibleToAll topHeader">Visible To All</th>
+            </tr>
+            <tr
+              id="loading"
+              class="loadingMsg ${this.loading ? 'loading' : ''}"
+            >
+              <td>Loading...</td>
+            </tr>
+          </tbody>
+          <tbody class=${this.loading ? 'loading' : ''}>
+            ${this.groups
+              .slice(0, SHOWN_ITEMS_COUNT)
+              .map(group => this.renderGroupList(group))}
+          </tbody>
+        </table>
+      </gr-list-view>
+      <gr-overlay id="createOverlay" with-backdrop>
+        <gr-dialog
+          id="createDialog"
+          class="confirmDialog"
+          ?disabled=${!this.hasNewGroupName}
+          confirm-label="Create"
+          confirm-on-enter
+          @confirm=${() => this.handleCreateGroup()}
+          @cancel=${() => this.handleCloseCreate()}
+        >
+          <div class="header" slot="header">Create Group</div>
+          <div class="main" slot="main">
+            <gr-create-group-dialog
+              id="createNewModal"
+              @has-new-group-name=${this.handleHasNewGroupName}
+            ></gr-create-group-dialog>
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private renderGroupList(group: GroupInfo) {
+    return html`
+      <tr class="table">
+        <td class="name">
+          <a href=${this.computeGroupUrl(group.id)}>${group.name}</a>
+        </td>
+        <td class="description">${group.description}</td>
+        <td class="visibleToAll">
+          ${group.options?.visible_to_all === true ? 'Y' : 'N'}
+        </td>
+      </tr>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this.paramsChanged();
+    }
+  }
+
+  // private but used in test
+  paramsChanged() {
+    this.filter = this.params?.filter ?? '';
+    this.offset = Number(this.params?.offset ?? 0);
+    this.maybeOpenCreateOverlay(this.params);
+
+    return this.getGroups(this.filter, this.groupsPerPage, this.offset);
   }
 
   /**
    * Opens the create overlay if the route has a hash 'create'
+   *
+   * private but used in test
    */
-  _maybeOpenCreateOverlay(params?: AppElementAdminParams) {
+  maybeOpenCreateOverlay(params?: AppElementAdminParams) {
     if (params?.openCreateModal) {
-      this.$.createOverlay.open();
+      assertIsDefined(this.createOverlay, 'createOverlay');
+      this.createOverlay.open();
     }
   }
 
   /**
    * Generates groups link (/admin/groups/<uuid>)
+   *
+   * private but used in test
    */
-  _computeGroupUrl(id: string) {
+  computeGroupUrl(id: string) {
     return GerritNav.getUrlForGroup(decodeURIComponent(id) as GroupId);
   }
 
-  _getCreateGroupCapability() {
+  private getCreateGroupCapability() {
     return this.restApiService.getAccount().then(account => {
-      if (!account) {
-        return;
-      }
+      if (!account) return;
       return this.restApiService
         .getAccountCapabilities(['createGroup'])
         .then(capabilities => {
           if (capabilities?.createGroup) {
-            this._createNewCapability = true;
+            this.createNewCapability = true;
           }
         });
     });
   }
 
-  _getGroups(filter: string, groupsPerPage: number, offset?: number) {
-    this._groups = [];
+  private getGroups(filter: string, groupsPerPage: number, offset?: number) {
+    this.groups = [];
+    this.loading = true;
     return this.restApiService
       .getGroups(filter, groupsPerPage, offset)
       .then(groups => {
-        if (!groups) {
-          return;
-        }
-        this._groups = Object.keys(groups).map(key => {
+        if (!groups) return;
+        this.groups = Object.keys(groups).map(key => {
           const group = groups[key];
           group.name = key as GroupName;
           return group;
         });
-        this._loading = false;
+      })
+      .finally(() => {
+        this.loading = false;
       });
   }
 
-  _refreshGroupsList() {
+  private refreshGroupsList() {
     this.restApiService.invalidateGroupsCache();
-    return this._getGroups(this._filter, this._groupsPerPage, this._offset);
+    return this.getGroups(this.filter, this.groupsPerPage, this.offset);
   }
 
-  _handleCreateGroup() {
-    this.$.createNewModal.handleCreateGroup().then(() => {
-      this._refreshGroupsList();
+  // private but used in test
+  handleCreateGroup() {
+    assertIsDefined(this.createNewModal, 'createNewModal');
+    this.createNewModal.handleCreateGroup().then(() => {
+      this.refreshGroupsList();
     });
   }
 
-  _handleCloseCreate() {
-    this.$.createOverlay.close();
+  // private but used in test
+  handleCloseCreate() {
+    assertIsDefined(this.createOverlay, 'createOverlay');
+    this.createOverlay.close();
   }
 
-  _handleCreateClicked() {
-    this.$.createOverlay.open().then(() => {
-      this.$.createNewModal.focus();
+  // private but used in test
+  handleCreateClicked() {
+    assertIsDefined(this.createOverlay, 'createOverlay');
+    this.createOverlay.open().then(() => {
+      assertIsDefined(this.createNewModal, 'createNewModal');
+      this.createNewModal.focus();
     });
   }
 
-  _visibleToAll(item: GroupInfo) {
-    return item.options?.visible_to_all === true ? 'Y' : 'N';
-  }
-
-  computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
+  private handleHasNewGroupName() {
+    assertIsDefined(this.createNewModal, 'createNewModal');
+    this.hasNewGroupName = !!this.createNewModal.name;
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
deleted file mode 100644
index 91863a9..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_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">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <gr-list-view
-    create-new="[[_createNewCapability]]"
-    filter="[[_filter]]"
-    items="[[_groups]]"
-    items-per-page="[[_groupsPerPage]]"
-    loading="[[_loading]]"
-    offset="[[_offset]]"
-    on-create-clicked="_handleCreateClicked"
-    path="[[_path]]"
-  >
-    <table id="list" class="genericList">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Group Name</th>
-          <th class="description topHeader">Group Description</th>
-          <th class="visibleToAll topHeader">Visible To All</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_shownGroups]]">
-          <tr class="table">
-            <td class="name">
-              <a href$="[[_computeGroupUrl(item.id)]]">[[item.name]]</a>
-            </td>
-            <td class="description">[[item.description]]</td>
-            <td class="visibleToAll">[[_visibleToAll(item)]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </gr-list-view>
-  <gr-overlay id="createOverlay" with-backdrop="">
-    <gr-dialog
-      id="createDialog"
-      class="confirmDialog"
-      disabled="[[!_hasNewGroupName]]"
-      confirm-label="Create"
-      confirm-on-enter=""
-      on-confirm="_handleCreateGroup"
-      on-cancel="_handleCloseCreate"
-    >
-      <div class="header" slot="header">Create Group</div>
-      <div class="main" slot="main">
-        <gr-create-group-dialog
-          has-new-group-name="{{_hasNewGroupName}}"
-          id="createNewModal"
-        ></gr-create-group-dialog>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
deleted file mode 100644
index 7b7b959..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
+++ /dev/null
@@ -1,184 +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-admin-group-list.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import 'lodash/lodash.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-admin-group-list');
-
-let counter = 0;
-const groupGenerator = () => {
-  return {
-    name: `test${++counter}`,
-    id: '59b92f35489e62c80d1ab1bf0c2d17843038df8b',
-    url: '#/admin/groups/uuid-59b92f35489e62c80d1ab1bf0c2d17843038df8b',
-    options: {
-      visible_to_all: false,
-    },
-    description: 'Gerrit Site Administrators',
-    group_id: 1,
-    owner: 'Administrators',
-    owner_id: '7ca042f4d5847936fcb90ca91057673157fd06fc',
-  };
-};
-
-suite('gr-admin-group-list tests', () => {
-  let element;
-  let groups;
-
-  let value;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_computeGroupUrl', () => {
-    let urlStub = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
-        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-    let group = {
-      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
-    };
-    assert.equal(element._computeGroupUrl(group),
-        '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-    urlStub.restore();
-
-    urlStub = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
-        () => '/admin/groups/user/test');
-
-    group = {
-      id: 'user%2Ftest',
-    };
-    assert.equal(element._computeGroupUrl(group),
-        '/admin/groups/user/test');
-
-    urlStub.restore();
-  });
-
-  suite('list with groups', () => {
-    setup(async () => {
-      groups = _.times(26, groupGenerator);
-      stubRestApi('getGroups').returns(Promise.resolve(groups));
-      element._paramsChanged(value);
-      await flush();
-    });
-
-    test('test for test group in the list', () => {
-      assert.equal(element._groups[1].name, '1');
-      assert.equal(element._groups[1].options.visible_to_all, false);
-    });
-
-    test('_shownGroups', () => {
-      assert.equal(element._shownGroups.length, 25);
-    });
-
-    test('_maybeOpenCreateOverlay', () => {
-      const overlayOpen = sinon.stub(element.$.createOverlay, 'open');
-      element._maybeOpenCreateOverlay();
-      assert.isFalse(overlayOpen.called);
-      const params = {};
-      element._maybeOpenCreateOverlay(params);
-      assert.isFalse(overlayOpen.called);
-      params.openCreateModal = true;
-      element._maybeOpenCreateOverlay(params);
-      assert.isTrue(overlayOpen.called);
-    });
-  });
-
-  suite('test with less then 25 groups', () => {
-    setup(async () => {
-      groups = _.times(25, groupGenerator);
-      stubRestApi('getGroups').returns(Promise.resolve(groups));
-      await element._paramsChanged(value);
-      await flush();
-    });
-
-    test('_shownGroups', () => {
-      assert.equal(element._shownGroups.length, 25);
-    });
-  });
-
-  suite('filter', () => {
-    test('_paramsChanged', async () => {
-      const getGroupsStub = stubRestApi('getGroups');
-      getGroupsStub.returns(Promise.resolve(groups));
-      const value = {
-        filter: 'test',
-        offset: 25,
-      };
-      await element._paramsChanged(value);
-      assert.isTrue(getGroupsStub.lastCall.calledWithExactly('test', 25, 25));
-    });
-  });
-
-  suite('loading', () => {
-    test('correct contents are displayed', () => {
-      assert.isTrue(element._loading);
-      assert.equal(element.computeLoadingClass(element._loading), 'loading');
-      assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-      element._loading = false;
-      element._groups = _.times(25, groupGenerator);
-
-      flush();
-      assert.equal(element.computeLoadingClass(element._loading), '');
-      assert.equal(getComputedStyle(element.$.loading).display, 'none');
-    });
-  });
-
-  suite('create new', () => {
-    test('_handleCreateClicked called when create-click fired', () => {
-      sinon.stub(element, '_handleCreateClicked');
-      element.shadowRoot
-          .querySelector('gr-list-view').dispatchEvent(
-              new CustomEvent('create-clicked', {
-                composed: true, bubbles: true,
-              }));
-      assert.isTrue(element._handleCreateClicked.called);
-    });
-
-    test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
-          Promise.resolve());
-      element._handleCreateClicked();
-      assert.isTrue(openStub.called);
-    });
-
-    test('_handleCreateGroup called when confirm fired', () => {
-      sinon.stub(element, '_handleCreateGroup');
-      element.$.createDialog.dispatchEvent(
-          new CustomEvent('confirm', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCreateGroup.called);
-    });
-
-    test('_handleCloseCreate called when cancel fired', () => {
-      sinon.stub(element, '_handleCloseCreate');
-      element.$.createDialog.dispatchEvent(
-          new CustomEvent('cancel', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCloseCreate.called);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
new file mode 100644
index 0000000..709a0b7
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
@@ -0,0 +1,229 @@
+/**
+ * @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-admin-group-list';
+import {GrAdminGroupList} from './gr-admin-group-list';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  GroupId,
+  GroupName,
+  GroupNameToGroupInfoMap,
+} from '../../../types/common';
+import {AppElementAdminParams} from '../../gr-app-types';
+import {GerritView} from '../../../services/router/router-model';
+import {GrListView} from '../../shared/gr-list-view/gr-list-view';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+
+const basicFixture = fixtureFromElement('gr-admin-group-list');
+
+function createGroup(name: string, counter: number) {
+  return {
+    name: `${name}${counter}` as GroupName,
+    id: '59b92f35489e62c80d1ab1bf0c2d17843038df8b' as GroupId,
+    url: '#/admin/groups/uuid-59b92f35489e62c80d1ab1bf0c2d17843038df8b',
+    options: {
+      visible_to_all: false,
+    },
+    description: 'Gerrit Site Administrators',
+    group_id: 1,
+    owner: 'Administrators',
+    owner_id: '7ca042f4d5847936fcb90ca91057673157fd06fc',
+  };
+}
+
+function createGroupList(name: string, n: number) {
+  const groups = [];
+  for (let i = 0; i < n; ++i) {
+    groups.push(createGroup(name, i));
+  }
+  return groups;
+}
+
+function createGroupObjectList(name: string, n: number) {
+  const groups: GroupNameToGroupInfoMap = {};
+  for (let i = 0; i < n; ++i) {
+    groups[`${name}${i}`] = createGroup(name, i);
+  }
+  return groups;
+}
+
+suite('gr-admin-group-list tests', () => {
+  let element: GrAdminGroupList;
+  let groups: GroupNameToGroupInfoMap;
+
+  const value: AppElementAdminParams = {view: GerritView.ADMIN, adminView: ''};
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  test('computeGroupUrl', () => {
+    let urlStub = sinon
+      .stub(GerritNav, 'getUrlForGroup')
+      .callsFake(
+        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5'
+      );
+
+    let group = 'e2cd66f88a2db4d391ac068a92d987effbe872f5';
+    assert.equal(
+      element.computeGroupUrl(group),
+      '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5'
+    );
+
+    urlStub.restore();
+
+    urlStub = sinon
+      .stub(GerritNav, 'getUrlForGroup')
+      .callsFake(() => '/admin/groups/user/test');
+
+    group = 'user%2Ftest';
+    assert.equal(element.computeGroupUrl(group), '/admin/groups/user/test');
+
+    urlStub.restore();
+  });
+
+  suite('list with groups', () => {
+    setup(async () => {
+      groups = createGroupObjectList('test', 26);
+      stubRestApi('getGroups').returns(Promise.resolve(groups));
+      element.params = value;
+      element.paramsChanged();
+      await element.updateComplete;
+    });
+
+    test('test for test group in the list', () => {
+      assert.equal(element.groups[1].name, 'test1' as GroupName);
+      assert.equal(element.groups[1].options!.visible_to_all, false);
+    });
+
+    test('groups', () => {
+      assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+    });
+
+    test('maybeOpenCreateOverlay', () => {
+      const overlayOpen = sinon.stub(
+        queryAndAssert<GrOverlay>(element, '#createOverlay'),
+        'open'
+      );
+      element.maybeOpenCreateOverlay();
+      assert.isFalse(overlayOpen.called);
+      element.maybeOpenCreateOverlay(undefined);
+      assert.isFalse(overlayOpen.called);
+      value.openCreateModal = true;
+      element.maybeOpenCreateOverlay(value);
+      assert.isTrue(overlayOpen.called);
+    });
+  });
+
+  suite('test with 25 groups', () => {
+    setup(async () => {
+      groups = createGroupObjectList('test', 25);
+      stubRestApi('getGroups').returns(Promise.resolve(groups));
+      element.params = value;
+      await element.paramsChanged();
+      await element.updateComplete;
+    });
+
+    test('groups', () => {
+      assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+    });
+  });
+
+  suite('filter', () => {
+    test('paramsChanged', async () => {
+      const getGroupsStub = stubRestApi('getGroups');
+      getGroupsStub.returns(Promise.resolve(groups));
+      value.filter = 'test';
+      value.offset = 25;
+      element.params = value;
+      await element.paramsChanged();
+      assert.isTrue(getGroupsStub.lastCall.calledWithExactly('test', 25, 25));
+    });
+  });
+
+  suite('loading', async () => {
+    test('correct contents are displayed', async () => {
+      assert.isTrue(element.loading);
+      assert.equal(
+        getComputedStyle(queryAndAssert<HTMLTableElement>(element, '#loading'))
+          .display,
+        'block'
+      );
+
+      element.loading = false;
+      element.groups = createGroupList('test', 25);
+
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyle(queryAndAssert<HTMLTableElement>(element, '#loading'))
+          .display,
+        'none'
+      );
+    });
+  });
+
+  suite('create new', () => {
+    test('handleCreateClicked called when create-click fired', () => {
+      const handleCreateClickedStub = sinon.stub(
+        element,
+        'handleCreateClicked'
+      );
+      queryAndAssert<GrListView>(element, 'gr-list-view').dispatchEvent(
+        new CustomEvent('create-clicked', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleCreateClickedStub.called);
+    });
+
+    test('handleCreateClicked opens modal', () => {
+      const openStub = sinon
+        .stub(queryAndAssert<GrOverlay>(element, '#createOverlay'), 'open')
+        .returns(Promise.resolve());
+      element.handleCreateClicked();
+      assert.isTrue(openStub.called);
+    });
+
+    test('handleCreateGroup called when confirm fired', () => {
+      const handleCreateGroupStub = sinon.stub(element, 'handleCreateGroup');
+      queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
+        new CustomEvent('confirm', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleCreateGroupStub.called);
+    });
+
+    test('handleCloseCreate called when cancel fired', () => {
+      const handleCloseCreateStub = sinon.stub(element, 'handleCloseCreate');
+      queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
+        new CustomEvent('cancel', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleCloseCreateStub.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 1c9c6f3..154b470 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
@@ -14,9 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-menu-page-styles';
-import '../../../styles/gr-page-nav-styles';
-import '../../../styles/shared-styles';
+
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-icons/gr-icons';
 import '../../shared/gr-page-nav/gr-page-nav';
@@ -31,8 +29,6 @@
 import '../gr-repo-dashboards/gr-repo-dashboards';
 import '../gr-repo-detail-list/gr-repo-detail-list';
 import '../gr-repo-list/gr-repo-list';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-admin-view_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {
   GerritNav,
@@ -46,7 +42,6 @@
   NavLink,
   SubsectionInterface,
 } from '../../../utils/admin-nav-util';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
   AppElementAdminParams,
   AppElementGroupParams,
@@ -60,12 +55,18 @@
 } from '../../../types/common';
 import {GroupNameChangedDetail} from '../gr-group/gr-group';
 import {ValueChangeDetail} from '../../shared/gr-dropdown-list/gr-dropdown-list';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {GerritView} from '../../../services/router/router-model';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {ifDefined} from 'lit/directives/if-defined';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
-interface AdminSubsectionLink {
+export interface AdminSubsectionLink {
   text: string;
   value: string;
   view: GerritView;
@@ -90,11 +91,7 @@
 }
 
 @customElement('gr-admin-view')
-export class GrAdminView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAdminView extends LitElement {
   private account?: AccountDetailInfo;
 
   @property({type: Object})
@@ -106,99 +103,402 @@
   @property({type: String})
   adminView?: string;
 
-  @property({type: String})
-  _breadcrumbParentName?: string;
+  @state() private breadcrumbParentName?: string;
 
-  @property({type: String})
-  _repoName?: RepoName;
+  // private but used in test
+  @state() repoName?: RepoName;
 
-  @property({type: String, observer: '_computeGroupName'})
-  _groupId?: GroupId;
+  // private but used in test
+  @state() groupId?: GroupId;
 
-  @property({type: Boolean})
-  _groupIsInternal?: boolean;
+  // private but used in test
+  @state() groupIsInternal?: boolean;
 
-  @property({type: String})
-  _groupName?: GroupName;
+  // private but used in test
+  @state() groupName?: GroupName;
 
-  @property({type: Boolean})
-  _groupOwner = false;
+  // private but used in test
+  @state() subsectionLinks?: AdminSubsectionLink[];
 
-  @property({type: Array})
-  _subsectionLinks?: AdminSubsectionLink[];
+  // private but used in test
+  @state() filteredLinks?: NavLink[];
 
-  @property({type: Array})
-  _filteredLinks?: NavLink[];
+  private reloading = false;
 
-  @property({type: Boolean})
-  _showDownload = false;
+  // private but used in the tests
+  readonly jsAPI = getAppContext().jsApiService;
 
-  @property({type: Boolean})
-  _isAdmin = false;
-
-  @property({type: Boolean})
-  _showGroup?: boolean;
-
-  @property({type: Boolean})
-  _showGroupAuditLog?: boolean;
-
-  @property({type: Boolean})
-  _showGroupList?: boolean;
-
-  @property({type: Boolean})
-  _showGroupMembers?: boolean;
-
-  @property({type: Boolean})
-  _showRepoAccess?: boolean;
-
-  @property({type: Boolean})
-  _showRepoCommands?: boolean;
-
-  @property({type: Boolean})
-  _showRepoDashboards?: boolean;
-
-  @property({type: Boolean})
-  _showRepoDetailList?: boolean;
-
-  @property({type: Boolean})
-  _showRepoMain?: boolean;
-
-  @property({type: Boolean})
-  _showRepoList?: boolean;
-
-  @property({type: Boolean})
-  _showPluginList?: boolean;
-
-  private readonly restApiService = appContext.restApiService;
-
-  private readonly jsAPI = appContext.jsApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
     this.reload();
   }
 
-  reload() {
-    const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] = [
-      this.restApiService.getAccount(),
-      getPluginLoader().awaitPluginsLoaded(),
+  static override get styles() {
+    return [
+      sharedStyles,
+      menuPageStyles,
+      pageNavStyles,
+      css`
+        .breadcrumbText {
+          /* Same as dropdown trigger so chevron spacing is consistent. */
+          padding: 5px 4px;
+        }
+        iron-icon {
+          margin: 0 var(--spacing-xs);
+        }
+        .breadcrumb {
+          align-items: center;
+          display: flex;
+        }
+        .mainHeader {
+          align-items: baseline;
+          border-bottom: 1px solid var(--border-color);
+          display: flex;
+        }
+        .selectText {
+          display: none;
+        }
+        .selectText.show {
+          display: inline-block;
+        }
+        .main.breadcrumbs:not(.table) {
+          margin-top: var(--spacing-l);
+        }
+      `,
     ];
-    return Promise.all(promises).then(result => {
+  }
+
+  override render() {
+    return html`
+      <gr-page-nav class="navStyles">
+        <ul class="sectionContent">
+          ${this.filteredLinks?.map(item => this.renderAdminNav(item))}
+        </ul>
+      </gr-page-nav>
+      ${this.renderSubsectionLinks()} ${this.renderRepoList()}
+      ${this.renderGroupList()} ${this.renderPluginList()}
+      ${this.renderRepoMain()} ${this.renderGroup()}
+      ${this.renderGroupMembers()} ${this.renderGroupAuditLog()}
+      ${this.renderRepoDetailList()} ${this.renderRepoCommands()}
+      ${this.renderRepoAccess()} ${this.renderRepoDashboards()}
+    `;
+  }
+
+  private renderAdminNav(item: NavLink) {
+    return html`
+      <li class="sectionTitle ${this.computeSelectedClass(item.view)}">
+        <a class="title" href=${this.computeLinkURL(item)} rel="noopener"
+          >${item.name}</a
+        >
+      </li>
+      ${item.children?.map(child => this.renderAdminNavChild(child))}
+      ${this.renderAdminNavSubsection(item)}
+    `;
+  }
+
+  private renderAdminNavChild(child: SubsectionInterface) {
+    return html`
+      <li class=${this.computeSelectedClass(child.view)}>
+        <a href=${this.computeLinkURL(child)} rel="noopener">${child.name}</a>
+      </li>
+    `;
+  }
+
+  private renderAdminNavSubsection(item: NavLink) {
+    if (!item.subsection) return;
+
+    return html`
+      <!--If a section has a subsection, render that.-->
+      <li class=${this.computeSelectedClass(item.subsection.view)}>
+        ${this.renderAdminNavSubsectionUrl(item.subsection)}
+      </li>
+      <!--Loop through the links in the sub-section.-->
+      ${item.subsection?.children?.map(child =>
+        this.renderAdminNavSubsectionChild(child)
+      )}
+    `;
+  }
+
+  private renderAdminNavSubsectionUrl(subsection?: SubsectionInterface) {
+    if (!subsection!.url) return html`${subsection!.name}`;
+
+    return html`
+      <a class="title" href=${this.computeLinkURL(subsection)} rel="noopener">
+        ${subsection!.name}</a
+      >
+    `;
+  }
+
+  private renderAdminNavSubsectionChild(child: SubsectionInterface) {
+    return html`
+      <li
+        class="subsectionItem ${this.computeSelectedClass(
+          child.view,
+          child.detailType
+        )}"
+      >
+        <a href=${this.computeLinkURL(child)}>${child.name}</a>
+      </li>
+    `;
+  }
+
+  private renderSubsectionLinks() {
+    if (!this.subsectionLinks?.length) return;
+
+    return html`
+      <section class="mainHeader">
+        <span class="breadcrumb">
+          <span class="breadcrumbText">${this.breadcrumbParentName}</span>
+          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+        </span>
+        <gr-dropdown-list
+          id="pageSelect"
+          value=${ifDefined(this.computeSelectValue())}
+          .items=${this.subsectionLinks}
+          @value-change=${this.handleSubsectionChange}
+        >
+        </gr-dropdown-list>
+      </section>
+    `;
+  }
+
+  private renderRepoList() {
+    const params = this.params as AppElementAdminParams;
+    if (
+      !(
+        params?.view === GerritView.ADMIN &&
+        params?.adminView === 'gr-repo-list'
+      )
+    )
+      return;
+
+    return html`
+      <div class="main table">
+        <gr-repo-list class="table" .params=${params}></gr-repo-list>
+      </div>
+    `;
+  }
+
+  private renderGroupList() {
+    const params = this.params as AppElementAdminParams;
+    if (
+      !(
+        params?.view === GerritView.ADMIN &&
+        params?.adminView === 'gr-admin-group-list'
+      )
+    )
+      return;
+
+    return html`
+      <div class="main table">
+        <gr-admin-group-list class="table" .params=${params}>
+        </gr-admin-group-list>
+      </div>
+    `;
+  }
+
+  private renderPluginList() {
+    const params = this.params as AppElementAdminParams;
+    if (
+      !(
+        params?.view === GerritView.ADMIN &&
+        params?.adminView === 'gr-plugin-list'
+      )
+    )
+      return;
+
+    return html`
+      <div class="main table">
+        <gr-plugin-list class="table" .params=${params}></gr-plugin-list>
+      </div>
+    `;
+  }
+
+  private renderRepoMain() {
+    const params = this.params as AppElementRepoParams;
+    if (
+      !(
+        params?.view === GerritView.REPO &&
+        (!params?.detail || params?.detail === RepoDetailView.GENERAL)
+      )
+    )
+      return;
+
+    return html`
+      <div class="main breadcrumbs">
+        <gr-repo .repo=${params.repo}></gr-repo>
+      </div>
+    `;
+  }
+
+  private renderGroup() {
+    const params = this.params as AppElementGroupParams;
+    if (!(params?.view === GerritView.GROUP && !params?.detail)) return;
+
+    return html`
+      <div class="main breadcrumbs">
+        <gr-group
+          .groupId=${params.groupId}
+          @name-changed=${(e: CustomEvent<GroupNameChangedDetail>) => {
+            this.updateGroupName(e);
+          }}
+        ></gr-group>
+      </div>
+    `;
+  }
+
+  private renderGroupMembers() {
+    const params = this.params as AppElementGroupParams;
+    if (
+      !(
+        params?.view === GerritView.GROUP &&
+        params?.detail === GroupDetailView.MEMBERS
+      )
+    )
+      return;
+
+    return html`
+      <div class="main breadcrumbs">
+        <gr-group-members .groupId=${params.groupId}></gr-group-members>
+      </div>
+    `;
+  }
+
+  private renderGroupAuditLog() {
+    const params = this.params as AppElementGroupParams;
+    if (
+      !(
+        params?.view === GerritView.GROUP &&
+        params?.detail === GroupDetailView.LOG
+      )
+    )
+      return;
+
+    return html`
+      <div class="main table breadcrumbs">
+        <gr-group-audit-log
+          class="table"
+          .groupId=${params.groupId}
+        ></gr-group-audit-log>
+      </div>
+    `;
+  }
+
+  private renderRepoDetailList() {
+    const params = this.params as AppElementRepoParams;
+    if (
+      !(
+        params?.view === GerritView.REPO &&
+        (params?.detail === RepoDetailView.BRANCHES ||
+          params?.detail === RepoDetailView.TAGS)
+      )
+    )
+      return;
+
+    return html`
+      <div class="main table breadcrumbs">
+        <gr-repo-detail-list
+          class="table"
+          .params=${params}
+        ></gr-repo-detail-list>
+      </div>
+    `;
+  }
+
+  private renderRepoCommands() {
+    const params = this.params as AppElementRepoParams;
+    if (
+      !(
+        params?.view === GerritView.REPO &&
+        params?.detail === RepoDetailView.COMMANDS
+      )
+    )
+      return;
+
+    return html`
+      <div class="main breadcrumbs">
+        <gr-repo-commands .repo=${params.repo}></gr-repo-commands>
+      </div>
+    `;
+  }
+
+  private renderRepoAccess() {
+    const params = this.params as AppElementRepoParams;
+    if (
+      !(
+        params?.view === GerritView.REPO &&
+        params?.detail === RepoDetailView.ACCESS
+      )
+    )
+      return;
+
+    return html`
+      <div class="main breadcrumbs">
+        <gr-repo-access
+          .path=${this.path}
+          .repo=${params.repo}
+        ></gr-repo-access>
+      </div>
+    `;
+  }
+
+  private renderRepoDashboards() {
+    const params = this.params as AppElementRepoParams;
+    if (
+      !(
+        params?.view === GerritView.REPO &&
+        params?.detail === RepoDetailView.DASHBOARDS
+      )
+    )
+      return;
+
+    return html`
+      <div class="main table breadcrumbs">
+        <gr-repo-dashboards .repo=${params.repo}></gr-repo-dashboards>
+      </div>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this.paramsChanged();
+    }
+
+    if (changedProperties.has('groupId')) {
+      this.computeGroupName();
+    }
+  }
+
+  async reload() {
+    try {
+      this.reloading = true;
+      const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] =
+        [
+          this.restApiService.getAccount(),
+          getPluginLoader().awaitPluginsLoaded(),
+        ];
+      const result = await Promise.all(promises);
       this.account = result[0];
       let options: AdminNavLinksOption | undefined = undefined;
-      if (this._repoName) {
-        options = {repoName: this._repoName};
-      } else if (this._groupId) {
+      if (this.repoName) {
+        options = {repoName: this.repoName};
+      } else if (this.groupId) {
+        const isAdmin = await this.restApiService.getIsAdmin();
+        const isOwner = await this.restApiService.getIsGroupOwner(
+          this.groupName
+        );
         options = {
-          groupId: this._groupId,
-          groupName: this._groupName,
-          groupIsInternal: this._groupIsInternal,
-          isAdmin: this._isAdmin,
-          groupOwner: this._groupOwner,
+          groupId: this.groupId,
+          groupName: this.groupName,
+          groupIsInternal: this.groupIsInternal,
+          isAdmin,
+          groupOwner: isOwner,
         };
       }
 
-      return getAdminLinks(
+      const res = await getAdminLinks(
         this.account,
         () =>
           this.restApiService.getAccountCapabilities().then(capabilities => {
@@ -209,170 +509,114 @@
           }),
         () => this.jsAPI.getAdminMenuLinks(),
         options
-      ).then(res => {
-        this._filteredLinks = res.links;
-        this._breadcrumbParentName = res.expandedSection
-          ? res.expandedSection.name
-          : '';
+      );
+      this.filteredLinks = res.links;
+      this.breadcrumbParentName = res.expandedSection
+        ? res.expandedSection.name
+        : '';
 
-        if (!res.expandedSection) {
-          this._subsectionLinks = [];
-          return;
-        }
-        this._subsectionLinks = [res.expandedSection]
-          .concat(res.expandedSection.children ?? [])
-          .map(section => {
-            return {
-              text: !section.detailType ? 'Home' : section.name,
-              value: section.view + (section.detailType ?? ''),
-              view: section.view,
-              url: section.url,
-              detailType: section.detailType,
-              parent: this._groupId ?? this._repoName,
-            };
-          });
-      });
-    });
+      if (!res.expandedSection) {
+        this.subsectionLinks = [];
+        return;
+      }
+      this.subsectionLinks = [res.expandedSection]
+        .concat(res.expandedSection.children ?? [])
+        .map(section => {
+          return {
+            text: !section.detailType ? 'Home' : section.name,
+            value: section.view + (section.detailType ?? ''),
+            view: section.view,
+            url: section.url,
+            detailType: section.detailType,
+            parent: this.groupId ?? this.repoName,
+          };
+        });
+    } finally {
+      this.reloading = false;
+    }
   }
 
-  _computeSelectValue(params: AdminViewParams) {
-    if (!params || !params.view) return;
-    return `${params.view}${getAdminViewParamsDetail(params) ?? ''}`;
+  private computeSelectValue() {
+    if (!this.params?.view) return;
+    return `${this.params.view}${getAdminViewParamsDetail(this.params) ?? ''}`;
   }
 
-  _selectedIsCurrentPage(selected: AdminSubsectionLink) {
+  // private but used in test
+  selectedIsCurrentPage(selected: AdminSubsectionLink) {
     if (!this.params) return false;
 
     return (
-      selected.parent === (this._repoName ?? this._groupId) &&
+      selected.parent === (this.repoName ?? this.groupId) &&
       selected.view === this.params.view &&
       selected.detailType === getAdminViewParamsDetail(this.params)
     );
   }
 
-  _handleSubsectionChange(e: CustomEvent<ValueChangeDetail>) {
-    if (!this._subsectionLinks) return;
+  // private but used in test
+  handleSubsectionChange(e: CustomEvent<ValueChangeDetail>) {
+    if (!this.subsectionLinks) return;
 
-    // The GrDropdownList items are _subsectionLinks, so find(...) always return
-    // an item _subsectionLinks and never returns undefined
-    const selected = this._subsectionLinks.find(
+    // The GrDropdownList items are subsectionLinks, so find(...) always return
+    // an item subsectionLinks and never returns undefined
+    const selected = this.subsectionLinks.find(
       section => section.value === e.detail.value
     )!;
 
     // This is when it gets set initially.
-    if (this._selectedIsCurrentPage(selected)) return;
+    if (this.selectedIsCurrentPage(selected)) return;
     if (selected.url === undefined) return;
+    if (this.reloading) return;
     GerritNav.navigateToRelativeUrl(selected.url);
   }
 
-  @observe('params')
-  _paramsChanged(params: AdminViewParams) {
-    this.set('_showGroup', params.view === GerritView.GROUP && !params.detail);
-    this.set(
-      '_showGroupAuditLog',
-      params.view === GerritView.GROUP && params.detail === GroupDetailView.LOG
-    );
-    this.set(
-      '_showGroupMembers',
-      params.view === GerritView.GROUP &&
-        params.detail === GroupDetailView.MEMBERS
-    );
+  private async paramsChanged() {
+    if (this.needsReload()) await this.reload();
+  }
 
-    this.set(
-      '_showGroupList',
-      params.view === GerritView.ADMIN &&
-        params.adminView === 'gr-admin-group-list'
-    );
-
-    this.set(
-      '_showRepoAccess',
-      params.view === GerritView.REPO && params.detail === RepoDetailView.ACCESS
-    );
-    this.set(
-      '_showRepoCommands',
-      params.view === GerritView.REPO &&
-        params.detail === RepoDetailView.COMMANDS
-    );
-    this.set(
-      '_showRepoDetailList',
-      params.view === GerritView.REPO &&
-        (params.detail === RepoDetailView.BRANCHES ||
-          params.detail === RepoDetailView.TAGS)
-    );
-    this.set(
-      '_showRepoDashboards',
-      params.view === GerritView.REPO &&
-        params.detail === RepoDetailView.DASHBOARDS
-    );
-    this.set(
-      '_showRepoMain',
-      params.view === GerritView.REPO &&
-        (!params.detail || params.detail === RepoDetailView.GENERAL)
-    );
-    this.set(
-      '_showRepoList',
-      params.view === GerritView.ADMIN && params.adminView === 'gr-repo-list'
-    );
-
-    this.set(
-      '_showPluginList',
-      params.view === GerritView.ADMIN && params.adminView === 'gr-plugin-list'
-    );
+  needsReload(): boolean {
+    if (!this.params) return false;
 
     let needsReload = false;
     const newRepoName =
-      params.view === GerritView.REPO ? params.repo : undefined;
-    if (newRepoName !== this._repoName) {
-      this._repoName = newRepoName;
+      this.params.view === GerritView.REPO ? this.params.repo : undefined;
+    if (newRepoName !== this.repoName) {
+      this.repoName = newRepoName;
       // Reloads the admin menu.
       needsReload = true;
     }
     const newGroupId =
-      params.view === GerritView.GROUP ? params.groupId : undefined;
-    if (newGroupId !== this._groupId) {
-      this._groupId = newGroupId;
+      this.params.view === GerritView.GROUP ? this.params.groupId : undefined;
+    if (newGroupId !== this.groupId) {
+      this.groupId = newGroupId;
       // Reloads the admin menu.
       needsReload = true;
     }
     if (
-      this._breadcrumbParentName &&
-      (params.view !== GerritView.GROUP || !params.groupId) &&
-      (params.view !== GerritView.REPO || !params.repo)
+      this.breadcrumbParentName &&
+      (this.params.view !== GerritView.GROUP || !this.params.groupId) &&
+      (this.params.view !== GerritView.REPO || !this.params.repo)
     ) {
       needsReload = true;
     }
-    if (!needsReload) {
-      return;
-    }
-    this.reload();
+
+    return needsReload;
   }
 
-  // TODO (beckysiegel): Update these functions after router abstraction is
-  // updated. They are currently copied from gr-dropdown (and should be
-  // updated there as well once complete).
-  _computeURLHelper(host: string, path: string) {
-    return '//' + host + getBaseUrl() + path;
-  }
-
-  _computeRelativeURL(path: string) {
-    const host = window.location.host;
-    return this._computeURLHelper(host, path);
-  }
-
-  _computeLinkURL(link: NavLink | SubsectionInterface) {
+  // private but used in test
+  computeLinkURL(link?: NavLink | SubsectionInterface) {
     if (!link || typeof link.url === 'undefined') return '';
 
     if ((link as NavLink).target || !(link as NavLink).noBaseUrl) {
       return link.url;
     }
-    return this._computeRelativeURL(link.url);
+    return `//${window.location.host}${getBaseUrl()}${link.url}`;
   }
 
-  _computeSelectedClass(
+  private computeSelectedClass(
     itemView?: GerritView,
-    params?: AdminViewParams,
     detailType?: GroupDetailView | RepoDetailView
   ) {
+    const params = this.params;
     if (!params) return '';
     // Group params are structured differently from admin params. Compute
     // selected differently for groups.
@@ -409,40 +653,23 @@
       : '';
   }
 
-  _computeGroupName(groupId?: GroupId) {
-    if (!groupId) return;
+  // private but used in test
+  async computeGroupName() {
+    if (!this.groupId) return;
 
-    const promises: Array<Promise<void>> = [];
-    this.restApiService.getGroupConfig(groupId).then(group => {
-      if (!group || !group.name) {
-        return;
-      }
+    const group = await this.restApiService.getGroupConfig(this.groupId);
+    if (!group || !group.name) {
+      return;
+    }
 
-      this._groupName = group.name;
-      this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
-      this.reload();
-
-      promises.push(
-        this.restApiService.getIsAdmin().then(isAdmin => {
-          this._isAdmin = !!isAdmin;
-        })
-      );
-
-      promises.push(
-        this.restApiService.getIsGroupOwner(group.name).then(isOwner => {
-          this._groupOwner = isOwner;
-        })
-      );
-
-      return Promise.all(promises).then(() => {
-        this.reload();
-      });
-    });
+    this.groupName = group.name;
+    this.groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
+    await this.reload();
   }
 
-  _updateGroupName(e: CustomEvent<GroupNameChangedDetail>) {
-    this._groupName = e.detail.name;
-    this.reload();
+  private async updateGroupName(e: CustomEvent<GroupNameChangedDetail>) {
+    this.groupName = e.detail.name;
+    await this.reload();
   }
 }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
deleted file mode 100644
index a3afc5c..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
+++ /dev/null
@@ -1,181 +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-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-page-nav-styles">
-    .breadcrumbText {
-      /* Same as dropdown trigger so chevron spacing is consistent. */
-      padding: 5px 4px;
-    }
-    iron-icon {
-      margin: 0 var(--spacing-xs);
-    }
-    .breadcrumb {
-      align-items: center;
-      display: flex;
-    }
-    .mainHeader {
-      align-items: baseline;
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-    }
-    .selectText {
-      display: none;
-    }
-    .selectText.show {
-      display: inline-block;
-    }
-    .main.breadcrumbs:not(.table) {
-      margin-top: var(--spacing-l);
-    }
-  </style>
-  <gr-page-nav class="navStyles">
-    <ul class="sectionContent">
-      <template id="adminNav" is="dom-repeat" items="[[_filteredLinks]]">
-        <li class$="sectionTitle [[_computeSelectedClass(item.view, params)]]">
-          <a class="title" href="[[_computeLinkURL(item)]]" rel="noopener"
-            >[[item.name]]</a
-          >
-        </li>
-        <template is="dom-repeat" items="[[item.children]]" as="child">
-          <li class$="[[_computeSelectedClass(child.view, params)]]">
-            <a href$="[[_computeLinkURL(child)]]" rel="noopener"
-              >[[child.name]]</a
-            >
-          </li>
-        </template>
-        <template is="dom-if" if="[[item.subsection]]">
-          <!--If a section has a subsection, render that.-->
-          <li class$="[[_computeSelectedClass(item.subsection.view, params)]]">
-            <template is="dom-if" if="[[item.subsection.url]]" as="child">
-              <a
-                class="title"
-                href$="[[_computeLinkURL(item.subsection)]]"
-                rel="noopener"
-              >
-                [[item.subsection.name]]</a
-              >
-            </template>
-            <template is="dom-if" if="[[!item.subsection.url]]" as="child">
-              [[item.subsection.name]]
-            </template>
-          </li>
-          <!--Loop through the links in the sub-section.-->
-          <template
-            is="dom-repeat"
-            items="[[item.subsection.children]]"
-            as="child"
-          >
-            <li
-              class$="subsectionItem [[_computeSelectedClass(child.view, params, child.detailType)]]"
-            >
-              <a href$="[[_computeLinkURL(child)]]">[[child.name]]</a>
-            </li>
-          </template>
-        </template>
-      </template>
-    </ul>
-  </gr-page-nav>
-  <template is="dom-if" if="[[_subsectionLinks.length]]">
-    <section class="mainHeader">
-      <span class="breadcrumb">
-        <span class="breadcrumbText">[[_breadcrumbParentName]]</span>
-        <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-      </span>
-      <gr-dropdown-list
-        lowercase=""
-        id="pageSelect"
-        value="[[_computeSelectValue(params)]]"
-        items="[[_subsectionLinks]]"
-        on-value-change="_handleSubsectionChange"
-      >
-      </gr-dropdown-list>
-    </section>
-  </template>
-  <template is="dom-if" if="[[_showRepoList]]" restamp="true">
-    <div class="main table">
-      <gr-repo-list class="table" params="[[params]]"></gr-repo-list>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showGroupList]]" restamp="true">
-    <div class="main table">
-      <gr-admin-group-list class="table" params="[[params]]">
-      </gr-admin-group-list>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showPluginList]]" restamp="true">
-    <div class="main table">
-      <gr-plugin-list class="table" params="[[params]]"></gr-plugin-list>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showRepoMain]]" restamp="true">
-    <div class="main breadcrumbs">
-      <gr-repo repo="[[params.repo]]"></gr-repo>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showGroup]]" restamp="true">
-    <div class="main breadcrumbs">
-      <gr-group
-        group-id="[[params.groupId]]"
-        on-name-changed="_updateGroupName"
-      ></gr-group>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
-    <div class="main breadcrumbs">
-      <gr-group-members group-id="[[params.groupId]]"></gr-group-members>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showRepoDetailList]]" restamp="true">
-    <div class="main table breadcrumbs">
-      <gr-repo-detail-list
-        params="[[params]]"
-        class="table"
-      ></gr-repo-detail-list>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showGroupAuditLog]]" restamp="true">
-    <div class="main table breadcrumbs">
-      <gr-group-audit-log
-        group-id="[[params.groupId]]"
-        class="table"
-      ></gr-group-audit-log>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showRepoCommands]]" restamp="true">
-    <div class="main breadcrumbs">
-      <gr-repo-commands repo="[[params.repo]]"></gr-repo-commands>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showRepoAccess]]" restamp="true">
-    <div class="main breadcrumbs">
-      <gr-repo-access path="[[path]]" repo="[[params.repo]]"></gr-repo-access>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showRepoDashboards]]" restamp="true">
-    <div class="main table breadcrumbs">
-      <gr-repo-dashboards repo="[[params.repo]]"></gr-repo-dashboards>
-    </div>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
deleted file mode 100644
index cf0fdd4..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ /dev/null
@@ -1,594 +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-admin-view.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {mockPromise, stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
-import {GerritView} from '../../../services/router/router-model.js';
-
-const basicFixture = fixtureFromElement('gr-admin-view');
-
-function createAdminCapabilities() {
-  return {
-    createGroup: true,
-    createProject: true,
-    viewPlugins: true,
-  };
-}
-
-suite('gr-admin-view tests', () => {
-  let element;
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-    const pluginsLoaded = Promise.resolve();
-    sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
-    await pluginsLoaded;
-    await flush();
-  });
-
-  test('_computeURLHelper', () => {
-    const path = '/test';
-    const host = 'http://www.testsite.com';
-    const computedPath = element._computeURLHelper(host, path);
-    assert.equal(computedPath, '//http://www.testsite.com/test');
-  });
-
-  test('link URLs', () => {
-    assert.equal(
-        element._computeLinkURL({url: '/test', noBaseUrl: true}),
-        '//' + window.location.host + '/test');
-
-    stubBaseUrl('/foo');
-    assert.equal(
-        element._computeLinkURL({url: '/test', noBaseUrl: true}),
-        '//' + window.location.host + '/foo/test');
-    assert.equal(element._computeLinkURL({url: '/test'}), '/test');
-    assert.equal(
-        element._computeLinkURL({url: '/test', target: '_blank'}),
-        '/test');
-  });
-
-  test('current page gets selected and is displayed', () => {
-    element._filteredLinks = [{
-      name: 'Repositories',
-      url: '/admin/repos',
-      view: 'gr-repo-list',
-    }];
-
-    element.params = {
-      view: 'admin',
-      adminView: 'gr-repo-list',
-    };
-
-    flush();
-    assert.equal(element.root.querySelectorAll(
-        '.selected').length, 1);
-    assert.ok(element.shadowRoot
-        .querySelector('gr-repo-list'));
-    assert.isNotOk(element.shadowRoot
-        .querySelector('gr-admin-create-repo'));
-  });
-
-  test('_filteredLinks admin', async () => {
-    stubRestApi('getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    stubRestApi('getAccountCapabilities').returns(
-        Promise.resolve(createAdminCapabilities()));
-    await element.reload();
-    assert.equal(element._filteredLinks.length, 3);
-
-    // Repos
-    assert.isNotOk(element._filteredLinks[0].subsection);
-
-    // Groups
-    assert.isNotOk(element._filteredLinks[0].subsection);
-
-    // Plugins
-    assert.isNotOk(element._filteredLinks[0].subsection);
-  });
-
-  test('_filteredLinks non admin authenticated', async () => {
-    await element.reload();
-    assert.equal(element._filteredLinks.length, 2);
-    // Repos
-    assert.isNotOk(element._filteredLinks[0].subsection);
-    // Groups
-    assert.isNotOk(element._filteredLinks[0].subsection);
-  });
-
-  test('_filteredLinks non admin unathenticated', async () => {
-    stubRestApi('getAccount').returns(Promise.resolve(undefined));
-    await element.reload();
-    assert.equal(element._filteredLinks.length, 1);
-    // Repos
-    assert.isNotOk(element._filteredLinks[0].subsection);
-  });
-
-  test('_filteredLinks from plugin', () => {
-    stubRestApi('getAccount').returns(Promise.resolve(undefined));
-    sinon.stub(element.jsAPI, 'getAdminMenuLinks').returns([
-      {text: 'internal link text', url: '/internal/link/url'},
-      {text: 'external link text', url: 'http://external/link/url'},
-    ]);
-    return element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 3);
-      assert.deepEqual(element._filteredLinks[1], {
-        capability: undefined,
-        url: '/internal/link/url',
-        name: 'internal link text',
-        noBaseUrl: true,
-        view: null,
-        viewableToAll: true,
-        target: null,
-      });
-      assert.deepEqual(element._filteredLinks[2], {
-        capability: undefined,
-        url: 'http://external/link/url',
-        name: 'external link text',
-        noBaseUrl: false,
-        view: null,
-        viewableToAll: true,
-        target: '_blank',
-      });
-    });
-  });
-
-  test('Repo shows up in nav', async () => {
-    element._repoName = 'Test Repo';
-    stubRestApi('getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    stubRestApi('getAccountCapabilities').returns(
-        Promise.resolve(createAdminCapabilities()));
-    await element.reload();
-    await flush();
-    assert.equal(dom(element.root)
-        .querySelectorAll('.sectionTitle').length, 3);
-    assert.equal(element.shadowRoot
-        .querySelector('.breadcrumbText').innerText, 'Test Repo');
-    assert.equal(
-        element.shadowRoot.querySelector('#pageSelect').items.length,
-        7
-    );
-  });
-
-  test('Group shows up in nav', async () => {
-    element._groupId = 'a15262';
-    element._groupName = 'my-group';
-    element._groupIsInternal = true;
-    element._isAdmin = true;
-    element._groupOwner = false;
-    stubRestApi('getAccount').returns(Promise.resolve({name: 'test-user'}));
-    stubRestApi('getAccountCapabilities').returns(
-        Promise.resolve(createAdminCapabilities()));
-    await element.reload();
-    await flush();
-    assert.equal(element._filteredLinks.length, 3);
-    // Repos
-    assert.isNotOk(element._filteredLinks[0].subsection);
-    // Groups
-    assert.equal(element._filteredLinks[1].subsection.children.length, 2);
-    assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
-    // Plugins
-    assert.isNotOk(element._filteredLinks[2].subsection);
-  });
-
-  test('Nav is reloaded when repo changes', () => {
-    stubRestApi('getAccountCapabilities').returns(
-        Promise.resolve(createAdminCapabilities()));
-    stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
-    sinon.stub(element, 'reload');
-    element.params = {repo: 'Test Repo', view: GerritView.REPO};
-    assert.equal(element.reload.callCount, 1);
-    element.params = {repo: 'Test Repo 2',
-      view: GerritView.REPO};
-    assert.equal(element.reload.callCount, 2);
-  });
-
-  test('Nav is reloaded when group changes', () => {
-    sinon.stub(element, '_computeGroupName');
-    stubRestApi('getAccountCapabilities').returns(
-        Promise.resolve(createAdminCapabilities()));
-    stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
-    sinon.stub(element, 'reload');
-    element.params = {groupId: '1', view: GerritView.GROUP};
-    assert.equal(element.reload.callCount, 1);
-  });
-
-  test('Nav is reloaded when group name changes', async () => {
-    const newName = 'newName';
-    const reloadCalled = mockPromise();
-    sinon.stub(element, '_computeGroupName');
-    sinon.stub(element, 'reload').callsFake(() => {
-      assert.equal(element._groupName, newName);
-      reloadCalled.resolve();
-    });
-    element.params = {group: 1, view: GerritNav.View.GROUP};
-    element._groupName = 'oldName';
-    await flush();
-    element.shadowRoot
-        .querySelector('gr-group').dispatchEvent(
-            new CustomEvent('name-changed', {
-              detail: {name: newName},
-              composed: true, bubbles: true,
-            }));
-    await reloadCalled;
-  });
-
-  test('dropdown displays if there is a subsection', () => {
-    assert.isNotOk(element.shadowRoot
-        .querySelector('.mainHeader'));
-    element._subsectionLinks = [
-      {
-        text: 'Home',
-        value: 'repo',
-        view: 'repo',
-        parent: 'my-repo',
-        detailType: undefined,
-      },
-    ];
-    flush();
-    assert.isOk(element.shadowRoot
-        .querySelector('.mainHeader'));
-    element._subsectionLinks = undefined;
-    flush();
-    assert.equal(
-        getComputedStyle(element.shadowRoot
-            .querySelector('.mainHeader')).display,
-        'none');
-  });
-
-  test('Dropdown only triggers navigation on explicit select', async () => {
-    element._repoName = 'my-repo';
-    element.params = {
-      repo: 'my-repo',
-      view: GerritNav.View.REPO,
-      detail: GerritNav.RepoDetailView.ACCESS,
-    };
-    stubRestApi('getAccountCapabilities').returns(
-        Promise.resolve(createAdminCapabilities()));
-    stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
-    await flush();
-    const expectedFilteredLinks = [
-      {
-        name: 'Repositories',
-        noBaseUrl: true,
-        url: '/admin/repos',
-        view: 'gr-repo-list',
-        viewableToAll: true,
-        subsection: {
-          name: 'my-repo',
-          view: 'repo',
-          children: [
-            {
-              name: 'General',
-              view: 'repo',
-              url: '',
-              detailType: 'general',
-            },
-            {
-              name: 'Access',
-              view: 'repo',
-              detailType: 'access',
-              url: '',
-            },
-            {
-              name: 'Commands',
-              view: 'repo',
-              detailType: 'commands',
-              url: '',
-            },
-            {
-              name: 'Branches',
-              view: 'repo',
-              detailType: 'branches',
-              url: '',
-            },
-            {
-              name: 'Tags',
-              view: 'repo',
-              detailType: 'tags',
-              url: '',
-            },
-            {
-              name: 'Dashboards',
-              view: 'repo',
-              detailType: 'dashboards',
-              url: '',
-            },
-          ],
-        },
-      },
-      {
-        name: 'Groups',
-        section: 'Groups',
-        noBaseUrl: true,
-        url: '/admin/groups',
-        view: 'gr-admin-group-list',
-      },
-      {
-        name: 'Plugins',
-        capability: 'viewPlugins',
-        section: 'Plugins',
-        noBaseUrl: true,
-        url: '/admin/plugins',
-        view: 'gr-plugin-list',
-      },
-    ];
-    const expectedSubsectionLinks = [
-      {
-        text: 'Home',
-        value: 'repo',
-        view: 'repo',
-        url: undefined,
-        parent: 'my-repo',
-        detailType: undefined,
-      },
-      {
-        text: 'General',
-        value: 'repogeneral',
-        view: 'repo',
-        url: '',
-        detailType: 'general',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Access',
-        value: 'repoaccess',
-        view: 'repo',
-        url: '',
-        detailType: 'access',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Commands',
-        value: 'repocommands',
-        view: 'repo',
-        url: '',
-        detailType: 'commands',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Branches',
-        value: 'repobranches',
-        view: 'repo',
-        url: '',
-        detailType: 'branches',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Tags',
-        value: 'repotags',
-        view: 'repo',
-        url: '',
-        detailType: 'tags',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Dashboards',
-        value: 'repodashboards',
-        view: 'repo',
-        url: '',
-        detailType: 'dashboards',
-        parent: 'my-repo',
-      },
-    ];
-    sinon.stub(GerritNav, 'navigateToRelativeUrl');
-    sinon.spy(element, '_selectedIsCurrentPage');
-    sinon.spy(element, '_handleSubsectionChange');
-    await element.reload();
-    assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
-    assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
-    assert.equal(
-        element.shadowRoot.querySelector('#pageSelect').value,
-        'repoaccess'
-    );
-    assert.isTrue(element._selectedIsCurrentPage.calledOnce);
-    // Doesn't trigger navigation from the page select menu.
-    assert.isFalse(GerritNav.navigateToRelativeUrl.called);
-
-    // When explicitly changed, navigation is called
-    element.shadowRoot.querySelector('#pageSelect').value = 'repogeneral';
-    assert.isTrue(element._selectedIsCurrentPage.calledTwice);
-    assert.isTrue(GerritNav.navigateToRelativeUrl.calledOnce);
-  });
-
-  test('_selectedIsCurrentPage', () => {
-    element._repoName = 'my-repo';
-    element.params = {view: 'repo', repo: 'my-repo'};
-    const selected = {
-      view: 'repo',
-      detailType: undefined,
-      parent: 'my-repo',
-    };
-    assert.isTrue(element._selectedIsCurrentPage(selected));
-    selected.parent = 'my-second-repo';
-    assert.isFalse(element._selectedIsCurrentPage(selected));
-    selected.detailType = 'detailType';
-    assert.isFalse(element._selectedIsCurrentPage(selected));
-  });
-
-  suite('_computeSelectedClass', () => {
-    setup(() => {
-      stubRestApi('getAccountCapabilities').returns(
-          Promise.resolve(createAdminCapabilities()));
-      stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
-      return element.reload();
-    });
-
-    suite('repos', () => {
-      setup(() => {
-        stub('gr-repo-access', '_repoChanged').callsFake(() => {});
-      });
-
-      test('repo list', () => {
-        element.params = {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-repo-list',
-          openCreateModal: false,
-        };
-        flush();
-        const selected = element.shadowRoot
-            .querySelector('gr-page-nav .selected');
-        assert.isOk(selected);
-        assert.equal(selected.textContent.trim(), 'Repositories');
-      });
-
-      test('repo', () => {
-        element.params = {
-          view: GerritNav.View.REPO,
-          repoName: 'foo',
-        };
-        element._repoName = 'foo';
-        return element.reload().then(() => {
-          flush();
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'foo');
-        });
-      });
-
-      test('repo access', () => {
-        element.params = {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.ACCESS,
-          repoName: 'foo',
-        };
-        element._repoName = 'foo';
-        return element.reload().then(() => {
-          flush();
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'Access');
-        });
-      });
-
-      test('repo dashboards', () => {
-        element.params = {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.DASHBOARDS,
-          repoName: 'foo',
-        };
-        element._repoName = 'foo';
-        return element.reload().then(() => {
-          flush();
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'Dashboards');
-        });
-      });
-    });
-
-    suite('groups', () => {
-      let getGroupConfigStub;
-      setup(() => {
-        stub('gr-group', '_loadGroup').callsFake(() => Promise.resolve({}));
-        stub('gr-group-members', '_loadGroupDetails').callsFake(() => {});
-
-        getGroupConfigStub = stubRestApi('getGroupConfig');
-        getGroupConfigStub.returns(Promise.resolve({
-          name: 'foo',
-          id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
-        }));
-        stubRestApi('getIsGroupOwner')
-            .returns(Promise.resolve(true));
-        return element.reload();
-      });
-
-      test('group list', () => {
-        element.params = {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          openCreateModal: false,
-        };
-        flush();
-        const selected = element.shadowRoot
-            .querySelector('gr-page-nav .selected');
-        assert.isOk(selected);
-        assert.equal(selected.textContent.trim(), 'Groups');
-      });
-
-      test('internal group', () => {
-        element.params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-        };
-        element._groupName = 'foo';
-        return element.reload().then(() => {
-          flush();
-          const subsectionItems = dom(element.root)
-              .querySelectorAll('.subsectionItem');
-          assert.equal(subsectionItems.length, 2);
-          assert.isTrue(element._groupIsInternal);
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'foo');
-        });
-      });
-
-      test('external group', () => {
-        getGroupConfigStub.returns(Promise.resolve({
-          name: 'foo',
-          id: 'external-id',
-        }));
-        element.params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-        };
-        element._groupName = 'foo';
-        return element.reload().then(() => {
-          flush();
-          const subsectionItems = dom(element.root)
-              .querySelectorAll('.subsectionItem');
-          assert.equal(subsectionItems.length, 0);
-          assert.isFalse(element._groupIsInternal);
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'foo');
-        });
-      });
-
-      test('group members', () => {
-        element.params = {
-          view: GerritNav.View.GROUP,
-          detail: GerritNav.GroupDetailView.MEMBERS,
-          groupId: 1234,
-        };
-        element._groupName = 'foo';
-        return element.reload().then(() => {
-          flush();
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'Members');
-        });
-      });
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..0f473c3
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
@@ -0,0 +1,673 @@
+/**
+ * @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-admin-view';
+import {AdminSubsectionLink, GrAdminView} from './gr-admin-view';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {mockPromise, stubBaseUrl, stubRestApi} from '../../../test/test-utils';
+import {GerritView} from '../../../services/router/router-model';
+import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrRepoList} from '../gr-repo-list/gr-repo-list';
+import {GroupId, GroupName, RepoName, Timestamp} from '../../../types/common';
+import {GrDropdownList} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrGroup} from '../gr-group/gr-group';
+
+const basicFixture = fixtureFromElement('gr-admin-view');
+
+function createAdminCapabilities() {
+  return {
+    createGroup: true,
+    createProject: true,
+    viewPlugins: true,
+  };
+}
+
+suite('gr-admin-view tests', () => {
+  let element: GrAdminView;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    stubRestApi('getProjectConfig').returns(Promise.resolve(undefined));
+    const pluginsLoaded = Promise.resolve();
+    sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
+    await pluginsLoaded;
+    await element.updateComplete;
+  });
+
+  test('link URLs', () => {
+    assert.equal(
+      element.computeLinkURL({name: '', url: '/test', noBaseUrl: true}),
+      '//' + window.location.host + '/test'
+    );
+
+    stubBaseUrl('/foo');
+    assert.equal(
+      element.computeLinkURL({name: '', url: '/test', noBaseUrl: true}),
+      '//' + window.location.host + '/foo/test'
+    );
+    assert.equal(
+      element.computeLinkURL({name: '', url: '/test', noBaseUrl: false}),
+      '/test'
+    );
+    assert.equal(
+      element.computeLinkURL({
+        name: '',
+        url: '/test',
+        target: '_blank',
+        noBaseUrl: false,
+      }),
+      '/test'
+    );
+  });
+
+  test('current page gets selected and is displayed', async () => {
+    element.filteredLinks = [
+      {
+        name: 'Repositories',
+        url: '/admin/repos',
+        view: 'gr-repo-list' as GerritView,
+        noBaseUrl: false,
+      },
+    ];
+
+    element.params = {
+      view: GerritView.ADMIN,
+      adminView: 'gr-repo-list',
+    };
+
+    await element.updateComplete;
+    assert.equal(queryAll<HTMLLIElement>(element, '.selected').length, 1);
+    assert.ok(queryAndAssert<GrRepoList>(element, 'gr-repo-list'));
+    assert.isNotOk(query(element, 'gr-admin-create-repo'));
+  });
+
+  test('filteredLinks admin', async () => {
+    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();
+    assert.equal(element.filteredLinks!.length, 3);
+
+    // Repos
+    assert.isNotOk(element.filteredLinks![0].subsection);
+
+    // Groups
+    assert.isNotOk(element.filteredLinks![0].subsection);
+
+    // Plugins
+    assert.isNotOk(element.filteredLinks![0].subsection);
+  });
+
+  test('filteredLinks non admin authenticated', async () => {
+    await element.reload();
+    assert.equal(element.filteredLinks!.length, 2);
+    // Repos
+    assert.isNotOk(element.filteredLinks![0].subsection);
+    // Groups
+    assert.isNotOk(element.filteredLinks![0].subsection);
+  });
+
+  test('filteredLinks non admin unathenticated', async () => {
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    await element.reload();
+    assert.equal(element.filteredLinks!.length, 1);
+    // Repos
+    assert.isNotOk(element.filteredLinks![0].subsection);
+  });
+
+  test('filteredLinks from plugin', () => {
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    sinon.stub(element.jsAPI, 'getAdminMenuLinks').returns([
+      {capability: null, text: 'internal link text', url: '/internal/link/url'},
+      {
+        capability: null,
+        text: 'external link text',
+        url: 'http://external/link/url',
+      },
+    ]);
+    return element.reload().then(() => {
+      assert.equal(element.filteredLinks!.length, 3);
+      assert.deepEqual(element.filteredLinks![1], {
+        capability: undefined,
+        url: '/internal/link/url',
+        name: 'internal link text',
+        noBaseUrl: true,
+        view: undefined,
+        viewableToAll: true,
+        target: null,
+      });
+      assert.deepEqual(element.filteredLinks![2], {
+        capability: undefined,
+        url: 'http://external/link/url',
+        name: 'external link text',
+        noBaseUrl: false,
+        view: undefined,
+        viewableToAll: true,
+        target: '_blank',
+      });
+    });
+  });
+
+  test('Repo shows up in nav', 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;
+    assert.equal(queryAll<HTMLLIElement>(element, '.sectionTitle').length, 3);
+    assert.equal(
+      queryAndAssert<HTMLSpanElement>(element, '.breadcrumbText').innerText,
+      'Test Repo'
+    );
+    assert.equal(
+      queryAndAssert<GrDropdownList>(element, '#pageSelect').items!.length,
+      7
+    );
+  });
+
+  test('Group shows up in nav', async () => {
+    element.groupId = 'a15262' as GroupId;
+    element.groupName = 'my-group' as GroupName;
+    element.groupIsInternal = true;
+    stubRestApi('getIsAdmin').returns(Promise.resolve(true));
+    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;
+    assert.equal(element.filteredLinks!.length, 3);
+    // Repos
+    assert.isNotOk(element.filteredLinks![0].subsection);
+    // Groups
+    assert.equal(element.filteredLinks![1].subsection!.children!.length, 2);
+    assert.equal(element.filteredLinks![1].subsection!.name, 'my-group');
+    // Plugins
+    assert.isNotOk(element.filteredLinks![2].subsection);
+  });
+
+  test('Nav is reloaded when repo changes', async () => {
+    stubRestApi('getAccountCapabilities').returns(
+      Promise.resolve(createAdminCapabilities())
+    );
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        _id: 1,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+    const reloadStub = sinon.stub(element, 'reload');
+    element.params = {repo: 'Test Repo' as RepoName, view: GerritView.REPO};
+    await element.updateComplete;
+    assert.equal(reloadStub.callCount, 1);
+    element.params = {repo: 'Test Repo 2' as RepoName, view: GerritView.REPO};
+    await element.updateComplete;
+    assert.equal(reloadStub.callCount, 2);
+  });
+
+  test('Nav is reloaded when group changes', async () => {
+    sinon.stub(element, 'computeGroupName');
+    stubRestApi('getAccountCapabilities').returns(
+      Promise.resolve(createAdminCapabilities())
+    );
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        _id: 1,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+    const reloadStub = sinon.stub(element, 'reload');
+    element.params = {groupId: '1' as GroupId, view: GerritView.GROUP};
+    await element.updateComplete;
+    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();
+    sinon.stub(element, 'computeGroupName');
+    sinon.stub(element, 'reload').callsFake(() => {
+      reloadCalled.resolve();
+      return Promise.resolve();
+    });
+    element.params = {groupId: '1' as GroupId, view: GerritView.GROUP};
+    element.groupName = 'oldName' as GroupName;
+    await element.updateComplete;
+    queryAndAssert<GrGroup>(element, 'gr-group').dispatchEvent(
+      new CustomEvent('name-changed', {
+        detail: {name: newName},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    await reloadCalled;
+    assert.equal(element.groupName, newName);
+  });
+
+  test('dropdown displays if there is a subsection', async () => {
+    assert.isNotOk(query(element, '.mainHeader'));
+    element.subsectionLinks = [
+      {
+        text: 'Home',
+        value: 'repo',
+        view: GerritView.REPO,
+        parent: 'my-repo' as RepoName,
+        detailType: undefined,
+      },
+    ];
+    await element.updateComplete;
+    assert.isOk(query(element, '.mainHeader'));
+    element.subsectionLinks = undefined;
+    await element.updateComplete;
+    assert.isNotOk(query(element, '.mainHeader'));
+  });
+
+  test('Dropdown only triggers navigation on explicit select', async () => {
+    element.repoName = 'my-repo' as RepoName;
+    element.params = {
+      repo: 'my-repo' as RepoName,
+      view: GerritNav.View.REPO,
+      detail: GerritNav.RepoDetailView.ACCESS,
+    };
+    stubRestApi('getAccountCapabilities').returns(
+      Promise.resolve(createAdminCapabilities())
+    );
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        _id: 1,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+    await element.updateComplete;
+    const expectedFilteredLinks = [
+      {
+        name: 'Repositories',
+        noBaseUrl: true,
+        url: '/admin/repos',
+        view: 'gr-repo-list' as GerritView,
+        viewableToAll: true,
+        subsection: {
+          name: 'my-repo',
+          view: GerritView.REPO,
+          children: [
+            {
+              name: 'General',
+              view: GerritView.REPO,
+              url: '',
+              detailType: GerritNav.RepoDetailView.GENERAL,
+            },
+            {
+              name: 'Access',
+              view: GerritView.REPO,
+              detailType: GerritNav.RepoDetailView.ACCESS,
+              url: '',
+            },
+            {
+              name: 'Commands',
+              view: GerritView.REPO,
+              detailType: GerritNav.RepoDetailView.COMMANDS,
+              url: '',
+            },
+            {
+              name: 'Branches',
+              view: GerritView.REPO,
+              detailType: GerritNav.RepoDetailView.BRANCHES,
+              url: '',
+            },
+            {
+              name: 'Tags',
+              view: GerritView.REPO,
+              detailType: GerritNav.RepoDetailView.TAGS,
+              url: '',
+            },
+            {
+              name: 'Dashboards',
+              view: GerritView.REPO,
+              detailType: GerritNav.RepoDetailView.DASHBOARDS,
+              url: '',
+            },
+          ],
+        },
+      },
+      {
+        name: 'Groups',
+        section: 'Groups',
+        noBaseUrl: true,
+        url: '/admin/groups',
+        view: 'gr-admin-group-list' as GerritView,
+      },
+      {
+        name: 'Plugins',
+        capability: 'viewPlugins',
+        section: 'Plugins',
+        noBaseUrl: true,
+        url: '/admin/plugins',
+        view: 'gr-plugin-list' as GerritView,
+      },
+    ];
+    const expectedSubsectionLinks = [
+      {
+        text: 'Home',
+        value: 'repo',
+        view: GerritView.REPO,
+        url: undefined,
+        parent: 'my-repo' as RepoName,
+        detailType: undefined,
+      },
+      {
+        text: 'General',
+        value: 'repogeneral',
+        view: GerritView.REPO,
+        url: '',
+        detailType: GerritNav.RepoDetailView.GENERAL,
+        parent: 'my-repo' as RepoName,
+      },
+      {
+        text: 'Access',
+        value: 'repoaccess',
+        view: GerritView.REPO,
+        url: '',
+        detailType: GerritNav.RepoDetailView.ACCESS,
+        parent: 'my-repo' as RepoName,
+      },
+      {
+        text: 'Commands',
+        value: 'repocommands',
+        view: GerritView.REPO,
+        url: '',
+        detailType: GerritNav.RepoDetailView.COMMANDS,
+        parent: 'my-repo' as RepoName,
+      },
+      {
+        text: 'Branches',
+        value: 'repobranches',
+        view: GerritView.REPO,
+        url: '',
+        detailType: GerritNav.RepoDetailView.BRANCHES,
+        parent: 'my-repo' as RepoName,
+      },
+      {
+        text: 'Tags',
+        value: 'repotags',
+        view: GerritView.REPO,
+        url: '',
+        detailType: GerritNav.RepoDetailView.TAGS,
+        parent: 'my-repo' as RepoName,
+      },
+      {
+        text: 'Dashboards',
+        value: 'repodashboards',
+        view: GerritView.REPO,
+        url: '',
+        detailType: GerritNav.RepoDetailView.DASHBOARDS,
+        parent: 'my-repo' as RepoName,
+      },
+    ];
+    const navigateToRelativeUrlStub = sinon.stub(
+      GerritNav,
+      'navigateToRelativeUrl'
+    );
+    const selectedIsCurrentPageSpy = sinon.spy(
+      element,
+      'selectedIsCurrentPage'
+    );
+    sinon.spy(element, 'handleSubsectionChange');
+    await element.reload();
+    assert.deepEqual(element.filteredLinks, expectedFilteredLinks);
+    assert.deepEqual(element.subsectionLinks, expectedSubsectionLinks);
+    assert.equal(
+      queryAndAssert<GrDropdownList>(element, '#pageSelect').value,
+      'repoaccess'
+    );
+    assert.isTrue(selectedIsCurrentPageSpy.calledOnce);
+    // Doesn't trigger navigation from the page select menu.
+    assert.isFalse(navigateToRelativeUrlStub.called);
+
+    // When explicitly changed, navigation is called
+    queryAndAssert<GrDropdownList>(element, '#pageSelect').value =
+      'repogeneral';
+    assert.isTrue(selectedIsCurrentPageSpy.calledTwice);
+    assert.isTrue(navigateToRelativeUrlStub.calledOnce);
+  });
+
+  test('selectedIsCurrentPage', () => {
+    element.repoName = 'my-repo' as RepoName;
+    element.params = {view: GerritView.REPO, repo: 'my-repo' as RepoName};
+    const selected = {
+      view: GerritView.REPO,
+      parent: 'my-repo' as RepoName,
+      value: '',
+      text: '',
+    } as AdminSubsectionLink;
+    assert.isTrue(element.selectedIsCurrentPage(selected));
+    selected.parent = 'my-second-repo' as RepoName;
+    assert.isFalse(element.selectedIsCurrentPage(selected));
+    selected.detailType = GerritNav.RepoDetailView.GENERAL;
+    assert.isFalse(element.selectedIsCurrentPage(selected));
+  });
+
+  suite('computeSelectedClass', () => {
+    setup(async () => {
+      stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities())
+      );
+      stubRestApi('getAccount').returns(
+        Promise.resolve({
+          _id: 1,
+          registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+        })
+      );
+      await element.reload();
+    });
+
+    suite('repos', () => {
+      setup(() => {
+        stub('gr-repo-access', '_repoChanged').callsFake(() =>
+          Promise.resolve()
+        );
+      });
+
+      test('repo list', async () => {
+        element.params = {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-repo-list',
+          openCreateModal: false,
+        };
+        await element.updateComplete;
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'Repositories');
+      });
+
+      test('repo', async () => {
+        element.params = {
+          view: GerritNav.View.REPO,
+          repo: 'foo' as RepoName,
+        };
+        element.repoName = 'foo' as RepoName;
+        await element.reload();
+        await element.updateComplete;
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'foo');
+      });
+
+      test('repo access', async () => {
+        element.params = {
+          view: GerritNav.View.REPO,
+          detail: GerritNav.RepoDetailView.ACCESS,
+          repo: 'foo' as RepoName,
+        };
+        element.repoName = 'foo' as RepoName;
+        await element.reload();
+        await element.updateComplete;
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'Access');
+      });
+
+      test('repo dashboards', async () => {
+        element.params = {
+          view: GerritNav.View.REPO,
+          detail: GerritNav.RepoDetailView.DASHBOARDS,
+          repo: 'foo' as RepoName,
+        };
+        element.repoName = 'foo' as RepoName;
+        await element.reload();
+        await element.updateComplete;
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'Dashboards');
+      });
+    });
+
+    suite('groups', () => {
+      let getGroupConfigStub: sinon.SinonStub;
+
+      setup(async () => {
+        stub('gr-group', 'loadGroup').callsFake(() => Promise.resolve());
+        stub('gr-group-members', 'loadGroupDetails').callsFake(() =>
+          Promise.resolve()
+        );
+
+        getGroupConfigStub = stubRestApi('getGroupConfig');
+        getGroupConfigStub.returns(
+          Promise.resolve({
+            name: 'foo',
+            id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
+          })
+        );
+        stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+        await element.reload();
+      });
+
+      test('group list', async () => {
+        element.params = {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          openCreateModal: false,
+        };
+        await element.updateComplete;
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'Groups');
+      });
+
+      test('internal group', async () => {
+        element.params = {
+          view: GerritNav.View.GROUP,
+          groupId: '1234' as GroupId,
+        };
+        element.groupName = 'foo' as GroupName;
+        await element.reload();
+        await element.updateComplete;
+        const subsectionItems = queryAll<HTMLLIElement>(
+          element,
+          '.subsectionItem'
+        );
+        assert.equal(subsectionItems.length, 2);
+        assert.isTrue(element.groupIsInternal);
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'foo');
+      });
+
+      test('external group', async () => {
+        getGroupConfigStub.returns(
+          Promise.resolve({
+            name: 'foo',
+            id: 'external-id',
+          })
+        );
+        element.params = {
+          view: GerritNav.View.GROUP,
+          groupId: '1234' as GroupId,
+        };
+        element.groupName = 'foo' as GroupName;
+        await element.reload();
+        await element.updateComplete;
+        const subsectionItems = queryAll<HTMLLIElement>(
+          element,
+          '.subsectionItem'
+        );
+        assert.equal(subsectionItems.length, 0);
+        assert.isFalse(element.groupIsInternal);
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'foo');
+      });
+
+      test('group members', async () => {
+        element.params = {
+          view: GerritNav.View.GROUP,
+          detail: GerritNav.GroupDetailView.MEMBERS,
+          groupId: '1234' as GroupId,
+        };
+        element.groupName = 'foo' as GroupName;
+        await element.reload();
+        await element.updateComplete;
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'Members');
+      });
+    });
+  });
+});
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 84b7bce..8e797b7 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
@@ -20,121 +20,208 @@
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-create-change-dialog_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, observe} from '@polymer/decorators';
 import {
   RepoName,
   BranchName,
   ChangeId,
-  ConfigInfo,
   InheritedBooleanInfo,
 } from '../../../types/common';
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
-import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import {appContext} from '../../../services/app-context';
-import {Subject} from 'rxjs';
-import {
-  repoConfig$,
-  serverConfig$,
-} from '../../../services/config/config-model';
-import {takeUntil} from 'rxjs/operators';
-import {IronInputElement} from '@polymer/iron-input/iron-input';
+import {getAppContext} from '../../../services/app-context';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fireEvent} from '../../../utils/event-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {resolve} from '../../../models/dependency';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
 
-export interface GrCreateChangeDialog {
-  $: {
-    privateChangeCheckBox: HTMLInputElement;
-    branchInput: GrTypedAutocomplete<BranchName>;
-    tagNameInput: IronInputElement;
-    messageInput: IronAutogrowTextareaElement;
-  };
-}
-@customElement('gr-create-change-dialog')
-export class GrCreateChangeDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-change-dialog': GrCreateChangeDialog;
   }
+}
+
+@customElement('gr-create-change-dialog')
+export class GrCreateChangeDialog extends LitElement {
+  // private but used in test
+  @query('#privateChangeCheckBox') privateChangeCheckBox!: HTMLInputElement;
 
   @property({type: String})
   repoName?: RepoName;
 
-  @property({type: String})
-  branch = '' as BranchName;
+  // private but used in test
+  @state() branch = '' as BranchName;
 
-  @property({type: Object})
-  _repoConfig?: ConfigInfo;
+  // private but used in test
+  @state() subject = '';
 
-  @property({type: String})
-  subject = '';
-
-  @property({type: String})
-  topic?: string;
-
-  @property({type: Object})
-  _query?: (input: string) => Promise<{name: BranchName}[]>;
+  // private but used in test
+  @state() topic?: string;
 
   @property({type: String})
   baseChange?: ChangeId;
 
-  @property({type: String})
-  baseCommit?: string;
+  @state() private baseCommit?: string;
 
   @property({type: Object})
   privateByDefault?: InheritedBooleanInfo;
 
-  @property({type: Boolean, notify: true})
-  canCreate = false;
+  @state() private privateChangesEnabled = false;
 
-  @property({type: Boolean})
-  _privateChangesEnabled = false;
+  private readonly query: (input: string) => Promise<{name: BranchName}[]>;
 
-  restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  disconnected$ = new Subject();
+  private readonly configModel = resolve(this, configModelToken);
 
   constructor() {
     super();
-    this._query = (input: string) => this._getRepoBranchesSuggestions(input);
+    this.query = (input: string) => this.getRepoBranchesSuggestions(input);
   }
 
   override connectedCallback() {
     super.connectedCallback();
     if (!this.repoName) return;
 
-    repoConfig$.pipe(takeUntil(this.disconnected$)).subscribe(config => {
-      this.privateByDefault = config?.private_by_default;
-    });
-
-    serverConfig$.pipe(takeUntil(this.disconnected$)).subscribe(config => {
-      this._privateChangesEnabled =
+    subscribe(this, this.configModel().serverConfig$, config => {
+      this.privateChangesEnabled =
         config?.change?.disable_private_changes ?? false;
     });
   }
 
-  override disconnectedCallback() {
-    this.disconnected$.next();
-    super.disconnectedCallback();
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      css`
+        input:not([type='checkbox']),
+        gr-autocomplete,
+        iron-autogrow-textarea {
+          width: 100%;
+        }
+        .value {
+          width: 32em;
+        }
+        .hide {
+          display: none;
+        }
+        @media only screen and (max-width: 40em) {
+          .value {
+            width: 29em;
+          }
+        }
+      `,
+    ];
   }
 
-  _computeBranchClass(baseChange?: ChangeId) {
-    return baseChange ? 'hide' : '';
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <section class=${this.baseChange ? 'hide' : ''}>
+          <span class="title">Select branch for new change</span>
+          <span class="value">
+            <gr-autocomplete
+              id="branchInput"
+              .text=${this.branch}
+              .query=${this.query}
+              placeholder="Destination branch"
+              @text-changed=${(e: CustomEvent) => {
+                this.branch = e.detail.value;
+              }}
+            >
+            </gr-autocomplete>
+          </span>
+        </section>
+        <section class=${this.baseChange ? 'hide' : ''}>
+          <span class="title">Provide base commit sha1 for change</span>
+          <span class="value">
+            <iron-input
+              .bindValue=${this.baseCommit}
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                this.baseCommit = e.detail.value;
+              }}
+            >
+              <input
+                id="baseCommitInput"
+                maxlength="40"
+                placeholder="(optional)"
+              />
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <span class="title">Enter topic for new change</span>
+          <span class="value">
+            <iron-input
+              .bindValue=${this.topic}
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                this.topic = e.detail.value;
+              }}
+            >
+              <input
+                id="tagNameInput"
+                maxlength="1024"
+                placeholder="(optional)"
+              />
+            </iron-input>
+          </span>
+        </section>
+        <section id="description">
+          <span class="title">Description</span>
+          <span class="value">
+            <iron-autogrow-textarea
+              id="messageInput"
+              class="message"
+              autocomplete="on"
+              rows="4"
+              maxRows="15"
+              .bindValue=${this.subject}
+              placeholder="Insert the description of the change."
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                this.subject = e.detail.value;
+              }}
+            >
+            </iron-autogrow-textarea>
+          </span>
+        </section>
+        <section class=${this.privateChangesEnabled ? 'hide' : ''}>
+          <label class="title" for="privateChangeCheckBox"
+            >Private change</label
+          >
+          <span class="value">
+            <input
+              type="checkbox"
+              id="privateChangeCheckBox"
+              ?checked=${this.formatPrivateByDefaultBoolean()}
+            />
+          </span>
+        </section>
+      </div>
+    `;
   }
 
-  @observe('branch', 'subject')
-  _allowCreate(branch: BranchName, subject: string) {
-    this.canCreate = !!branch && !!subject;
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('branch') || changedProperties.has('subject')) {
+      this.allowCreate();
+    }
+  }
+
+  private allowCreate() {
+    fireEvent(this, 'can-create-change');
   }
 
   handleCreateChange(): Promise<void> {
     if (!this.repoName || !this.branch || !this.subject) {
       return Promise.resolve();
     }
-    const isPrivate = this.$.privateChangeCheckBox.checked;
+    const isPrivate = this.privateChangeCheckBox.checked;
     const isWip = true;
     return this.restApiService
       .createChange(
@@ -148,14 +235,13 @@
         this.baseCommit || undefined
       )
       .then(changeCreated => {
-        if (!changeCreated) {
-          return;
-        }
+        if (!changeCreated) return;
         GerritNav.navigateToChange(changeCreated);
       });
   }
 
-  _getRepoBranchesSuggestions(input: string) {
+  // private but used in test
+  getRepoBranchesSuggestions(input: string) {
     if (!this.repoName) {
       return Promise.reject(new Error('missing repo name'));
     }
@@ -178,34 +264,19 @@
       });
   }
 
-  _formatBooleanString(config?: InheritedBooleanInfo) {
-    if (
-      config &&
-      config.configured_value === InheritedBooleanInfoConfiguredValue.TRUE
-    ) {
-      return true;
-    } else if (
-      config &&
-      config.configured_value === InheritedBooleanInfoConfiguredValue.FALSE
-    ) {
-      return false;
-    } else if (
-      config &&
-      config.configured_value === InheritedBooleanInfoConfiguredValue.INHERIT
-    ) {
-      return !!(config && config.inherited_value);
-    } else {
-      return false;
+  // private but used in test
+  formatPrivateByDefaultBoolean() {
+    const config = this.privateByDefault;
+    if (config === undefined) return false;
+    switch (config.configured_value) {
+      case InheritedBooleanInfoConfiguredValue.TRUE:
+        return true;
+      case InheritedBooleanInfoConfiguredValue.FALSE:
+        return false;
+      case InheritedBooleanInfoConfiguredValue.INHERIT:
+        return !!config.inherited_value;
+      default:
+        return false;
     }
   }
-
-  _computePrivateSectionClass(config: boolean) {
-    return config ? 'hide' : '';
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-create-change-dialog': GrCreateChangeDialog;
-  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
deleted file mode 100644
index 47f3818..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
+++ /dev/null
@@ -1,116 +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">
-    input:not([type='checkbox']),
-    gr-autocomplete,
-    iron-autogrow-textarea {
-      width: 100%;
-    }
-    .value {
-      width: 32em;
-    }
-    .hide {
-      display: none;
-    }
-    @media only screen and (max-width: 40em) {
-      .value {
-        width: 29em;
-      }
-    }
-  </style>
-  <div class="gr-form-styles">
-    <section class$="[[_computeBranchClass(baseChange)]]">
-      <span class="title">Select branch for new change</span>
-      <span class="value">
-        <gr-autocomplete
-          id="branchInput"
-          text="{{branch}}"
-          query="[[_query]]"
-          placeholder="Destination branch"
-        >
-        </gr-autocomplete>
-      </span>
-    </section>
-    <section class$="[[_computeBranchClass(baseChange)]]">
-      <span class="title">Provide base commit sha1 for change</span>
-      <span class="value">
-        <iron-input
-          maxlength="40"
-          placeholder="(optional)"
-          bind-value="{{baseCommit}}"
-        >
-          <input
-            is="iron-input"
-            id="baseCommitInput"
-            maxlength="40"
-            placeholder="(optional)"
-            bind-value="{{baseCommit}}"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <span class="title">Enter topic for new change</span>
-      <span class="value">
-        <iron-input
-          maxlength="1024"
-          placeholder="(optional)"
-          bind-value="{{topic}}"
-        >
-          <input
-            is="iron-input"
-            id="tagNameInput"
-            maxlength="1024"
-            placeholder="(optional)"
-            bind-value="{{topic}}"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section id="description">
-      <span class="title">Description</span>
-      <span class="value">
-        <iron-autogrow-textarea
-          id="messageInput"
-          class="message"
-          autocomplete="on"
-          rows="4"
-          max-rows="15"
-          bind-value="{{subject}}"
-          placeholder="Insert the description of the change."
-        >
-        </iron-autogrow-textarea>
-      </span>
-    </section>
-    <section class$="[[_computePrivateSectionClass(_privateChangesEnabled)]]">
-      <label class="title" for="privateChangeCheckBox">Private change</label>
-      <span class="value">
-        <input
-          type="checkbox"
-          id="privateChangeCheckBox"
-          checked$="[[_formatBooleanString(privateByDefault)]]"
-        />
-      </span>
-    </section>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
index 9ed5d81..626b03f 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
@@ -20,19 +20,16 @@
 import {GrCreateChangeDialog} from './gr-create-change-dialog';
 import {BranchName, GitRef, RepoName} from '../../../types/common';
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
-import {
-  createChange,
-  createConfig,
-  TEST_CHANGE_ID,
-} from '../../../test/test-data-generators';
-import {stubRestApi} from '../../../test/test-utils';
+import {createChange} from '../../../test/test-data-generators';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 
 const basicFixture = fixtureFromElement('gr-create-change-dialog');
 
 suite('gr-create-change-dialog tests', () => {
   let element: GrCreateChangeDialog;
 
-  setup(() => {
+  setup(async () => {
     stubRestApi('getRepoBranches').callsFake((input: string) => {
       if (input.startsWith('test')) {
         return Promise.resolve([
@@ -47,15 +44,8 @@
       }
     });
     element = basicFixture.instantiate();
+    await element.updateComplete;
     element.repoName = 'test-repo' as RepoName;
-    element._repoConfig = {
-      ...createConfig(),
-      private_by_default: {
-        value: false,
-        configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
-        inherited_value: false,
-      },
-    };
   });
 
   test('new change created with default', async () => {
@@ -74,9 +64,13 @@
     element.branch = 'test-branch' as BranchName;
     element.topic = 'test-topic';
     element.subject = 'first change created with polygerrit ui';
-    assert.isFalse(element.$.privateChangeCheckBox.checked);
+    assert.isFalse(element.privateChangeCheckBox.checked);
 
-    element.$.messageInput.bindValue = configInputObj.subject;
+    const messageInput = queryAndAssert<IronAutogrowTextareaElement>(
+      element,
+      '#messageInput'
+    );
+    messageInput.bindValue = configInputObj.subject;
 
     await element.handleCreateChange();
     // Private change
@@ -92,8 +86,8 @@
       inherited_value: false,
       value: true,
     };
-    sinon.stub(element, '_formatBooleanString').callsFake(() => true);
-    flush();
+    sinon.stub(element, 'formatPrivateByDefaultBoolean').callsFake(() => true);
+    await element.updateComplete;
 
     const configInputObj = {
       branch: 'test-branch',
@@ -110,9 +104,13 @@
     element.branch = 'test-branch' as BranchName;
     element.topic = 'test-topic';
     element.subject = 'first change created with polygerrit ui';
-    assert.isTrue(element.$.privateChangeCheckBox.checked);
+    assert.isTrue(element.privateChangeCheckBox.checked);
 
-    element.$.messageInput.bindValue = configInputObj.subject;
+    const messageInput = queryAndAssert<IronAutogrowTextareaElement>(
+      element,
+      '#messageInput'
+    );
+    messageInput.bindValue = configInputObj.subject;
 
     await element.handleCreateChange();
     // Private change
@@ -122,24 +120,14 @@
     assert.isTrue(saveStub.called);
   });
 
-  test('_getRepoBranchesSuggestions empty', async () => {
-    const branches = await element._getRepoBranchesSuggestions('nonexistent');
+  test('getRepoBranchesSuggestions empty', async () => {
+    const branches = await element.getRepoBranchesSuggestions('nonexistent');
     assert.equal(branches.length, 0);
   });
 
-  test('_getRepoBranchesSuggestions non-empty', async () => {
-    const branches = await element._getRepoBranchesSuggestions('test-branch');
+  test('getRepoBranchesSuggestions non-empty', async () => {
+    const branches = await element.getRepoBranchesSuggestions('test-branch');
     assert.equal(branches.length, 1);
     assert.equal(branches[0].name, 'test-branch');
   });
-
-  test('_computeBranchClass', () => {
-    assert.equal(element._computeBranchClass(TEST_CHANGE_ID), 'hide');
-    assert.equal(element._computeBranchClass(undefined), '');
-  });
-
-  test('_computePrivateSectionClass', () => {
-    assert.equal(element._computePrivateSectionClass(true), 'hide');
-    assert.equal(element._computePrivateSectionClass(false), '');
-  });
 });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index 180e60a..9ab7646 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -17,61 +17,95 @@
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-create-group-dialog_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
-import {customElement, property, observe} from '@polymer/decorators';
 import {GroupName} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
-
-@customElement('gr-create-group-dialog')
-export class GrCreateGroupDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Boolean, notify: true})
-  hasNewGroupName = false;
-
-  @property({type: String})
-  _name: GroupName | '' = '';
-
-  @property({type: Boolean})
-  _groupCreated = false;
-
-  private readonly restApiService = appContext.restApiService;
-
-  _computeGroupUrl(groupId: string) {
-    return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
-  }
-
-  @observe('_name')
-  _updateGroupName(name: string) {
-    this.hasNewGroupName = !!name;
-  }
-
-  override focus() {
-    this.shadowRoot?.querySelector('input')?.focus();
-  }
-
-  handleCreateGroup() {
-    const name = this._name as GroupName;
-    return this.restApiService.createGroup({name}).then(groupRegistered => {
-      if (groupRegistered.status !== 201) {
-        return;
-      }
-      this._groupCreated = true;
-      return this.restApiService.getGroupConfig(name).then(group => {
-        // TODO(TS): should group always defined ?
-        page.show(this._computeGroupUrl(String(group!.group_id!)));
-      });
-    });
-  }
-}
+import {getAppContext} from '../../../services/app-context';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, query, property} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-create-group-dialog': GrCreateGroupDialog;
   }
 }
+
+@customElement('gr-create-group-dialog')
+export class GrCreateGroupDialog extends LitElement {
+  @query('input') private input!: HTMLInputElement;
+
+  @property({type: String})
+  name: GroupName | '' = '';
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+        }
+        input {
+          width: 20em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <div id="form">
+          <section>
+            <span class="title">Group name</span>
+            <iron-input
+              .bindValue=${this.name}
+              @bind-value-changed=${this.handleGroupNameBindValueChanged}
+            >
+              <input />
+            </iron-input>
+          </section>
+        </div>
+      </div>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('name')) {
+      this.updateGroupName();
+    }
+  }
+
+  private updateGroupName() {
+    fireEvent(this, 'has-new-group-name');
+  }
+
+  private computeGroupUrl(groupId: string) {
+    return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
+  }
+
+  override focus() {
+    this.input.focus();
+  }
+
+  handleCreateGroup() {
+    const name = this.name as GroupName;
+    return this.restApiService.createGroup({name}).then(groupRegistered => {
+      if (groupRegistered.status !== 201) return;
+      return this.restApiService.getGroupConfig(name).then(group => {
+        if (!group) return;
+        page.show(this.computeGroupUrl(String(group.group_id!)));
+      });
+    });
+  }
+
+  private handleGroupNameBindValueChanged(e: BindValueChangeEvent) {
+    this.name = e.detail.value as GroupName;
+  }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
deleted file mode 100644
index daf8780..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
+++ /dev/null
@@ -1,41 +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">
-    :host {
-      display: inline-block;
-    }
-    input {
-      width: 20em;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <div id="form">
-      <section>
-        <span class="title">Group name</span>
-        <iron-input bind-value="{{_name}}">
-          <input is="iron-input" bind-value="{{_name}}" />
-        </iron-input>
-      </section>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
deleted file mode 100644
index 321f069..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
+++ /dev/null
@@ -1,63 +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-create-group-dialog.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-create-group-dialog');
-
-suite('gr-create-group-dialog tests', () => {
-  let element;
-
-  const GROUP_NAME = 'test-group';
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('name is updated correctly', async () => {
-    assert.isFalse(element.hasNewGroupName);
-
-    const inputEl = element.root.querySelector('iron-input');
-    inputEl.bindValue = GROUP_NAME;
-
-    await new Promise(resolve => setTimeout(resolve));
-    assert.isTrue(element.hasNewGroupName);
-    assert.deepEqual(element._name, GROUP_NAME);
-  });
-
-  test('test for redirecting to group on successful creation', async () => {
-    stubRestApi('createGroup').returns(Promise.resolve({status: 201}));
-    stubRestApi('getGroupConfig').returns(Promise.resolve({group_id: 551}));
-
-    const showStub = sinon.stub(page, 'show');
-    await element.handleCreateGroup();
-    assert.isTrue(showStub.calledWith('/admin/groups/551'));
-  });
-
-  test('test for unsuccessful group creation', async () => {
-    stubRestApi('createGroup').returns(Promise.resolve({status: 409}));
-    stubRestApi('getGroupConfig').returns(Promise.resolve({group_id: 551}));
-
-    const showStub = sinon.stub(page, 'show');
-    await element.handleCreateGroup();
-    assert.isFalse(showStub.called);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
new file mode 100644
index 0000000..f84c76c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
@@ -0,0 +1,82 @@
+/**
+ * @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-create-group-dialog';
+import {GrCreateGroupDialog} from './gr-create-group-dialog';
+import {page} from '../../../utils/page-wrapper-utils';
+import {
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {IronInputElement} from '@polymer/iron-input';
+import {GroupId} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-create-group-dialog');
+
+suite('gr-create-group-dialog tests', () => {
+  let element: GrCreateGroupDialog;
+
+  const GROUP_NAME = 'test-group';
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  test('name is updated correctly', async () => {
+    const promise = mockPromise();
+    element.addEventListener('has-new-group-name', () => {
+      promise.resolve();
+    });
+
+    const inputEl = queryAndAssert<IronInputElement>(element, 'iron-input');
+    inputEl.bindValue = GROUP_NAME;
+    inputEl.dispatchEvent(new Event('input', {bubbles: true, composed: true}));
+
+    await promise;
+
+    assert.deepEqual(element.name, GROUP_NAME);
+  });
+
+  test('test for redirecting to group on successful creation', async () => {
+    stubRestApi('createGroup').returns(
+      Promise.resolve({status: 201} as Response)
+    );
+    stubRestApi('getGroupConfig').returns(
+      Promise.resolve({id: 'testId551' as GroupId, group_id: 551})
+    );
+
+    const showStub = sinon.stub(page, 'show');
+    await element.handleCreateGroup();
+    assert.isTrue(showStub.calledWith('/admin/groups/551'));
+  });
+
+  test('test for unsuccessful group creation', async () => {
+    stubRestApi('createGroup').returns(
+      Promise.resolve({status: 409} as Response)
+    );
+    stubRestApi('getGroupConfig').returns(
+      Promise.resolve({id: 'testId551' as GroupId, group_id: 551})
+    );
+
+    const showStub = sinon.stub(page, 'show');
+    await element.handleCreateGroup();
+    assert.isFalse(showStub.called);
+  });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
index 7fce8e5..63b852c 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -15,65 +15,132 @@
  * 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-create-pointer-dialog_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
-import {customElement, property, observe} from '@polymer/decorators';
 import {BranchName, RepoName} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fireEvent} from '../../../utils/event-util';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-pointer-dialog': GrCreatePointerDialog;
+  }
+}
 
 @customElement('gr-create-pointer-dialog')
-export class GrCreatePointerDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrCreatePointerDialog extends LitElement {
   @property({type: String})
   detailType?: string;
 
   @property({type: String})
   repoName?: RepoName;
 
-  @property({type: Boolean, notify: true})
-  hasNewItemName = false;
-
   @property({type: String})
   itemDetail?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
 
-  @property({type: String})
-  _itemName?: BranchName;
+  /* private but used in test */
+  @state() itemName?: BranchName;
 
-  @property({type: String})
-  _itemRevision?: string;
+  /* private but used in test */
+  @state() itemRevision?: string;
 
-  @property({type: String})
-  _itemAnnotation?: string;
+  /* private but used in test */
+  @state() itemAnnotation?: string;
 
-  @observe('_itemName')
-  _updateItemName(name?: string) {
-    this.hasNewItemName = !!name;
+  private readonly restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+        }
+        input {
+          width: 20em;
+        }
+        /* Add css selector with #id to increase priority
+          (otherwise ".gr-form-styles section" rule wins) */
+        .hideItem,
+        #itemAnnotationSection.hideItem {
+          display: none;
+        }
+      `,
+    ];
   }
 
-  private readonly restApiService = appContext.restApiService;
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <div id="form">
+          <section id="itemNameSection">
+            <span class="title">${this.detailType} name</span>
+            <iron-input
+              .bindValue=${this.itemName}
+              @bind-value-changed=${this.handleItemNameBindValueChanged}
+            >
+              <input placeholder="${this.detailType} Name" />
+            </iron-input>
+          </section>
+          <section id="itemRevisionSection">
+            <span class="title">Initial Revision</span>
+            <iron-input
+              .bindValue=${this.itemRevision}
+              @bind-value-changed=${this.handleItemRevisionBindValueChanged}
+            >
+              <input placeholder="Revision (Branch or SHA-1)" />
+            </iron-input>
+          </section>
+          <section
+            id="itemAnnotationSection"
+            class=${this.itemDetail === RepoDetailView.BRANCHES
+              ? 'hideItem'
+              : ''}
+          >
+            <span class="title">Annotation</span>
+            <iron-input
+              .bindValue=${this.itemAnnotation}
+              @bind-value-changed=${this.handleItemAnnotationBindValueChanged}
+            >
+              <input placeholder="Annotation (Optional)" />
+            </iron-input>
+          </section>
+        </div>
+      </div>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('itemName')) {
+      this.updateItemName();
+    }
+  }
+
+  private updateItemName() {
+    fireEvent(this, 'update-item-name');
+  }
 
   handleCreateItem() {
     if (!this.repoName) {
       throw new Error('repoName name is not set');
     }
-    if (!this._itemName) {
+    if (!this.itemName) {
       throw new Error('itemName name is not set');
     }
-    const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
+    const USE_HEAD = this.itemRevision ? this.itemRevision : 'HEAD';
     const url = `${getBaseUrl()}/admin/repos/${encodeURL(this.repoName, true)}`;
     if (this.itemDetail === RepoDetailView.BRANCHES) {
       return this.restApiService
-        .createRepoBranch(this.repoName, this._itemName, {revision: USE_HEAD})
+        .createRepoBranch(this.repoName, this.itemName, {revision: USE_HEAD})
         .then(itemRegistered => {
           if (itemRegistered.status === 201) {
             page.show(`${url},branches`);
@@ -81,9 +148,9 @@
         });
     } else if (this.itemDetail === RepoDetailView.TAGS) {
       return this.restApiService
-        .createRepoTag(this.repoName, this._itemName, {
+        .createRepoTag(this.repoName, this.itemName, {
           revision: USE_HEAD,
-          message: this._itemAnnotation || undefined,
+          message: this.itemAnnotation || undefined,
         })
         .then(itemRegistered => {
           if (itemRegistered.status === 201) {
@@ -94,13 +161,15 @@
     throw new Error(`Invalid itemDetail: ${this.itemDetail}`);
   }
 
-  _computeHideItemClass(type?: RepoDetailView.BRANCHES | RepoDetailView.TAGS) {
-    return type === RepoDetailView.BRANCHES ? 'hideItem' : '';
+  private handleItemNameBindValueChanged(e: BindValueChangeEvent) {
+    this.itemName = e.detail.value as BranchName;
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-create-pointer-dialog': GrCreatePointerDialog;
+  private handleItemRevisionBindValueChanged(e: BindValueChangeEvent) {
+    this.itemRevision = e.detail.value;
+  }
+
+  private handleItemAnnotationBindValueChanged(e: BindValueChangeEvent) {
+    this.itemAnnotation = e.detail.value;
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
deleted file mode 100644
index 0e2b157..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
+++ /dev/null
@@ -1,62 +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">
-    :host {
-      display: inline-block;
-    }
-    input {
-      width: 20em;
-    }
-    /* Add css selector with #id to increase priority
-      (otherwise ".gr-form-styles section" rule wins) */
-    .hideItem,
-    #itemAnnotationSection.hideItem {
-      display: none;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <div id="form">
-      <section id="itemNameSection">
-        <span class="title">[[detailType]] name</span>
-        <iron-input bind-value="{{_itemName}}">
-          <input placeholder="[[detailType]] Name" />
-        </iron-input>
-      </section>
-      <section id="itemRevisionSection">
-        <span class="title">Initial Revision</span>
-        <iron-input bind-value="{{_itemRevision}}">
-          <input placeholder="Revision (Branch or SHA-1)" />
-        </iron-input>
-      </section>
-      <section
-        id="itemAnnotationSection"
-        class$="[[_computeHideItemClass(itemDetail)]]"
-      >
-        <span class="title">Annotation</span>
-        <iron-input bind-value="{{_itemAnnotation}}">
-          <input placeholder="Annotation (Optional)" />
-        </iron-input>
-      </section>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts
index ea0919c..b888c348 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts
@@ -18,7 +18,11 @@
 import '../../../test/common-test-setup-karma';
 import './gr-create-pointer-dialog';
 import {GrCreatePointerDialog} from './gr-create-pointer-dialog';
-import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
 import {BranchName} from '../../../types/common';
 import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
 import {IronInputElement} from '@polymer/iron-input';
@@ -31,73 +35,78 @@
   const ironInput = (element: Element) =>
     queryAndAssert<IronInputElement>(element, 'iron-input');
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
   test('branch created', async () => {
     stubRestApi('createRepoBranch').returns(Promise.resolve(new Response()));
 
-    assert.isFalse(element.hasNewItemName);
+    const promise = mockPromise();
+    element.addEventListener('update-item-name', () => {
+      promise.resolve();
+    });
 
-    element._itemName = 'test-branch' as BranchName;
+    element.itemName = 'test-branch' as BranchName;
     element.itemDetail = 'branches' as RepoDetailView.BRANCHES;
 
-    ironInput(element.$.itemNameSection).bindValue = 'test-branch2';
-    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+    ironInput(queryAndAssert(element, '#itemNameSection')).bindValue =
+      'test-branch2';
+    ironInput(queryAndAssert(element, '#itemRevisionSection')).bindValue =
+      'HEAD';
 
-    await flush();
+    await promise;
 
-    assert.isTrue(element.hasNewItemName);
-    assert.equal(element._itemName, 'test-branch2' as BranchName);
-    assert.equal(element._itemRevision, 'HEAD');
+    assert.equal(element.itemName, 'test-branch2' as BranchName);
+    assert.equal(element.itemRevision, 'HEAD');
   });
 
   test('tag created', async () => {
     stubRestApi('createRepoTag').returns(Promise.resolve(new Response()));
 
-    assert.isFalse(element.hasNewItemName);
+    const promise = mockPromise();
+    element.addEventListener('update-item-name', () => {
+      promise.resolve();
+    });
 
-    element._itemName = 'test-tag' as BranchName;
+    element.itemName = 'test-tag' as BranchName;
     element.itemDetail = 'tags' as RepoDetailView.TAGS;
 
-    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
-    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+    ironInput(queryAndAssert(element, '#itemNameSection')).bindValue =
+      'test-tag2';
+    ironInput(queryAndAssert(element, '#itemRevisionSection')).bindValue =
+      'HEAD';
 
-    await flush();
-    assert.isTrue(element.hasNewItemName);
-    assert.equal(element._itemName, 'test-tag2' as BranchName);
-    assert.equal(element._itemRevision, 'HEAD');
+    await promise;
+
+    assert.equal(element.itemName, 'test-tag2' as BranchName);
+    assert.equal(element.itemRevision, 'HEAD');
   });
 
   test('tag created with annotations', async () => {
     stubRestApi('createRepoTag').returns(Promise.resolve(new Response()));
 
-    assert.isFalse(element.hasNewItemName);
+    const promise = mockPromise();
+    element.addEventListener('update-item-name', () => {
+      promise.resolve();
+    });
 
-    element._itemName = 'test-tag' as BranchName;
-    element._itemAnnotation = 'test-message';
+    element.itemName = 'test-tag' as BranchName;
+    element.itemAnnotation = 'test-message';
     element.itemDetail = 'tags' as RepoDetailView.TAGS;
 
-    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
-    ironInput(element.$.itemAnnotationSection).bindValue = 'test-message2';
-    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+    ironInput(queryAndAssert(element, '#itemNameSection')).bindValue =
+      'test-tag2';
+    ironInput(queryAndAssert(element, '#itemAnnotationSection')).bindValue =
+      'test-message2';
+    ironInput(queryAndAssert(element, '#itemRevisionSection')).bindValue =
+      'HEAD';
 
-    await flush();
-    assert.isTrue(element.hasNewItemName);
-    assert.equal(element._itemName, 'test-tag2' as BranchName);
-    assert.equal(element._itemAnnotation, 'test-message2');
-    assert.equal(element._itemRevision, 'HEAD');
-  });
+    await promise;
 
-  test('_computeHideItemClass returns hideItem if type is branches', () => {
-    assert.equal(
-      element._computeHideItemClass(RepoDetailView.BRANCHES),
-      'hideItem'
-    );
-  });
-
-  test('_computeHideItemClass returns strings if not branches', () => {
-    assert.equal(element._computeHideItemClass(RepoDetailView.TAGS), '');
+    assert.equal(element.itemName, 'test-tag2' as BranchName);
+    assert.equal(element.itemAnnotation, 'test-message2');
+    assert.equal(element.itemRevision, 'HEAD');
   });
 });
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 a493747..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
@@ -15,16 +15,11 @@
  * limitations under the License.
  */
 import '@polymer/iron-input/iron-input';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-create-repo-dialog_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
   BranchName,
   GroupId,
@@ -32,7 +27,13 @@
   RepoName,
 } from '../../../types/common';
 import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
+import {convertToString} from '../../../utils/string-util';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, query, property, state} from 'lit/decorators';
+import {fireEvent} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -41,46 +42,155 @@
 }
 
 @customElement('gr-create-repo-dialog')
-export class GrCreateRepoDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrCreateRepoDialog extends LitElement {
+  /**
+   * Fired when repostiory name is entered.
+   *
+   * @event new-repo-name
+   */
 
-  @property({type: Boolean, notify: true})
-  hasNewRepoName = false;
+  @query('input')
+  input?: HTMLInputElement;
 
-  @property({type: Object})
-  _repoConfig: ProjectInput & {name: RepoName} = {
+  @property({type: Boolean})
+  nameChanged = false;
+
+  /* private but used in test */
+  @state() repoConfig: ProjectInput & {name: RepoName} = {
     create_empty_commit: true,
     permissions_only: false,
     name: '' as RepoName,
     branches: [],
   };
 
-  @property({type: String})
-  _defaultBranch?: BranchName;
+  /* private but used in test */
+  @state() defaultBranch?: BranchName;
 
-  @property({type: Boolean})
-  _repoCreated = false;
+  /* private but used in test */
+  @state() repoCreated = false;
 
-  @property({type: String})
-  _repoOwner?: string;
+  /* private but used in test */
+  @state() repoOwner?: string;
 
-  @property({type: String})
-  _repoOwnerId?: GroupId;
+  /* private but used in test */
+  @state() repoOwnerId?: GroupId;
 
-  @property({type: Object})
-  _query: AutocompleteQuery;
+  private readonly query: AutocompleteQuery;
 
-  @property({type: Object})
-  _queryGroups: AutocompleteQuery;
+  private readonly queryGroups: AutocompleteQuery;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this._query = (input: string) => this._getRepoSuggestions(input);
-    this._queryGroups = (input: string) => this._getGroupSuggestions(input);
+    this.query = (input: string) => this.getRepoSuggestions(input);
+    this.queryGroups = (input: string) => this.getGroupSuggestions(input);
+  }
+
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+        }
+        input {
+          width: 20em;
+        }
+        gr-autocomplete {
+          width: 20em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <div id="form">
+          <section>
+            <span class="title">Repository name</span>
+            <iron-input
+              .bindValue=${convertToString(this.repoConfig.name)}
+              @bind-value-changed=${this.handleNameBindValueChanged}
+            >
+              <input id="repoNameInput" autocomplete="on" />
+            </iron-input>
+          </section>
+          <section>
+            <span class="title">Default Branch</span>
+            <iron-input
+              .bindValue=${convertToString(this.defaultBranch)}
+              @bind-value-changed=${this.handleBranchNameBindValueChanged}
+            >
+              <input id="defaultBranchNameInput" autocomplete="off" />
+            </iron-input>
+          </section>
+          <section>
+            <span class="title">Rights inherit from</span>
+            <span class="value">
+              <gr-autocomplete
+                id="rightsInheritFromInput"
+                .text=${convertToString(this.repoConfig.parent)}
+                .query=${this.query}
+                .placeholder=${"Optional, defaults to 'All-Projects'"}
+                @text-changed=${this.handleRightsTextChanged}
+              >
+              </gr-autocomplete>
+            </span>
+          </section>
+          <section>
+            <span class="title">Owner</span>
+            <span class="value">
+              <gr-autocomplete
+                id="ownerInput"
+                .text=${convertToString(this.repoOwner)}
+                .value=${convertToString(this.repoOwnerId)}
+                .query=${this.queryGroups}
+                @text-changed=${this.handleOwnerTextChanged}
+                @value-changed=${this.handleOwnerValueChanged}
+              >
+              </gr-autocomplete>
+            </span>
+          </section>
+          <section>
+            <span class="title">Create initial empty commit</span>
+            <span class="value">
+              <gr-select
+                id="initialCommit"
+                .bindValue=${this.repoConfig.create_empty_commit}
+                @bind-value-changed=${this
+                  .handleCreateEmptyCommitBindValueChanged}
+              >
+                <select>
+                  <option value="false">False</option>
+                  <option value="true">True</option>
+                </select>
+              </gr-select>
+            </span>
+          </section>
+          <section>
+            <span class="title"
+              >Only serve as parent for other repositories</span
+            >
+            <span class="value">
+              <gr-select
+                id="parentRepo"
+                .bindValue=${this.repoConfig.permissions_only}
+                @bind-value-changed=${this
+                  .handlePermissionsOnlyBindValueChanged}
+              >
+                <select>
+                  <option value="false">False</option>
+                  <option value="true">True</option>
+                </select>
+              </gr-select>
+            </span>
+          </section>
+        </div>
+      </div>
+    `;
   }
 
   _computeRepoUrl(repoName: string) {
@@ -88,44 +198,76 @@
   }
 
   override focus() {
-    this.shadowRoot?.querySelector('input')?.focus();
+    this.input?.focus();
   }
 
-  @observe('_repoConfig.name')
-  _updateRepoName(name: string) {
-    this.hasNewRepoName = !!name;
+  async handleCreateRepo() {
+    if (this.defaultBranch) this.repoConfig.branches = [this.defaultBranch];
+    if (this.repoOwnerId) this.repoConfig.owners = [this.repoOwnerId];
+    const repoRegistered = await this.restApiService.createRepo(
+      this.repoConfig
+    );
+    if (repoRegistered.status === 201) {
+      this.repoCreated = true;
+      page.show(this._computeRepoUrl(this.repoConfig.name));
+    }
+    return repoRegistered;
   }
 
-  handleCreateRepo() {
-    if (this._defaultBranch) this._repoConfig.branches = [this._defaultBranch];
-    if (this._repoOwnerId) this._repoConfig.owners = [this._repoOwnerId];
-    return this.restApiService
-      .createRepo(this._repoConfig)
-      .then(repoRegistered => {
-        if (repoRegistered.status === 201) {
-          this._repoCreated = true;
-          page.show(this._computeRepoUrl(this._repoConfig.name));
-        }
-      });
+  private async getRepoSuggestions(input: string) {
+    const response = await this.restApiService.getSuggestedProjects(input);
+
+    const repos = [];
+    for (const [name, project] of Object.entries(response ?? {})) {
+      repos.push({name, value: project.id});
+    }
+    return repos;
   }
 
-  _getRepoSuggestions(input: string) {
-    return this.restApiService.getSuggestedProjects(input).then(response => {
-      const repos = [];
-      for (const [name, project] of Object.entries(response ?? {})) {
-        repos.push({name, value: project.id});
-      }
-      return repos;
-    });
+  private async getGroupSuggestions(input: string) {
+    const response = await this.restApiService.getSuggestedGroups(input);
+
+    const groups = [];
+    for (const [name, group] of Object.entries(response ?? {})) {
+      groups.push({name, value: decodeURIComponent(group.id)});
+    }
+    return groups;
   }
 
-  _getGroupSuggestions(input: string) {
-    return this.restApiService.getSuggestedGroups(input).then(response => {
-      const groups = [];
-      for (const [name, group] of Object.entries(response ?? {})) {
-        groups.push({name, value: decodeURIComponent(group.id)});
-      }
-      return groups;
-    });
+  private handleRightsTextChanged(e: CustomEvent) {
+    this.repoConfig.parent = e.detail.value as RepoName;
+    this.requestUpdate();
+  }
+
+  private handleOwnerTextChanged(e: CustomEvent) {
+    this.repoOwner = e.detail.value;
+  }
+
+  private handleOwnerValueChanged(e: CustomEvent) {
+    this.repoOwnerId = e.detail.value as GroupId;
+  }
+
+  private handleNameBindValueChanged(e: CustomEvent) {
+    this.repoConfig.name = e.detail.value as RepoName;
+    // nameChanged needs to be set before the event is fired,
+    // because when the event is fired, gr-repo-list gets
+    // the nameChanged value.
+    this.nameChanged = !!e.detail.value;
+    fireEvent(this, 'new-repo-name');
+    this.requestUpdate();
+  }
+
+  private handleBranchNameBindValueChanged(e: CustomEvent) {
+    this.defaultBranch = e.detail.value as BranchName;
+  }
+
+  private handleCreateEmptyCommitBindValueChanged(e: CustomEvent) {
+    this.repoConfig.create_empty_commit = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handlePermissionsOnlyBindValueChanged(e: CustomEvent) {
+    this.repoConfig.permissions_only = e.detail.value;
+    this.requestUpdate();
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
deleted file mode 100644
index f529ac6..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
+++ /dev/null
@@ -1,113 +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">
-    :host {
-      display: inline-block;
-    }
-    input {
-      width: 20em;
-    }
-    gr-autocomplete {
-      width: 20em;
-    }
-  </style>
-
-  <div class="gr-form-styles">
-    <div id="form">
-      <section>
-        <span class="title">Repository name</span>
-        <iron-input autocomplete="on" bind-value="{{_repoConfig.name}}">
-          <input
-            is="iron-input"
-            id="repoNameInput"
-            autocomplete="on"
-            bind-value="{{_repoConfig.name}}"
-          />
-        </iron-input>
-      </section>
-      <section>
-        <span class="title">Default Branch</span>
-        <iron-input autocomplete="off" bind-value="{{_defaultBranch}}">
-          <input
-            is="iron-input"
-            id="defaultBranchNameInput"
-            autocomplete="off"
-            bind-value="{{_defaultBranch}}"
-          />
-        </iron-input>
-      </section>
-      <section>
-        <span class="title">Rights inherit from</span>
-        <span class="value">
-          <gr-autocomplete
-            id="rightsInheritFromInput"
-            text="{{_repoConfig.parent}}"
-            query="[[_query]]"
-            placeholder="Optional, defaults to 'All-Projects'"
-          >
-          </gr-autocomplete>
-        </span>
-      </section>
-      <section>
-        <span class="title">Owner</span>
-        <span class="value">
-          <gr-autocomplete
-            id="ownerInput"
-            text="{{_repoOwner}}"
-            value="{{_repoOwnerId}}"
-            query="[[_queryGroups]]"
-          >
-          </gr-autocomplete>
-        </span>
-      </section>
-      <section>
-        <span class="title">Create initial empty commit</span>
-        <span class="value">
-          <gr-select
-            id="initialCommit"
-            bind-value="{{_repoConfig.create_empty_commit}}"
-          >
-            <select>
-              <option value="false">False</option>
-              <option value="true">True</option>
-            </select>
-          </gr-select>
-        </span>
-      </section>
-      <section>
-        <span class="title">Only serve as parent for other repositories</span>
-        <span class="value">
-          <gr-select
-            id="parentRepo"
-            bind-value="{{_repoConfig.permissions_only}}"
-          >
-            <select>
-              <option value="false">False</option>
-              <option value="true">True</option>
-            </select>
-          </gr-select>
-        </span>
-      </section>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
deleted file mode 100644
index f1babee..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
+++ /dev/null
@@ -1,80 +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-create-repo-dialog.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-create-repo-dialog');
-
-suite('gr-create-repo-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('default values are populated', () => {
-    assert.isTrue(element.$.initialCommit.bindValue);
-    assert.isFalse(element.$.parentRepo.bindValue);
-  });
-
-  test('repo created', async () => {
-    const configInputObj = {
-      name: 'test-repo',
-      create_empty_commit: true,
-      parent: 'All-Project',
-      permissions_only: false,
-    };
-
-    const saveStub = stubRestApi('createRepo').returns(Promise.resolve({}));
-
-    assert.isFalse(element.hasNewRepoName);
-
-    element._repoConfig = {
-      name: 'test-repo',
-      create_empty_commit: true,
-      parent: 'All-Project',
-      permissions_only: false,
-    };
-
-    element._repoOwner = 'test';
-    element._repoOwnerId = 'testId';
-    element._defaultBranch = 'main';
-
-    element.$.repoNameInput.bindValue = configInputObj.name;
-    element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
-    element.$.initialCommit.bindValue =
-        configInputObj.create_empty_commit;
-    element.$.parentRepo.bindValue =
-        configInputObj.permissions_only;
-
-    assert.isTrue(element.hasNewRepoName);
-
-    assert.deepEqual(element._repoConfig, configInputObj);
-
-    await element.handleCreateRepo();
-    assert.isTrue(saveStub.lastCall.calledWithExactly(
-        {
-          ...configInputObj,
-          owners: ['testId'],
-          branches: ['main'],
-        }
-    ));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
new file mode 100644
index 0000000..d3e2171
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
@@ -0,0 +1,106 @@
+/**
+ * @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-create-repo-dialog';
+import {GrCreateRepoDialog} from './gr-create-repo-dialog';
+import {
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {BranchName, GroupId, RepoName} from '../../../types/common';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+
+const basicFixture = fixtureFromElement('gr-create-repo-dialog');
+
+suite('gr-create-repo-dialog tests', () => {
+  let element: GrCreateRepoDialog;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  test('default values are populated', () => {
+    assert.isTrue(
+      queryAndAssert<GrSelect>(element, '#initialCommit').bindValue
+    );
+    assert.isFalse(queryAndAssert<GrSelect>(element, '#parentRepo').bindValue);
+  });
+
+  test('repo created', async () => {
+    const configInputObj = {
+      name: 'test-repo-new' as RepoName,
+      create_empty_commit: true,
+      parent: 'All-Project' as RepoName,
+      permissions_only: false,
+    };
+
+    const saveStub = stubRestApi('createRepo').returns(
+      Promise.resolve(new Response())
+    );
+
+    const promise = mockPromise();
+    element.addEventListener('new-repo-name', () => {
+      promise.resolve();
+    });
+
+    element.repoConfig = {
+      name: 'test-repo' as RepoName,
+      create_empty_commit: true,
+      parent: 'All-Project' as RepoName,
+      permissions_only: false,
+    };
+
+    element.repoOwner = 'test';
+    element.repoOwnerId = 'testId' as GroupId;
+    element.defaultBranch = 'main' as BranchName;
+
+    const repoNameInput = queryAndAssert<HTMLInputElement>(
+      element,
+      '#repoNameInput'
+    );
+    repoNameInput.value = configInputObj.name;
+    repoNameInput.dispatchEvent(
+      new Event('input', {bubbles: true, composed: true})
+    );
+    queryAndAssert<GrAutocomplete>(element, '#rightsInheritFromInput').value =
+      configInputObj.parent;
+    queryAndAssert<GrSelect>(element, '#initialCommit').bindValue =
+      configInputObj.create_empty_commit;
+    queryAndAssert<GrSelect>(element, '#parentRepo').bindValue =
+      configInputObj.permissions_only;
+
+    assert.deepEqual(element.repoConfig, configInputObj);
+
+    await element.handleCreateRepo();
+    assert.isTrue(
+      saveStub.lastCall.calledWithExactly({
+        ...configInputObj,
+        owners: ['testId' as GroupId],
+        branches: ['main' as BranchName],
+      })
+    );
+
+    await promise;
+
+    assert.equal(element.repoConfig.name, configInputObj.name);
+    assert.equal(element.nameChanged, true);
+  });
+});
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 6605350..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,13 +15,8 @@
  * limitations under the License.
  */
 
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
-import '../../shared/gr-account-link/gr-account-link';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-group-audit-log_html';
+import '../../shared/gr-account-label/gr-account-label';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {
   GroupInfo,
   AccountInfo,
@@ -31,58 +26,139 @@
   isGroupAuditGroupEventInfo,
 } from '../../../types/common';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-group-audit-log': GrGroupAuditLog;
+  }
+}
 
 @customElement('gr-group-audit-log')
-export class GrGroupAuditLog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrGroupAuditLog extends LitElement {
   @property({type: String})
   groupId?: EncodedGroupId;
 
-  @property({type: Array})
-  _auditLog?: GroupAuditEventInfo[];
+  @state() private auditLog?: GroupAuditEventInfo[];
 
-  @property({type: Boolean})
-  _loading = true;
+  @state() private loading = true;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
     fireTitleChange(this, 'Audit Log');
   }
 
-  override ready() {
-    super.ready();
-    this._getAuditLogs();
+  static override get styles() {
+    return [
+      sharedStyles,
+      tableStyles,
+      css`
+        /* GenericList style centers the last column, but we don't want that here. */
+        .genericList tr th:last-of-type,
+        .genericList tr td:last-of-type {
+          text-align: left;
+        }
+      `,
+    ];
   }
 
-  _getAuditLogs() {
-    if (!this.groupId) {
-      return '';
+  override render() {
+    return html`
+      <table id="list" class="genericList">
+        <tbody>
+          <tr class="headerRow">
+            <th class="date topHeader">Date</th>
+            <th class="type topHeader">Type</th>
+            <th class="member topHeader">Member</th>
+            <th class="by-user topHeader">By User</th>
+          </tr>
+          ${this.renderLoading()}
+        </tbody>
+        ${this.renderAuditLogTable()}
+      </table>
+    `;
+  }
+
+  private renderLoading() {
+    if (!this.loading) return;
+
+    return html`
+      <tr id="loading" class="loadingMsg loading">
+        <td>Loading...</td>
+      </tr>
+    `;
+  }
+
+  private renderAuditLogTable() {
+    if (this.loading) return;
+
+    return html`
+      <tbody>
+        ${this.auditLog?.map(audit => this.renderAuditLog(audit))}
+      </tbody>
+    `;
+  }
+
+  private renderAuditLog(audit: GroupAuditEventInfo) {
+    return html`
+      <tr class="table">
+        <td class="date">
+          <gr-date-formatter withTooltip .dateStr=${audit.date}>
+          </gr-date-formatter>
+        </td>
+        <td class="type">${this.itemType(audit.type)}</td>
+        <td class="member">
+          ${this.isGroupEvent(audit)
+            ? html`<a href=${this.computeGroupUrl(audit.member)}
+                >${this.getNameForGroup(audit.member)}</a
+              >`
+            : html`<gr-account-label
+                  .account=${audit.member}
+                  clickable
+                ></gr-account-label
+                >${this.getIdForUser(audit.member)}`}
+        </td>
+        <td class="by-user">
+          <gr-account-label clickable .account=${audit.user}></gr-account-label>
+          ${this.getIdForUser(audit.user)}
+        </td>
+      </tr>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('groupId')) {
+      this.getAuditLogs();
     }
+  }
+
+  // private but used in test
+  getAuditLogs() {
+    if (!this.groupId) return;
 
     const errFn: ErrorCallback = response => {
       firePageError(response);
     };
 
+    this.loading = true;
     return this.restApiService
       .getGroupAuditLog(this.groupId, errFn)
       .then(auditLog => {
-        if (!auditLog) {
-          this._auditLog = [];
-          return;
-        }
-        this._auditLog = auditLog;
-        this._loading = false;
+        this.auditLog = auditLog ?? [];
+      })
+      .finally(() => {
+        this.loading = false;
       });
   }
 
-  itemType(type: string) {
+  private itemType(type: string) {
     let item;
     switch (type) {
       case 'ADD_GROUP':
@@ -99,11 +175,12 @@
     return item;
   }
 
-  _isGroupEvent(event: GroupAuditEventInfo): event is GroupAuditGroupEventInfo {
+  // private but used in test
+  isGroupEvent(event: GroupAuditEventInfo): event is GroupAuditGroupEventInfo {
     return isGroupAuditGroupEventInfo(event);
   }
 
-  _computeGroupUrl(group: GroupInfo) {
+  private computeGroupUrl(group: GroupInfo) {
     if (group && group.url && group.id) {
       return GerritNav.getUrlForGroup(group.id);
     }
@@ -111,11 +188,13 @@
     return '';
   }
 
-  _getIdForUser(account: AccountInfo) {
+  // private but used in test
+  getIdForUser(account: AccountInfo) {
     return account._account_id ? ` (${account._account_id})` : '';
   }
 
-  _getNameForGroup(group: GroupInfo) {
+  // private but used in test
+  getNameForGroup(group: GroupInfo) {
     if (group && group.name) {
       return group.name;
     } else if (group && group.id) {
@@ -125,14 +204,4 @@
 
     return '';
   }
-
-  computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-group-audit-log': GrGroupAuditLog;
-  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
deleted file mode 100644
index 828aa55..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
+++ /dev/null
@@ -1,69 +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-table-styles">
-    /* GenericList style centers the last column, but we don't want that here. */
-    .genericList tr th:last-of-type,
-    .genericList tr td:last-of-type {
-      text-align: left;
-    }
-  </style>
-  <table id="list" class="genericList">
-    <tbody>
-      <tr class="headerRow">
-        <th class="date topHeader">Date</th>
-        <th class="type topHeader">Type</th>
-        <th class="member topHeader">Member</th>
-        <th class="by-user topHeader">By User</th>
-      </tr>
-      <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-        <td>Loading...</td>
-      </tr>
-    </tbody>
-    <tbody class$="[[computeLoadingClass(_loading)]]">
-      <template is="dom-repeat" items="[[_auditLog]]">
-        <tr class="table">
-          <td class="date">
-            <gr-date-formatter withTooltip date-str="[[item.date]]">
-            </gr-date-formatter>
-          </td>
-          <td class="type">[[itemType(item.type)]]</td>
-          <td class="member">
-            <template is="dom-if" if="[[_isGroupEvent(item)]]">
-              <a href$="[[_computeGroupUrl(item.member)]]">
-                [[_getNameForGroup(item.member)]]
-              </a>
-            </template>
-            <template is="dom-if" if="[[!_isGroupEvent(item)]]">
-              <gr-account-link account="[[item.member]]"></gr-account-link>
-              [[_getIdForUser(item.member)]]
-            </template>
-          </td>
-          <td class="by-user">
-            <gr-account-link account="[[item.user]]"></gr-account-link>
-            [[_getIdForUser(item.user)]]
-          </td>
-        </tr>
-      </template>
-    </tbody>
-  </table>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
index dc09390..79b635a 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
@@ -15,67 +15,68 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-group-audit-log.js';
+import '../../../test/common-test-setup-karma';
+import './gr-group-audit-log';
 import {
   addListenerForTest,
   mockPromise,
   stubRestApi,
-} from '../../../test/test-utils.js';
-import {GrGroupAuditLog} from './gr-group-audit-log.js';
+} from '../../../test/test-utils';
+import {GrGroupAuditLog} from './gr-group-audit-log';
 import {
   EncodedGroupId,
   GroupAuditEventType,
   GroupInfo,
   GroupName,
-} from '../../../types/common.js';
+} from '../../../types/common';
 import {
   createAccountWithId,
   createGroupAuditEventInfo,
   createGroupInfo,
-} from '../../../test/test-data-generators.js';
-import {PageErrorEvent} from '../../../types/events.js';
+} from '../../../test/test-data-generators';
+import {PageErrorEvent} from '../../../types/events';
 
 const basicFixture = fixtureFromElement('gr-group-audit-log');
 
 suite('gr-group-audit-log tests', () => {
   let element: GrGroupAuditLog;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
   suite('members', () => {
-    test('test _getNameForGroup', () => {
+    test('test getNameForGroup', () => {
       let member: GroupInfo = {
         ...createGroupInfo(),
         name: 'test-name' as GroupName,
       };
-      assert.equal(element._getNameForGroup(member), 'test-name');
+      assert.equal(element.getNameForGroup(member), 'test-name');
 
       member = createGroupInfo('test-id');
-      assert.equal(element._getNameForGroup(member), 'test-id');
+      assert.equal(element.getNameForGroup(member), 'test-id');
     });
 
-    test('test _isGroupEvent', () => {
+    test('test isGroupEvent', () => {
       assert.isTrue(
-        element._isGroupEvent(
+        element.isGroupEvent(
           createGroupAuditEventInfo(GroupAuditEventType.ADD_GROUP)
         )
       );
       assert.isTrue(
-        element._isGroupEvent(
+        element.isGroupEvent(
           createGroupAuditEventInfo(GroupAuditEventType.REMOVE_GROUP)
         )
       );
 
       assert.isFalse(
-        element._isGroupEvent(
+        element.isGroupEvent(
           createGroupAuditEventInfo(GroupAuditEventType.ADD_USER)
         )
       );
       assert.isFalse(
-        element._isGroupEvent(
+        element.isGroupEvent(
           createGroupAuditEventInfo(GroupAuditEventType.REMOVE_USER)
         )
       );
@@ -83,12 +84,12 @@
   });
 
   suite('users', () => {
-    test('test _getIdForUser', () => {
+    test('test getIdForUser', () => {
       const user = {
         ...createAccountWithId(12),
         username: 'test-user',
       };
-      assert.equal(element._getIdForUser(user), ' (12)');
+      assert.equal(element.getIdForUser(user), ' (12)');
     });
 
     test('test _account_id not present', () => {
@@ -97,14 +98,14 @@
           username: 'test-user',
         },
       };
-      assert.equal(element._getIdForUser(account.user), '');
+      assert.equal(element.getIdForUser(account.user), '');
     });
   });
 
   suite('404', () => {
     test('fires page-error', async () => {
       element.groupId = '1' as EncodedGroupId;
-      await flush();
+      await element.updateComplete;
 
       const response = {...new Response(), status: 404};
       stubRestApi('getGroupAuditLog').callsFake((_group, errFn) => {
@@ -118,7 +119,7 @@
         pageErrorCalled.resolve();
       });
 
-      element._getAuditLogs();
+      element.getAuditLogs();
       await pageErrorCalled;
     });
   });
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 c38f8be..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,20 +15,12 @@
  * limitations under the License.
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
-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';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-group-members_html';
 import {getBaseUrl} from '../../../utils/url-util';
-import {customElement, property} from '@polymer/decorators';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {
   GroupId,
@@ -41,15 +33,23 @@
   AutocompleteQuery,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {PolymerDomRepeatEvent} from '../../../types/types';
 import {
   fireAlert,
   firePageError,
   fireTitleChange,
 } from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {assertNever} from '../../../utils/common-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {ifDefined} from 'lit/directives/if-defined';
 
 const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT =
@@ -62,84 +62,263 @@
   INCLUDED_GROUP = 'includedGroup',
 }
 
-export interface GrGroupMembers {
-  $: {
-    overlay: GrOverlay;
-  };
-}
-@customElement('gr-group-members')
-export class GrGroupMembers extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-group-members': GrGroupMembers;
   }
+}
 
-  @property({type: Number})
+@customElement('gr-group-members')
+export class GrGroupMembers extends LitElement {
+  @query('#overlay') protected overlay!: GrOverlay;
+
+  @property({type: String})
   groupId?: GroupId;
 
-  @property({type: Number})
-  _groupMemberSearchId?: number;
+  @state() protected groupMemberSearchId?: number;
 
-  @property({type: String})
-  _groupMemberSearchName?: string;
+  @state() protected groupMemberSearchName?: string;
 
-  @property({type: String})
-  _includedGroupSearchId?: string;
+  @state() protected includedGroupSearchId?: string;
 
-  @property({type: String})
-  _includedGroupSearchName?: string;
+  @state() protected includedGroupSearchName?: string;
 
-  @property({type: Boolean})
-  _loading = true;
+  @state() protected loading = true;
 
-  @property({type: String})
-  _groupName?: GroupName;
+  /* private but used in test */
+  @state() groupName?: GroupName;
 
-  @property({type: Object})
-  _groupMembers?: AccountInfo[];
+  @state() protected groupMembers?: AccountInfo[];
 
-  @property({type: Object})
-  _includedGroups?: GroupInfo[];
+  /* private but used in test */
+  @state() includedGroups?: GroupInfo[];
 
-  @property({type: String})
-  _itemName?: string;
+  /* private but used in test */
+  @state() itemName?: string;
 
-  @property({type: String})
-  _itemType?: ItemType;
+  @state() protected itemType?: ItemType;
 
-  @property({type: Object})
-  _queryMembers: AutocompleteQuery;
+  @state() protected queryMembers?: AutocompleteQuery;
 
-  @property({type: Object})
-  _queryIncludedGroup: AutocompleteQuery;
+  @state() protected queryIncludedGroup?: AutocompleteQuery;
 
-  @property({type: Boolean})
-  _groupOwner = false;
+  /* private but used in test */
+  @state() groupOwner = false;
 
-  @property({type: Boolean})
-  _isAdmin = false;
+  @state() protected isAdmin = false;
 
-  _itemId?: AccountId | GroupId;
+  /* private but used in test */
+  @state() itemId?: AccountId | GroupId;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this._queryMembers = input => this._getAccountSuggestions(input);
-    this._queryIncludedGroup = input => this._getGroupSuggestions(input);
+    this.queryMembers = input => this.getAccountSuggestions(input);
+    this.queryIncludedGroup = input => this.getGroupSuggestions(input);
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadGroupDetails();
+    this.loadGroupDetails();
 
     fireTitleChange(this, 'Members');
   }
 
-  _loadGroupDetails() {
-    if (!this.groupId) {
-      return;
+  static override get styles() {
+    return [
+      fontStyles,
+      formStyles,
+      sharedStyles,
+      subpageStyles,
+      tableStyles,
+      css`
+        .input {
+          width: 15em;
+        }
+        gr-autocomplete {
+          width: 20em;
+        }
+        a {
+          color: var(--primary-text-color);
+          text-decoration: none;
+        }
+        a:hover {
+          text-decoration: underline;
+        }
+        th {
+          border-bottom: 1px solid var(--border-color);
+          font-weight: var(--font-weight-bold);
+          text-align: left;
+        }
+        .canModify #groupMemberSearchInput,
+        .canModify #saveGroupMember,
+        .canModify .deleteHeader,
+        .canModify .deleteColumn,
+        .canModify #includedGroupSearchInput,
+        .canModify #saveIncludedGroups,
+        .canModify .deleteIncludedHeader,
+        .canModify #saveIncludedGroups {
+          display: none;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div
+        class="main gr-form-styles ${this.isAdmin || this.groupOwner
+          ? ''
+          : 'canModify'}"
+      >
+        <div id="loading" class=${this.loading ? 'loading' : ''}>
+          Loading...
+        </div>
+        <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
+          <h1 id="Title" class="heading-1">${this.groupName}</h1>
+          <div id="form">
+            <h3 id="members" class="heading-3">Members</h3>
+            <fieldset>
+              <span class="value">
+                <gr-autocomplete
+                  id="groupMemberSearchInput"
+                  .text=${this.groupMemberSearchName}
+                  .value=${this.groupMemberSearchId}
+                  .query=${this.queryMembers}
+                  placeholder="Name Or Email"
+                  @text-changed=${this.handleGroupMemberTextChanged}
+                  @value-changed=${this.handleGroupMemberValueChanged}
+                >
+                </gr-autocomplete>
+              </span>
+              <gr-button
+                id="saveGroupMember"
+                ?disabled=${!this.groupMemberSearchId}
+                @click=${this.handleSavingGroupMember}
+              >
+                Add
+              </gr-button>
+              <table id="groupMembers">
+                <tbody>
+                  <tr class="headerRow">
+                    <th class="nameHeader">Name</th>
+                    <th class="emailAddressHeader">Email Address</th>
+                    <th class="deleteHeader">Delete Member</th>
+                  </tr>
+                </tbody>
+                <tbody>
+                  ${this.groupMembers?.map((member, index) =>
+                    this.renderGroupMember(member, index)
+                  )}
+                </tbody>
+              </table>
+            </fieldset>
+            <h3 id="includedGroups" class="heading-3">Included Groups</h3>
+            <fieldset>
+              <span class="value">
+                <gr-autocomplete
+                  id="includedGroupSearchInput"
+                  .text=${this.includedGroupSearchName}
+                  .value=${this.includedGroupSearchId}
+                  .query=${this.queryIncludedGroup}
+                  placeholder="Group Name"
+                  @text-changed=${this.handleIncludedGroupTextChanged}
+                  @value-changed=${this.handleIncludedGroupValueChanged}
+                >
+                </gr-autocomplete>
+              </span>
+              <gr-button
+                id="saveIncludedGroups"
+                ?disabled=${!this.includedGroupSearchId}
+                @click=${this.handleSavingIncludedGroups}
+              >
+                Add
+              </gr-button>
+              <table id="includedGroups">
+                <tbody>
+                  <tr class="headerRow">
+                    <th class="groupNameHeader">Group Name</th>
+                    <th class="descriptionHeader">Description</th>
+                    <th class="deleteIncludedHeader">Delete Group</th>
+                  </tr>
+                </tbody>
+                <tbody>
+                  ${this.includedGroups?.map((group, index) =>
+                    this.renderIncludedGroup(group, index)
+                  )}
+                </tbody>
+              </table>
+            </fieldset>
+          </div>
+        </div>
+      </div>
+      <gr-overlay id="overlay" with-backdrop>
+        <gr-confirm-delete-item-dialog
+          class="confirmDialog"
+          .item=${this.itemName}
+          .itemTypeName=${this.computeItemTypeName(this.itemType)}
+          @confirm=${this.handleDeleteConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+        ></gr-confirm-delete-item-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private renderGroupMember(member: AccountInfo, index: number) {
+    return html`
+      <tr>
+        <td class="nameColumn">
+          <gr-account-label .account=${member} clickable></gr-account-label>
+        </td>
+        <td>${member.email}</td>
+        <td class="deleteColumn">
+          <gr-button
+            class="deleteMembersButton"
+            data-index=${index}
+            @click=${this.handleDeleteMember}
+          >
+            Delete
+          </gr-button>
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderIncludedGroup(group: GroupInfo, index: number) {
+    return html`
+      <tr>
+        <td class="nameColumn">${this.renderIncludedGroupHref(group)}</td>
+        <td>${group.description}</td>
+        <td class="deleteColumn">
+          <gr-button
+            class="deleteIncludedGroupButton"
+            data-index=${index}
+            @click=${this.handleDeleteIncludedGroup}
+          >
+            Delete
+          </gr-button>
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderIncludedGroupHref(group: GroupInfo) {
+    if (group.url) {
+      return html`
+        <a href=${ifDefined(this.computeGroupUrl(group.url))} rel="noopener">
+          ${group.name}
+        </a>
+      `;
     }
 
+    return group.name;
+  }
+
+  /* private but used in test */
+  loadGroupDetails() {
+    if (!this.groupId) return;
+
     const promises: Promise<void>[] = [];
 
     const errFn: ErrorCallback = response => {
@@ -153,52 +332,43 @@
           return Promise.resolve();
         }
 
-        this._groupName = config.name;
+        this.groupName = config.name;
 
         promises.push(
           this.restApiService.getIsAdmin().then(isAdmin => {
-            this._isAdmin = !!isAdmin;
+            this.isAdmin = !!isAdmin;
           })
         );
 
         promises.push(
-          this.restApiService.getIsGroupOwner(this._groupName).then(isOwner => {
-            this._groupOwner = !!isOwner;
+          this.restApiService.getIsGroupOwner(this.groupName).then(isOwner => {
+            this.groupOwner = !!isOwner;
           })
         );
 
         promises.push(
-          this.restApiService.getGroupMembers(this._groupName).then(members => {
-            this._groupMembers = members;
+          this.restApiService.getGroupMembers(this.groupName).then(members => {
+            this.groupMembers = members;
           })
         );
 
         promises.push(
           this.restApiService
-            .getIncludedGroup(this._groupName)
+            .getIncludedGroup(this.groupName)
             .then(includedGroup => {
-              this._includedGroups = includedGroup;
+              this.includedGroups = includedGroup;
             })
         );
 
         return Promise.all(promises).then(() => {
-          this._loading = false;
+          this.loading = false;
         });
       });
   }
 
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _computeGroupUrl(url: string) {
-    if (!url) {
-      return;
-    }
+  /* private but used in test */
+  computeGroupUrl(url?: string) {
+    if (!url) return;
 
     const r = new RegExp(URL_REGEX, 'i');
     if (r.test(url)) {
@@ -212,53 +382,55 @@
     return getBaseUrl() + url;
   }
 
-  _handleSavingGroupMember() {
-    if (!this._groupName) {
+  /* private but used in test */
+  handleSavingGroupMember() {
+    if (!this.groupName) {
       return Promise.reject(new Error('group name undefined'));
     }
     return this.restApiService
-      .saveGroupMember(this._groupName, this._groupMemberSearchId as AccountId)
+      .saveGroupMember(this.groupName, this.groupMemberSearchId as AccountId)
       .then(config => {
-        if (!config || !this._groupName) {
+        if (!config || !this.groupName) {
           return;
         }
-        this.restApiService.getGroupMembers(this._groupName).then(members => {
-          this._groupMembers = members;
+        this.restApiService.getGroupMembers(this.groupName).then(members => {
+          this.groupMembers = members;
         });
-        this._groupMemberSearchName = '';
-        this._groupMemberSearchId = undefined;
+        this.groupMemberSearchName = '';
+        this.groupMemberSearchId = undefined;
       });
   }
 
-  _handleDeleteConfirm() {
-    if (!this._groupName) {
+  /* private but used in test */
+  handleDeleteConfirm() {
+    if (!this.groupName) {
       return Promise.reject(new Error('group name undefined'));
     }
-    this.$.overlay.close();
-    if (this._itemType === ItemType.MEMBER) {
+    this.overlay.close();
+    if (this.itemType === ItemType.MEMBER) {
       return this.restApiService
-        .deleteGroupMember(this._groupName, this._itemId! as AccountId)
+        .deleteGroupMember(this.groupName, this.itemId! as AccountId)
         .then(itemDeleted => {
-          if (itemDeleted.status === 204 && this._groupName) {
+          if (itemDeleted.status === 204 && this.groupName) {
             this.restApiService
-              .getGroupMembers(this._groupName)
+              .getGroupMembers(this.groupName)
               .then(members => {
-                this._groupMembers = members;
+                this.groupMembers = members;
               });
           }
         });
-    } else if (this._itemType === ItemType.INCLUDED_GROUP) {
+    } else if (this.itemType === ItemType.INCLUDED_GROUP) {
       return this.restApiService
-        .deleteIncludedGroup(this._groupName, this._itemId! as GroupId)
+        .deleteIncludedGroup(this.groupName, this.itemId! as GroupId)
         .then(itemDeleted => {
           if (
             (itemDeleted.status === 204 || itemDeleted.status === 205) &&
-            this._groupName
+            this.groupName
           ) {
             this.restApiService
-              .getIncludedGroup(this._groupName)
+              .getIncludedGroup(this.groupName)
               .then(includedGroup => {
-                this._includedGroups = includedGroup;
+                this.includedGroups = includedGroup;
               });
           }
         });
@@ -266,7 +438,8 @@
     return Promise.reject(new Error('Unrecognized item type'));
   }
 
-  _computeItemTypeName(itemType?: ItemType): string {
+  /* private but used in test */
+  computeItemTypeName(itemType?: ItemType): string {
     if (itemType === undefined) return '';
     switch (itemType) {
       case ItemType.INCLUDED_GROUP:
@@ -278,35 +451,36 @@
     }
   }
 
-  _handleConfirmDialogCancel() {
-    this.$.overlay.close();
+  private handleConfirmDialogCancel() {
+    this.overlay.close();
   }
 
-  _handleDeleteMember(e: PolymerDomRepeatEvent<AccountInfo>) {
-    const id = e.model.get('item._account_id');
-    const name = e.model.get('item.name');
-    const username = e.model.get('item.username');
-    const email = e.model.get('item.email');
-    const item = username || name || email || id?.toString();
-    if (!item) {
-      return;
-    }
-    this._itemName = item;
-    this._itemId = id;
-    this._itemType = ItemType.MEMBER;
-    this.$.overlay.open();
+  private handleDeleteMember(e: Event) {
+    if (!this.groupMembers) return;
+
+    const el = e.target as GrButton;
+    const index = Number(el.getAttribute('data-index')!);
+    const keys = this.groupMembers[index];
+    const item =
+      keys.username || keys.name || keys.email || keys._account_id?.toString();
+    if (!item) return;
+    this.itemName = item;
+    this.itemId = keys._account_id;
+    this.itemType = ItemType.MEMBER;
+    this.overlay.open();
   }
 
-  _handleSavingIncludedGroups() {
-    if (!this._groupName || !this._includedGroupSearchId) {
+  /* private but used in test */
+  handleSavingIncludedGroups() {
+    if (!this.groupName || !this.includedGroupSearchId) {
       return Promise.reject(
         new Error('group name or includedGroupSearchId undefined')
       );
     }
     return this.restApiService
       .saveIncludedGroup(
-        this._groupName,
-        this._includedGroupSearchId.replace(/\+/g, ' ') as GroupId,
+        this.groupName,
+        this.includedGroupSearchId.replace(/\+/g, ' ') as GroupId,
         (errResponse, err) => {
           if (errResponse) {
             if (errResponse.status === 404) {
@@ -319,36 +493,38 @@
         }
       )
       .then(config => {
-        if (!config || !this._groupName) {
+        if (!config || !this.groupName) {
           return;
         }
         this.restApiService
-          .getIncludedGroup(this._groupName)
+          .getIncludedGroup(this.groupName)
           .then(includedGroup => {
-            this._includedGroups = includedGroup;
+            this.includedGroups = includedGroup;
           });
-        this._includedGroupSearchName = '';
-        this._includedGroupSearchId = '';
+        this.includedGroupSearchName = '';
+        this.includedGroupSearchId = '';
       });
   }
 
-  _handleDeleteIncludedGroup(e: PolymerDomRepeatEvent<GroupInfo>) {
-    const id = decodeURIComponent(`${e.model.get('item.id')}`).replace(
-      /\+/g,
-      ' '
-    ) as GroupId;
-    const name = e.model.get('item.name');
+  private handleDeleteIncludedGroup(e: Event) {
+    if (!this.includedGroups) return;
+
+    const el = e.target as GrButton;
+    const index = Number(el.getAttribute('data-index')!);
+    const keys = this.includedGroups[index];
+
+    const id = decodeURIComponent(keys.id).replace(/\+/g, ' ') as GroupId;
+    const name = keys.name;
     const item = name || id;
-    if (!item) {
-      return;
-    }
-    this._itemName = item;
-    this._itemId = id;
-    this._itemType = ItemType.INCLUDED_GROUP;
-    this.$.overlay.open();
+    if (!item) return;
+    this.itemName = item;
+    this.itemId = id;
+    this.itemType = ItemType.INCLUDED_GROUP;
+    this.overlay.open();
   }
 
-  _getAccountSuggestions(input: string) {
+  /* private but used in test */
+  getAccountSuggestions(input: string) {
     if (input.length === 0) {
       return Promise.resolve([]);
     }
@@ -373,7 +549,8 @@
       });
   }
 
-  _getGroupSuggestions(input: string) {
+  /* private but used in test */
+  getGroupSuggestions(input: string) {
     return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups: AutocompleteSuggestion[] = [];
       for (const [name, group] of Object.entries(response ?? {})) {
@@ -383,13 +560,23 @@
     });
   }
 
-  _computeHideItemClass(owner: boolean, admin: boolean) {
-    return admin || owner ? '' : 'canModify';
+  private handleGroupMemberTextChanged(e: CustomEvent) {
+    if (this.loading) return;
+    this.groupMemberSearchName = e.detail.value;
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-group-members': GrGroupMembers;
+  private handleGroupMemberValueChanged(e: CustomEvent) {
+    if (this.loading) return;
+    this.groupMemberSearchId = e.detail.value;
+  }
+
+  private handleIncludedGroupTextChanged(e: CustomEvent) {
+    if (this.loading) return;
+    this.includedGroupSearchName = e.detail.value;
+  }
+
+  private handleIncludedGroupValueChanged(e: CustomEvent) {
+    if (this.loading) return;
+    this.includedGroupSearchId = e.detail.value;
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
deleted file mode 100644
index 518abac..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
+++ /dev/null
@@ -1,184 +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-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    .input {
-      width: 15em;
-    }
-    gr-autocomplete {
-      width: 20em;
-    }
-    a {
-      color: var(--primary-text-color);
-      text-decoration: none;
-    }
-    a:hover {
-      text-decoration: underline;
-    }
-    th {
-      border-bottom: 1px solid var(--border-color);
-      font-weight: var(--font-weight-bold);
-      text-align: left;
-    }
-    .canModify #groupMemberSearchInput,
-    .canModify #saveGroupMember,
-    .canModify .deleteHeader,
-    .canModify .deleteColumn,
-    .canModify #includedGroupSearchInput,
-    .canModify #saveIncludedGroups,
-    .canModify .deleteIncludedHeader,
-    .canModify #saveIncludedGroups {
-      display: none;
-    }
-  </style>
-  <div
-    class$="main gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]"
-  >
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h1 id="Title" class="heading-1">[[_groupName]]</h1>
-      <div id="form">
-        <h3 id="members" class="heading-3">Members</h3>
-        <fieldset>
-          <span class="value">
-            <gr-autocomplete
-              id="groupMemberSearchInput"
-              text="{{_groupMemberSearchName}}"
-              value="{{_groupMemberSearchId}}"
-              query="[[_queryMembers]]"
-              placeholder="Name Or Email"
-            >
-            </gr-autocomplete>
-          </span>
-          <gr-button
-            id="saveGroupMember"
-            on-click="_handleSavingGroupMember"
-            disabled="[[!_groupMemberSearchId]]"
-          >
-            Add
-          </gr-button>
-          <table id="groupMembers">
-            <tbody>
-              <tr class="headerRow">
-                <th class="nameHeader">Name</th>
-                <th class="emailAddressHeader">Email Address</th>
-                <th class="deleteHeader">Delete Member</th>
-              </tr>
-            </tbody>
-            <tbody>
-              <template is="dom-repeat" items="[[_groupMembers]]">
-                <tr>
-                  <td class="nameColumn">
-                    <gr-account-link account="[[item]]"></gr-account-link>
-                  </td>
-                  <td>[[item.email]]</td>
-                  <td class="deleteColumn">
-                    <gr-button
-                      class="deleteMembersButton"
-                      on-click="_handleDeleteMember"
-                    >
-                      Delete
-                    </gr-button>
-                  </td>
-                </tr>
-              </template>
-            </tbody>
-          </table>
-        </fieldset>
-        <h3 id="includedGroups" class="heading-3">Included Groups</h3>
-        <fieldset>
-          <span class="value">
-            <gr-autocomplete
-              id="includedGroupSearchInput"
-              text="{{_includedGroupSearchName}}"
-              value="{{_includedGroupSearchId}}"
-              query="[[_queryIncludedGroup]]"
-              placeholder="Group Name"
-            >
-            </gr-autocomplete>
-          </span>
-          <gr-button
-            id="saveIncludedGroups"
-            on-click="_handleSavingIncludedGroups"
-            disabled="[[!_includedGroupSearchId]]"
-          >
-            Add
-          </gr-button>
-          <table id="includedGroups">
-            <tbody>
-              <tr class="headerRow">
-                <th class="groupNameHeader">Group Name</th>
-                <th class="descriptionHeader">Description</th>
-                <th class="deleteIncludedHeader">Delete Group</th>
-              </tr>
-            </tbody>
-            <tbody>
-              <template is="dom-repeat" items="[[_includedGroups]]">
-                <tr>
-                  <td class="nameColumn">
-                    <template is="dom-if" if="[[item.url]]">
-                      <a href$="[[_computeGroupUrl(item.url)]]" rel="noopener">
-                        [[item.name]]
-                      </a>
-                    </template>
-                    <template is="dom-if" if="[[!item.url]]">
-                      [[item.name]]
-                    </template>
-                  </td>
-                  <td>[[item.description]]</td>
-                  <td class="deleteColumn">
-                    <gr-button
-                      class="deleteIncludedGroupButton"
-                      on-click="_handleDeleteIncludedGroup"
-                    >
-                      Delete
-                    </gr-button>
-                  </td>
-                </tr>
-              </template>
-            </tbody>
-          </table>
-        </fieldset>
-      </div>
-    </div>
-  </div>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-confirm-delete-item-dialog
-      class="confirmDialog"
-      on-confirm="_handleDeleteConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      item="[[_itemName]]"
-      item-type-name="[[_computeItemTypeName(_itemType)]]"
-    ></gr-confirm-delete-item-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
deleted file mode 100644
index b91b04b..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
+++ /dev/null
@@ -1,363 +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-group-members.js';
-import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {addListenerForTest, mockPromise, stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
-import {ItemType} from './gr-group-members.js';
-
-const basicFixture = fixtureFromElement('gr-group-members');
-
-suite('gr-group-members tests', () => {
-  let element;
-
-  let groups;
-  let groupMembers;
-  let includedGroups;
-  let groupStub;
-
-  setup(() => {
-    groups = {
-      name: 'Administrators',
-      owner: 'Administrators',
-      group_id: 1,
-    };
-
-    groupMembers = [
-      {
-        _account_id: 1000097,
-        name: 'Jane Roe',
-        email: 'jane.roe@example.com',
-        username: 'jane',
-      },
-      {
-        _account_id: 1000096,
-        name: 'Test User',
-        email: 'john.doe@example.com',
-      },
-      {
-        _account_id: 1000095,
-        name: 'Gerrit',
-      },
-      {
-        _account_id: 1000098,
-      },
-    ];
-
-    includedGroups = [{
-      url: 'https://group/url',
-      options: {},
-      id: 'testId',
-      name: 'testName',
-    },
-    {
-      url: '/group/url',
-      options: {},
-      id: 'testId2',
-      name: 'testName2',
-    },
-    {
-      url: '#/group/url',
-      options: {},
-      id: 'testId3',
-      name: 'testName3',
-    },
-    ];
-
-    stubRestApi('getSuggestedAccounts').callsFake(input => {
-      if (input.startsWith('test')) {
-        return Promise.resolve([
-          {
-            _account_id: 1000096,
-            name: 'test-account',
-            email: 'test.account@example.com',
-            username: 'test123',
-          },
-          {
-            _account_id: 1001439,
-            name: 'test-admin',
-            email: 'test.admin@example.com',
-            username: 'test_admin',
-          },
-          {
-            _account_id: 1001439,
-            name: 'test-git',
-            username: 'test_git',
-          },
-        ]);
-      } else {
-        return Promise.resolve([]);
-      }
-    });
-    stubRestApi('getSuggestedGroups').callsFake(input => {
-      if (input.startsWith('test')) {
-        return Promise.resolve({
-          'test-admin': {
-            id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
-          },
-          'test/Administrator (admin)': {
-            id: 'test%3Aadmin',
-          },
-        });
-      } else {
-        return Promise.resolve({});
-      }
-    });
-    stubRestApi('getGroupMembers').returns(Promise.resolve(groupMembers));
-    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
-    stubRestApi('getIncludedGroup').returns(Promise.resolve(includedGroups));
-    element = basicFixture.instantiate();
-    stubBaseUrl('https://test/site');
-    element.groupId = 1;
-    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(groups));
-    return element._loadGroupDetails();
-  });
-
-  test('_includedGroups', () => {
-    assert.equal(element._includedGroups.length, 3);
-    assert.equal(dom(element.root)
-        .querySelectorAll('.nameColumn a')[0].href, includedGroups[0].url);
-    assert.equal(dom(element.root)
-        .querySelectorAll('.nameColumn a')[1].href,
-    'https://test/site/group/url');
-    assert.equal(dom(element.root)
-        .querySelectorAll('.nameColumn a')[2].href,
-    'https://test/site/group/url');
-  });
-
-  test('save members correctly', async () => {
-    element._groupOwner = true;
-
-    const memberName = 'test-admin';
-
-    const saveStub = stubRestApi('saveGroupMember')
-        .callsFake(() => Promise.resolve({}));
-
-    const button = element.$.saveGroupMember;
-
-    assert.isTrue(button.hasAttribute('disabled'));
-
-    element.$.groupMemberSearchInput.text = memberName;
-    element.$.groupMemberSearchInput.value = 1234;
-
-    await flush();
-    assert.isFalse(button.hasAttribute('disabled'));
-
-    return element._handleSavingGroupMember().then(() => {
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
-      assert.isTrue(saveStub.lastCall.calledWithExactly('Administrators',
-          1234));
-    });
-  });
-
-  test('save included groups correctly', async () => {
-    element._groupOwner = true;
-
-    const includedGroupName = 'testName';
-
-    const saveIncludedGroupStub = stubRestApi('saveIncludedGroup')
-        .callsFake(() => Promise.resolve({}));
-
-    const button = element.$.saveIncludedGroups;
-
-    assert.isTrue(button.hasAttribute('disabled'));
-
-    element.$.includedGroupSearchInput.text = includedGroupName;
-    element.$.includedGroupSearchInput.value = 'testId';
-    await flush();
-    assert.isFalse(button.hasAttribute('disabled'));
-
-    return element._handleSavingIncludedGroups().then(() => {
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
-      assert.equal(saveIncludedGroupStub.lastCall.args[0], 'Administrators');
-      assert.equal(saveIncludedGroupStub.lastCall.args[1], 'testId');
-    });
-  });
-
-  test('add included group 404 shows helpful error text', () => {
-    element._groupOwner = true;
-    element._groupName = 'test';
-
-    const memberName = 'bad-name';
-    const alertStub = sinon.stub();
-    element.addEventListener('show-alert', alertStub);
-    const errorResponse = {
-      status: 404,
-      ok: false,
-    };
-    stubRestApi('saveIncludedGroup').callsFake((
-        groupName,
-        includedGroup,
-        errFn
-    ) => {
-      errFn(errorResponse);
-      return Promise.resolve(undefined);
-    });
-
-    element.$.groupMemberSearchInput.text = memberName;
-    element.$.groupMemberSearchInput.value = 1234;
-
-    return flush(element._handleSavingIncludedGroups().then(() => {
-      assert.isTrue(alertStub.called);
-    }));
-  });
-
-  test('add included group network-error throws an exception', async () => {
-    element._groupOwner = true;
-    const memberName = 'bad-name';
-    stubRestApi('saveIncludedGroup').throws(new Error());
-
-    element.$.groupMemberSearchInput.text = memberName;
-    element.$.groupMemberSearchInput.value = 1234;
-
-    let exceptionThrown = false;
-    try {
-      await element._handleSavingIncludedGroups();
-    } catch (e) {
-      exceptionThrown = true;
-    }
-    assert.isTrue(exceptionThrown);
-  });
-
-  test('_getAccountSuggestions empty', async () => {
-    const accounts = await element._getAccountSuggestions('nonexistent');
-    assert.equal(accounts.length, 0);
-  });
-
-  test('_getAccountSuggestions non-empty', async () => {
-    const accounts = await element._getAccountSuggestions('test-');
-    assert.equal(accounts.length, 3);
-    assert.equal(accounts[0].name,
-        'test-account <test.account@example.com>');
-    assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
-    assert.equal(accounts[2].name, 'test-git');
-  });
-
-  test('_getGroupSuggestions empty', async () => {
-    const groups = await element._getGroupSuggestions('nonexistent');
-
-    assert.equal(groups.length, 0);
-  });
-
-  test('_getGroupSuggestions non-empty', async () => {
-    const groups = await element._getGroupSuggestions('test');
-
-    assert.equal(groups.length, 2);
-    assert.equal(groups[0].name, 'test-admin');
-    assert.equal(groups[1].name, 'test/Administrator (admin)');
-  });
-
-  test('_computeHideItemClass returns string for admin', () => {
-    const admin = true;
-    const owner = false;
-    assert.equal(element._computeHideItemClass(owner, admin), '');
-  });
-
-  test('_computeHideItemClass returns hideItem for admin and owner', () => {
-    const admin = false;
-    const owner = false;
-    assert.equal(element._computeHideItemClass(owner, admin), 'canModify');
-  });
-
-  test('_computeHideItemClass returns string for owner', () => {
-    const admin = false;
-    const owner = true;
-    assert.equal(element._computeHideItemClass(owner, admin), '');
-  });
-
-  test('delete member', () => {
-    const deleteBtns = dom(element.root)
-        .querySelectorAll('.deleteMembersButton');
-    MockInteractions.tap(deleteBtns[0]);
-    assert.equal(element._itemId, '1000097');
-    assert.equal(element._itemName, 'jane');
-    MockInteractions.tap(deleteBtns[1]);
-    assert.equal(element._itemId, '1000096');
-    assert.equal(element._itemName, 'Test User');
-    MockInteractions.tap(deleteBtns[2]);
-    assert.equal(element._itemId, '1000095');
-    assert.equal(element._itemName, 'Gerrit');
-    MockInteractions.tap(deleteBtns[3]);
-    assert.equal(element._itemId, '1000098');
-    assert.equal(element._itemName, '1000098');
-  });
-
-  test('delete included groups', () => {
-    const deleteBtns = dom(element.root)
-        .querySelectorAll('.deleteIncludedGroupButton');
-    MockInteractions.tap(deleteBtns[0]);
-    assert.equal(element._itemId, 'testId');
-    assert.equal(element._itemName, 'testName');
-    MockInteractions.tap(deleteBtns[1]);
-    assert.equal(element._itemId, 'testId2');
-    assert.equal(element._itemName, 'testName2');
-    MockInteractions.tap(deleteBtns[2]);
-    assert.equal(element._itemId, 'testId3');
-    assert.equal(element._itemName, 'testName3');
-  });
-
-  test('_computeLoadingClass', () => {
-    assert.equal(element._computeLoadingClass(true), 'loading');
-
-    assert.equal(element._computeLoadingClass(false), '');
-  });
-
-  test('_computeGroupUrl', () => {
-    assert.isUndefined(element._computeGroupUrl(undefined));
-
-    assert.isUndefined(element._computeGroupUrl(false));
-
-    let url = '#/admin/groups/uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
-    assert.equal(element._computeGroupUrl(url),
-        'https://test/site/admin/groups/' +
-        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498');
-
-    url = 'https://gerrit.local/admin/groups/' +
-        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
-    assert.equal(element._computeGroupUrl(url), url);
-  });
-
-  test('fires page-error', async () => {
-    groupStub.restore();
-
-    element.groupId = 1;
-
-    const response = {status: 404};
-    stubRestApi('getGroupConfig').callsFake((group, errFn) => {
-      errFn(response);
-      return Promise.resolve();
-    });
-    const promise = mockPromise();
-    addListenerForTest(document, 'page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      promise.resolve();
-    });
-
-    element._loadGroupDetails();
-    await promise;
-  });
-
-  test('_computeItemName', () => {
-    assert.equal(element._computeItemTypeName(ItemType.MEMBER), 'Member');
-    assert.equal(element._computeItemTypeName(ItemType.INCLUDED_GROUP),
-        'Included Group');
-  });
-});
-
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
new file mode 100644
index 0000000..762bd2d
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
@@ -0,0 +1,400 @@
+/**
+ * @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-group-members';
+import {GrGroupMembers, ItemType} from './gr-group-members';
+import {
+  addListenerForTest,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubBaseUrl,
+  stubRestApi,
+  waitUntil,
+} from '../../../test/test-utils';
+import {
+  AccountId,
+  AccountInfo,
+  EmailAddress,
+  GroupId,
+  GroupInfo,
+  GroupName,
+} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {PageErrorEvent} from '../../../types/events.js';
+
+const basicFixture = fixtureFromElement('gr-group-members');
+
+suite('gr-group-members tests', () => {
+  let element: GrGroupMembers;
+
+  let groups: GroupInfo;
+  let groupMembers: AccountInfo[];
+  let includedGroups: GroupInfo[];
+  let groupStub: sinon.SinonStub;
+
+  setup(async () => {
+    groups = {
+      id: 'testId1' as GroupId,
+      name: 'Administrators' as GroupName,
+      owner: 'Administrators',
+      group_id: 1,
+    };
+
+    groupMembers = [
+      {
+        _account_id: 1000097 as AccountId,
+        name: 'Jane Roe',
+        email: 'jane.roe@example.com' as EmailAddress,
+        username: 'jane',
+      },
+      {
+        _account_id: 1000096 as AccountId,
+        name: 'Test User',
+        email: 'john.doe@example.com' as EmailAddress,
+      },
+      {
+        _account_id: 1000095 as AccountId,
+        name: 'Gerrit',
+      },
+      {
+        _account_id: 1000098 as AccountId,
+      },
+    ];
+
+    includedGroups = [
+      {
+        url: 'https://group/url',
+        options: {
+          visible_to_all: false,
+        },
+        id: 'testId' as GroupId,
+        name: 'testName' as GroupName,
+      },
+      {
+        url: '/group/url',
+        options: {
+          visible_to_all: false,
+        },
+        id: 'testId2' as GroupId,
+        name: 'testName2' as GroupName,
+      },
+      {
+        url: '#/group/url',
+        options: {
+          visible_to_all: false,
+        },
+        id: 'testId3' as GroupId,
+        name: 'testName3' as GroupName,
+      },
+    ];
+
+    stubRestApi('getSuggestedAccounts').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            _account_id: 1000096 as AccountId,
+            name: 'test-account',
+            email: 'test.account@example.com' as EmailAddress,
+            username: 'test123',
+          },
+          {
+            _account_id: 1001439 as AccountId,
+            name: 'test-admin',
+            email: 'test.admin@example.com' as EmailAddress,
+            username: 'test_admin',
+          },
+          {
+            _account_id: 1001439 as AccountId,
+            name: 'test-git',
+            username: 'test_git',
+          },
+        ]);
+      } else {
+        return Promise.resolve([]);
+      }
+    });
+    stubRestApi('getSuggestedGroups').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve({
+          'test-admin': {
+            id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
+          },
+          'test/Administrator (admin)': {
+            id: 'test%3Aadmin',
+          },
+        });
+      } else {
+        return Promise.resolve({});
+      }
+    });
+    stubRestApi('getGroupMembers').returns(Promise.resolve(groupMembers));
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    stubRestApi('getIncludedGroup').returns(Promise.resolve(includedGroups));
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+    stubBaseUrl('https://test/site');
+    element.groupId = 'testId1' as GroupId;
+    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(groups));
+    return element.loadGroupDetails();
+  });
+
+  test('includedGroups', () => {
+    assert.equal(element.includedGroups!.length, 3);
+    assert.equal(
+      queryAll<HTMLAnchorElement>(element, '.nameColumn a')[0].href,
+      includedGroups[0].url
+    );
+    assert.equal(
+      queryAll<HTMLAnchorElement>(element, '.nameColumn a')[1].href,
+      'https://test/site/group/url'
+    );
+    assert.equal(
+      queryAll<HTMLAnchorElement>(element, '.nameColumn a')[2].href,
+      'https://test/site/group/url'
+    );
+  });
+
+  test('save members correctly', async () => {
+    element.groupOwner = true;
+
+    const memberName = 'test-admin';
+
+    const saveStub = stubRestApi('saveGroupMember').callsFake(() =>
+      Promise.resolve({})
+    );
+
+    const button = queryAndAssert<GrButton>(element, '#saveGroupMember');
+
+    assert.isTrue(button.hasAttribute('disabled'));
+
+    const groupMemberSearchInput = queryAndAssert<GrAutocomplete>(
+      element,
+      '#groupMemberSearchInput'
+    );
+    groupMemberSearchInput.text = memberName;
+    groupMemberSearchInput.value = '1234';
+
+    await waitUntil(() => !button.hasAttribute('disabled'));
+
+    return element.handleSavingGroupMember().then(() => {
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(
+        queryAndAssert<HTMLHeadingElement>(
+          element,
+          '#Title'
+        ).classList.contains('edited')
+      );
+      assert.equal(saveStub.lastCall.args[0], 'Administrators');
+      assert.equal(saveStub.lastCall.args[1], 1234);
+    });
+  });
+
+  test('save included groups correctly', async () => {
+    element.groupOwner = true;
+
+    const includedGroupName = 'testName';
+
+    const saveIncludedGroupStub = stubRestApi('saveIncludedGroup').callsFake(
+      () => Promise.resolve({id: '0' as GroupId})
+    );
+
+    const button = queryAndAssert<GrButton>(element, '#saveIncludedGroups');
+
+    await waitUntil(() => button.hasAttribute('disabled'));
+
+    const includedGroupSearchInput = queryAndAssert<GrAutocomplete>(
+      element,
+      '#includedGroupSearchInput'
+    );
+    includedGroupSearchInput.text = includedGroupName;
+    includedGroupSearchInput.value = 'testId';
+
+    await waitUntil(() => !button.hasAttribute('disabled'));
+
+    return element.handleSavingIncludedGroups().then(() => {
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(
+        queryAndAssert<HTMLHeadingElement>(
+          element,
+          '#Title'
+        ).classList.contains('edited')
+      );
+      assert.equal(saveIncludedGroupStub.lastCall.args[0], 'Administrators');
+      assert.equal(saveIncludedGroupStub.lastCall.args[1], 'testId');
+    });
+  });
+
+  test('add included group 404 shows helpful error text', async () => {
+    element.groupOwner = true;
+    element.groupName = 'test' as GroupName;
+
+    const memberName = 'bad-name';
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    const errorResponse = {...new Response(), status: 404, ok: false};
+    stubRestApi('saveIncludedGroup').callsFake((_, _non, errFn) => {
+      if (errFn !== undefined) {
+        errFn(errorResponse);
+      } else {
+        assert.fail('errFn is undefined');
+      }
+      return Promise.resolve(undefined);
+    });
+
+    const groupMemberSearchInput = queryAndAssert<GrAutocomplete>(
+      element,
+      '#groupMemberSearchInput'
+    );
+    groupMemberSearchInput.text = memberName;
+    groupMemberSearchInput.value = '1234';
+
+    await element.updateComplete;
+    element.handleSavingIncludedGroups().then(() => {
+      assert.isTrue(alertStub.called);
+    });
+  });
+
+  test('add included group network-error throws an exception', async () => {
+    element.groupOwner = true;
+    const memberName = 'bad-name';
+    stubRestApi('saveIncludedGroup').throws(new Error());
+
+    const groupMemberSearchInput = queryAndAssert<GrAutocomplete>(
+      element,
+      '#groupMemberSearchInput'
+    );
+    groupMemberSearchInput.text = memberName;
+    groupMemberSearchInput.value = '1234';
+
+    let exceptionThrown = false;
+    try {
+      await element.handleSavingIncludedGroups();
+    } catch (e) {
+      exceptionThrown = true;
+    }
+    assert.isTrue(exceptionThrown);
+  });
+
+  test('getAccountSuggestions empty', async () => {
+    const accounts = await element.getAccountSuggestions('nonexistent');
+    assert.equal(accounts.length, 0);
+  });
+
+  test('getAccountSuggestions non-empty', async () => {
+    const accounts = await element.getAccountSuggestions('test-');
+    assert.equal(accounts.length, 3);
+    assert.equal(accounts[0].name, 'test-account <test.account@example.com>');
+    assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
+    assert.equal(accounts[2].name, 'test-git');
+  });
+
+  test('getGroupSuggestions empty', async () => {
+    const groups = await element.getGroupSuggestions('nonexistent');
+
+    assert.equal(groups.length, 0);
+  });
+
+  test('getGroupSuggestions non-empty', async () => {
+    const groups = await element.getGroupSuggestions('test');
+
+    assert.equal(groups.length, 2);
+    assert.equal(groups[0].name, 'test-admin');
+    assert.equal(groups[1].name, 'test/Administrator (admin)');
+  });
+
+  test('delete member', () => {
+    const deleteBtns = queryAll<GrButton>(element, '.deleteMembersButton');
+    MockInteractions.tap(deleteBtns[0]);
+    assert.equal(element.itemId, 1000097 as AccountId);
+    assert.equal(element.itemName, 'jane');
+    MockInteractions.tap(deleteBtns[1]);
+    assert.equal(element.itemId, 1000096 as AccountId);
+    assert.equal(element.itemName, 'Test User');
+    MockInteractions.tap(deleteBtns[2]);
+    assert.equal(element.itemId, 1000095 as AccountId);
+    assert.equal(element.itemName, 'Gerrit');
+    MockInteractions.tap(deleteBtns[3]);
+    assert.equal(element.itemId, 1000098 as AccountId);
+    assert.equal(element.itemName, '1000098');
+  });
+
+  test('delete included groups', () => {
+    const deleteBtns = queryAll<GrButton>(
+      element,
+      '.deleteIncludedGroupButton'
+    );
+    MockInteractions.tap(deleteBtns[0]);
+    assert.equal(element.itemId, 'testId' as GroupId);
+    assert.equal(element.itemName, 'testName');
+    MockInteractions.tap(deleteBtns[1]);
+    assert.equal(element.itemId, 'testId2' as GroupId);
+    assert.equal(element.itemName, 'testName2');
+    MockInteractions.tap(deleteBtns[2]);
+    assert.equal(element.itemId, 'testId3' as GroupId);
+    assert.equal(element.itemName, 'testName3');
+  });
+
+  test('computeGroupUrl', () => {
+    assert.isUndefined(element.computeGroupUrl(undefined));
+
+    let url = '#/admin/groups/uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
+    assert.equal(
+      element.computeGroupUrl(url),
+      'https://test/site/admin/groups/' +
+        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498'
+    );
+
+    url =
+      'https://gerrit.local/admin/groups/' +
+      'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
+    assert.equal(element.computeGroupUrl(url), url);
+  });
+
+  test('fires page-error', async () => {
+    groupStub.restore();
+
+    element.groupId = 'testId1' as GroupId;
+
+    const response = {...new Response(), status: 404};
+    stubRestApi('getGroupConfig').callsFake((_, errFn) => {
+      if (errFn !== undefined) {
+        errFn(response);
+      }
+      return Promise.resolve(undefined);
+    });
+    const promise = mockPromise();
+    addListenerForTest(document, 'page-error', e => {
+      assert.deepEqual((e as PageErrorEvent).detail.response, response);
+      promise.resolve();
+    });
+
+    element.loadGroupDetails();
+    await promise;
+  });
+
+  test('_computeItemName', () => {
+    assert.equal(element.computeItemTypeName(ItemType.MEMBER), 'Member');
+    assert.equal(
+      element.computeItemTypeName(ItemType.INCLUDED_GROUP),
+      'Included Group'
+    );
+  });
+});
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 442f454..fc53ac6 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -15,29 +15,27 @@
  * limitations under the License.
  */
 
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-textarea/gr-textarea';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-group_html';
-import {customElement, property, observe} from '@polymer/decorators';
 import {
   AutocompleteSuggestion,
   AutocompleteQuery,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
-import {
-  fireEvent,
-  firePageError,
-  fireTitleChange,
-} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {convertToString} from '../../../utils/string-util';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -52,12 +50,6 @@
   },
 };
 
-export interface GrGroup {
-  $: {
-    loading: HTMLDivElement;
-  };
-}
-
 export interface GroupNameChangedDetail {
   name: GroupName;
   external: boolean;
@@ -70,75 +62,263 @@
 }
 
 @customElement('gr-group')
-export class GrGroup extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrGroup extends LitElement {
   /**
    * Fired when the group name changes.
    *
    * @event name-changed
    */
 
+  private readonly query: AutocompleteQuery;
+
   @property({type: String})
   groupId?: GroupId;
 
-  @property({type: Boolean})
-  _rename = false;
+  @state() private originalOwnerName?: string;
 
-  @property({type: Boolean})
-  _groupIsInternal = false;
+  @state() private originalDescriptionName?: string;
 
-  @property({type: Boolean})
-  _description = false;
+  @state() private originalOptionsVisibleToAll?: boolean;
 
-  @property({type: Boolean})
-  _owner = false;
+  @state() private submitTypes = Object.values(OPTIONS);
 
-  @property({type: Boolean})
-  _options = false;
+  // private but used in test
+  @state() isAdmin = false;
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() groupOwner = false;
 
-  @property({type: Object})
-  _groupConfig?: GroupInfo;
+  // private but used in test
+  @state() groupIsInternal = false;
 
-  @property({type: String})
-  _groupConfigOwner?: string;
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: Object})
-  _groupName?: string;
+  // private but used in test
+  @state() groupConfig?: GroupInfo;
 
-  @property({type: Boolean})
-  _groupOwner = false;
+  // private but used in test
+  @state() groupConfigOwner?: string;
 
-  @property({type: Array})
-  _submitTypes = Object.values(OPTIONS);
+  // private but used in test
+  @state() originalName?: GroupName;
 
-  @property({type: Object})
-  _query: AutocompleteQuery;
-
-  @property({type: Boolean})
-  _isAdmin = false;
-
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this._query = (input: string) => this._getGroupSuggestions(input);
+    this.query = (input: string) => this.getGroupSuggestions(input);
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadGroup();
   }
 
-  _loadGroup() {
-    if (!this.groupId) {
-      return;
+  static override get styles() {
+    return [
+      fontStyles,
+      formStyles,
+      sharedStyles,
+      subpageStyles,
+      css`
+        h3.edited:after {
+          color: var(--deemphasized-text-color);
+          content: ' *';
+        }
+      `,
+    ];
+  }
+
+  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()}>
+          <h1 id="Title" class="heading-1">${this.originalName}</h1>
+          <h2 id="configurations" class="heading-2">General</h2>
+          <div id="form">
+            <fieldset>
+              ${this.renderGroupUUID()} ${this.renderGroupName()}
+              ${this.renderGroupOwner()} ${this.renderGroupDescription()}
+              ${this.renderGroupOptions()}
+            </fieldset>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderGroupUUID() {
+    return html`
+      <h3 id="groupUUID" class="heading-3">Group UUID</h3>
+      <fieldset>
+        <gr-copy-clipboard
+          id="uuid"
+          .text=${this.getGroupUUID()}
+        ></gr-copy-clipboard>
+      </fieldset>
+    `;
+  }
+
+  private renderGroupName() {
+    const groupNameEdited = this.originalName !== this.groupConfig?.name;
+    return html`
+      <h3
+        id="groupName"
+        class="heading-3 ${this.computeHeaderClass(groupNameEdited)}"
+      >
+        Group Name
+      </h3>
+      <fieldset>
+        <span class="value">
+          <gr-autocomplete
+            id="groupNameInput"
+            .text=${this.groupConfig?.name}
+            ?disabled=${this.computeGroupDisabled()}
+            @text-changed=${this.handleNameTextChanged}
+          ></gr-autocomplete>
+        </span>
+        <span class="value">
+          <gr-button
+            id="inputUpdateNameBtn"
+            ?disabled=${!groupNameEdited}
+            @click=${this.handleSaveName}
+          >
+            Rename Group</gr-button
+          >
+        </span>
+      </fieldset>
+    `;
+  }
+
+  private renderGroupOwner() {
+    const groupOwnerNameEdited =
+      this.originalOwnerName !== this.groupConfig?.owner;
+    return html`
+      <h3
+        id="groupOwner"
+        class="heading-3 ${this.computeHeaderClass(groupOwnerNameEdited)}"
+      >
+        Owners
+      </h3>
+      <fieldset>
+        <span class="value">
+          <gr-autocomplete
+            id="groupOwnerInput"
+            .text=${this.groupConfig?.owner}
+            .value=${this.groupConfigOwner}
+            .query=${this.query}
+            ?disabled=${this.computeGroupDisabled()}
+            @text-changed=${this.handleOwnerTextChanged}
+            @value-changed=${this.handleOwnerValueChanged}
+          >
+          </gr-autocomplete>
+        </span>
+        <span class="value">
+          <gr-button
+            id="inputUpdateOwnerBtn"
+            ?disabled=${!groupOwnerNameEdited}
+            @click=${this.handleSaveOwner}
+          >
+            Change Owners</gr-button
+          >
+        </span>
+      </fieldset>
+    `;
+  }
+
+  private renderGroupDescription() {
+    const groupDescriptionEdited =
+      this.originalDescriptionName !== this.groupConfig?.description;
+    return html`
+      <h3 class="heading-3 ${this.computeHeaderClass(groupDescriptionEdited)}">
+        Description
+      </h3>
+      <fieldset>
+        <div>
+          <gr-textarea
+            class="description"
+            autocomplete="on"
+            rows="4"
+            monospace
+            ?disabled=${this.computeGroupDisabled()}
+            .text=${this.groupConfig?.description}
+            @text-changed=${this.handleDescriptionTextChanged}
+          ></gr-textarea>
+        </div>
+        <span class="value">
+          <gr-button
+            ?disabled=${!groupDescriptionEdited}
+            @click=${this.handleSaveDescription}
+          >
+            Save Description
+          </gr-button>
+        </span>
+      </fieldset>
+    `;
+  }
+
+  private renderGroupOptions() {
+    // We make sure the value is a boolean
+    // this is done so undefined is converted to false.
+    const groupOptionsEdited =
+      Boolean(this.originalOptionsVisibleToAll) !==
+      Boolean(this.groupConfig?.options?.visible_to_all);
+
+    // We have to convert boolean to string in order
+    // for the selection to work correctly.
+    // We also convert undefined to false using boolean.
+    return html`
+      <h3
+        id="options"
+        class="heading-3 ${this.computeHeaderClass(groupOptionsEdited)}"
+      >
+        Group Options
+      </h3>
+      <fieldset>
+        <section>
+          <span class="title">
+            Make group visible to all registered users
+          </span>
+          <span class="value">
+            <gr-select
+              id="visibleToAll"
+              .bindValue=${convertToString(
+                Boolean(this.groupConfig?.options?.visible_to_all)
+              )}
+              @bind-value-changed=${this.handleOptionsBindValueChanged}
+            >
+              <select ?disabled=${this.computeGroupDisabled()}>
+                ${this.submitTypes.map(
+                  item => html`
+                    <option value=${item.value}>${item.label}</option>
+                  `
+                )}
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <span class="value">
+          <gr-button
+            ?disabled=${!groupOptionsEdited}
+            @click=${this.handleSaveOptions}
+          >
+            Save Group Options
+          </gr-button>
+        </span>
+      </fieldset>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('groupId')) {
+      this.loadGroup();
     }
+  }
+
+  // private but used in test
+  async loadGroup() {
+    if (!this.groupId) return;
 
     const promises: Promise<unknown>[] = [];
 
@@ -146,154 +326,119 @@
       firePageError(response);
     };
 
-    return this.restApiService
-      .getGroupConfig(this.groupId, errFn)
-      .then(config => {
-        if (!config || !config.name) {
-          return Promise.resolve();
-        }
+    const config = await this.restApiService.getGroupConfig(
+      this.groupId,
+      errFn
+    );
+    if (!config || !config.name) return;
 
-        this._groupName = config.name;
-        this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
+    if (config.description === undefined) {
+      config.description = '';
+    }
 
-        promises.push(
-          this.restApiService.getIsAdmin().then(isAdmin => {
-            this._isAdmin = !!isAdmin;
-          })
-        );
+    this.originalName = config.name;
+    this.originalOwnerName = config.owner;
+    this.originalDescriptionName = config.description;
+    this.groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
 
-        promises.push(
-          this.restApiService.getIsGroupOwner(config.name).then(isOwner => {
-            this._groupOwner = !!isOwner;
-          })
-        );
+    promises.push(
+      this.restApiService.getIsAdmin().then(isAdmin => {
+        this.isAdmin = !!isAdmin;
+      })
+    );
 
-        // If visible to all is undefined, set to false. If it is defined
-        // as false, setting to false is fine. If any optional values
-        // are added with a default of true, then this would need to be an
-        // undefined check and not a truthy/falsy check.
-        if (config.options && !config.options.visible_to_all) {
-          config.options.visible_to_all = false;
-        }
-        this._groupConfig = config;
+    promises.push(
+      this.restApiService.getIsGroupOwner(config.name).then(isOwner => {
+        this.groupOwner = !!isOwner;
+      })
+    );
 
-        fireTitleChange(this, config.name);
+    this.groupConfig = config;
+    this.originalOptionsVisibleToAll = config?.options?.visible_to_all;
 
-        return Promise.all(promises).then(() => {
-          this._loading = false;
-        });
-      });
+    fireTitleChange(this, config.name);
+
+    await Promise.all(promises);
+    this.loading = false;
   }
 
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
+  // private but used in test
+  computeLoadingClass() {
+    return this.loading ? 'loading' : '';
   }
 
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _handleSaveName() {
-    const groupConfig = this._groupConfig;
+  // private but used in test
+  async handleSaveName() {
+    const groupConfig = this.groupConfig;
     if (!this.groupId || !groupConfig || !groupConfig.name) {
       return Promise.reject(new Error('invalid groupId or config name'));
     }
     const groupName = groupConfig.name;
-    return this.restApiService
-      .saveGroupName(this.groupId, groupName)
-      .then(config => {
-        if (config.status === 200) {
-          this._groupName = groupName;
-          const detail: GroupNameChangedDetail = {
-            name: groupName,
-            external: !this._groupIsInternal,
-          };
-          fireEvent(this, 'name-changed');
-          this.dispatchEvent(
-            new CustomEvent('name-changed', {
-              detail,
-              composed: true,
-              bubbles: true,
-            })
-          );
-          this._rename = false;
-        }
-      });
+    const config = await this.restApiService.saveGroupName(
+      this.groupId,
+      groupName
+    );
+    if (config.status === 200) {
+      this.originalName = groupName;
+      const detail: GroupNameChangedDetail = {
+        name: groupName,
+        external: !this.groupIsInternal,
+      };
+      this.dispatchEvent(
+        new CustomEvent('name-changed', {
+          detail,
+          composed: true,
+          bubbles: true,
+        })
+      );
+      this.requestUpdate();
+    }
+
+    return;
   }
 
-  _handleSaveOwner() {
-    if (!this.groupId || !this._groupConfig) return;
-    let owner = this._groupConfig.owner;
-    if (this._groupConfigOwner) {
-      owner = decodeURIComponent(this._groupConfigOwner);
+  // private but used in test
+  async handleSaveOwner() {
+    if (!this.groupId || !this.groupConfig) return;
+    let owner = this.groupConfig.owner;
+    if (this.groupConfigOwner) {
+      owner = decodeURIComponent(this.groupConfigOwner);
     }
     if (!owner) return;
-    return this.restApiService.saveGroupOwner(this.groupId, owner).then(() => {
-      this._owner = false;
-    });
+    await this.restApiService.saveGroupOwner(this.groupId, owner);
+    this.originalOwnerName = this.groupConfig?.owner;
+    this.groupConfigOwner = undefined;
   }
 
-  _handleSaveDescription() {
-    if (!this.groupId || !this._groupConfig || !this._groupConfig.description)
+  // private but used in test
+  async handleSaveDescription() {
+    if (
+      !this.groupId ||
+      !this.groupConfig ||
+      this.groupConfig.description === undefined
+    )
       return;
-    return this.restApiService
-      .saveGroupDescription(this.groupId, this._groupConfig.description)
-      .then(() => {
-        this._description = false;
-      });
+    await this.restApiService.saveGroupDescription(
+      this.groupId,
+      this.groupConfig.description
+    );
+    this.originalDescriptionName = this.groupConfig.description;
   }
 
-  _handleSaveOptions() {
-    if (!this.groupId || !this._groupConfig || !this._groupConfig.options)
-      return;
-    const visible = this._groupConfig.options.visible_to_all;
-
+  // private but used in test
+  async handleSaveOptions() {
+    if (!this.groupId || !this.groupConfig || !this.groupConfig.options) return;
+    const visible = this.groupConfig.options.visible_to_all;
     const options = {visible_to_all: visible};
-
-    return this.restApiService
-      .saveGroupOptions(this.groupId, options)
-      .then(() => {
-        this._options = false;
-      });
+    await this.restApiService.saveGroupOptions(this.groupId, options);
+    this.originalOptionsVisibleToAll = visible;
   }
 
-  @observe('_groupConfig.name')
-  _handleConfigName() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._rename = true;
-  }
-
-  @observe('_groupConfig.owner', '_groupConfigOwner')
-  _handleConfigOwner() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._owner = true;
-  }
-
-  @observe('_groupConfig.description')
-  _handleConfigDescription() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._description = true;
-  }
-
-  @observe('_groupConfig.options.visible_to_all')
-  _handleConfigOptions() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._options = true;
-  }
-
-  _computeHeaderClass(configChanged: boolean) {
+  private computeHeaderClass(configChanged: boolean) {
     return configChanged ? 'edited' : '';
   }
 
-  _getGroupSuggestions(input: string) {
+  private getGroupSuggestions(input: string) {
     return this.restApiService.getSuggestedGroups(input).then(response => {
       const groups: AutocompleteSuggestion[] = [];
       for (const [name, group] of Object.entries(response ?? {})) {
@@ -303,17 +448,48 @@
     });
   }
 
-  _computeGroupDisabled(
-    owner: boolean,
-    admin: boolean,
-    groupIsInternal: boolean
-  ) {
-    return !(groupIsInternal && (admin || owner));
+  // private but used in test
+  computeGroupDisabled() {
+    return !(this.groupIsInternal && (this.isAdmin || this.groupOwner));
   }
 
-  _getGroupUUID(id: GroupId) {
+  private getGroupUUID() {
+    const id = this.groupConfig?.id;
     if (!id) return;
-
     return id.match(INTERNAL_GROUP_REGEX) ? id : decodeURIComponent(id);
   }
+
+  private handleNameTextChanged(e: CustomEvent) {
+    if (!this.groupConfig || this.loading) return;
+    this.groupConfig.name = e.detail.value as GroupName;
+    this.requestUpdate();
+  }
+
+  private handleOwnerTextChanged(e: CustomEvent) {
+    if (!this.groupConfig || this.loading) return;
+    this.groupConfig.owner = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handleOwnerValueChanged(e: CustomEvent) {
+    if (this.loading) return;
+    this.groupConfigOwner = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handleDescriptionTextChanged(e: CustomEvent) {
+    if (!this.groupConfig || this.loading) return;
+    this.groupConfig.description = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handleOptionsBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.groupConfig || this.loading) return;
+
+    // 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.requestUpdate();
+  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
deleted file mode 100644
index c08908d..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
+++ /dev/null
@@ -1,170 +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">
-    h3.edited:after {
-      color: var(--deemphasized-text-color);
-      content: ' *';
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="main gr-form-styles read-only">
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h1 id="Title" class="heading-1">[[_groupName]]</h1>
-      <h2 id="configurations" class="heading-2">General</h2>
-      <div id="form">
-        <fieldset>
-          <h3 id="groupUUID" class="heading-3">Group UUID</h3>
-          <fieldset>
-            <gr-copy-clipboard
-              id="uuid"
-              text="[[_getGroupUUID(_groupConfig.id)]]"
-            ></gr-copy-clipboard>
-          </fieldset>
-          <h3
-            id="groupName"
-            class$="heading-3 [[_computeHeaderClass(_rename)]]"
-          >
-            Group Name
-          </h3>
-          <fieldset>
-            <span class="value">
-              <gr-autocomplete
-                id="groupNameInput"
-                text="{{_groupConfig.name}}"
-                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-              ></gr-autocomplete>
-            </span>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button
-                id="inputUpdateNameBtn"
-                on-click="_handleSaveName"
-                disabled="[[!_rename]]"
-              >
-                Rename Group</gr-button
-              >
-            </span>
-          </fieldset>
-          <h3
-            id="groupOwner"
-            class$="heading-3 [[_computeHeaderClass(_owner)]]"
-          >
-            Owners
-          </h3>
-          <fieldset>
-            <span class="value">
-              <gr-autocomplete
-                id="groupOwnerInput"
-                text="{{_groupConfig.owner}}"
-                value="{{_groupConfigOwner}}"
-                query="[[_query]]"
-                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-              >
-              </gr-autocomplete>
-            </span>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button
-                id="inputUpdateOwnerBtn"
-                on-click="_handleSaveOwner"
-                disabled="[[!_owner]]"
-              >
-                Change Owners</gr-button
-              >
-            </span>
-          </fieldset>
-          <h3 class$="heading-3 [[_computeHeaderClass(_description)]]">
-            Description
-          </h3>
-          <fieldset>
-            <div>
-              <gr-textarea
-                class="description"
-                autocomplete="on"
-                rows="4"
-                monospace=""
-                text="{{_groupConfig.description}}"
-                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-              ></gr-textarea>
-            </div>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button
-                on-click="_handleSaveDescription"
-                disabled="[[!_description]]"
-              >
-                Save Description
-              </gr-button>
-            </span>
-          </fieldset>
-          <h3 id="options" class$="heading-3 [[_computeHeaderClass(_options)]]">
-            Group Options
-          </h3>
-          <fieldset>
-            <section>
-              <span class="title">
-                Make group visible to all registered users
-              </span>
-              <span class="value">
-                <gr-select
-                  id="visibleToAll"
-                  bind-value="{{_groupConfig.options.visible_to_all}}"
-                >
-                  <select
-                    disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-                  >
-                    <template is="dom-repeat" items="[[_submitTypes]]">
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button on-click="_handleSaveOptions" disabled="[[!_options]]">
-                Save Group Options
-              </gr-button>
-            </span>
-          </fieldset>
-        </fieldset>
-      </div>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
deleted file mode 100644
index e390ac5..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
+++ /dev/null
@@ -1,234 +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-group.js';
-import {
-  addListenerForTest,
-  mockPromise,
-  stubRestApi,
-} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-group');
-
-suite('gr-group tests', () => {
-  let element;
-
-  let groupStub;
-  const group = {
-    id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    url: '#/admin/groups/uuid-6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    options: {},
-    description: 'Gerrit Site Administrators',
-    group_id: 1,
-    owner: 'Administrators',
-    owner_id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    name: 'Administrators',
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(group));
-  });
-
-  test('loading displays before group config is loaded', () => {
-    assert.isTrue(element.$.loading.classList.contains('loading'));
-    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
-    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
-    assert.isTrue(getComputedStyle(element.$.loadedContent)
-        .display === 'none');
-  });
-
-  test('default values are populated with internal group', async () => {
-    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
-    element.groupId = 1;
-    await element._loadGroup();
-    assert.isTrue(element._groupIsInternal);
-    assert.isFalse(element.$.visibleToAll.bindValue);
-  });
-
-  test('default values with external group', async () => {
-    const groupExternal = {...group};
-    groupExternal.id = 'external-group-id';
-    groupStub.restore();
-    groupStub = stubRestApi('getGroupConfig').returns(
-        Promise.resolve(groupExternal));
-    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
-    element.groupId = 1;
-    await element._loadGroup();
-    assert.isFalse(element._groupIsInternal);
-    assert.isFalse(element.$.visibleToAll.bindValue);
-  });
-
-  test('rename group', async () => {
-    const groupName = 'test-group';
-    const groupName2 = 'test-group2';
-    element.groupId = 1;
-    element._groupConfig = {
-      name: groupName,
-    };
-    element._groupName = groupName;
-
-    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
-    stubRestApi('saveGroupName').returns(Promise.resolve({status: 200}));
-
-    const button = element.$.inputUpdateNameBtn;
-
-    await element._loadGroup();
-    assert.isTrue(button.hasAttribute('disabled'));
-    assert.isFalse(element.$.Title.classList.contains('edited'));
-
-    element.$.groupNameInput.text = groupName2;
-
-    await flush();
-    assert.isFalse(button.hasAttribute('disabled'));
-    assert.isTrue(element.$.groupName.classList.contains('edited'));
-
-    await element._handleSaveName();
-    assert.isTrue(button.hasAttribute('disabled'));
-    assert.isFalse(element.$.Title.classList.contains('edited'));
-    assert.equal(element._groupName, groupName2);
-  });
-
-  test('rename group owner', async () => {
-    const groupName = 'test-group';
-    element.groupId = 1;
-    element._groupConfig = {
-      name: groupName,
-    };
-    element._groupConfigOwner = 'testId';
-    element._groupOwner = true;
-
-    stubRestApi('getIsGroupOwner').returns(Promise.resolve({status: 200}));
-
-    const button = element.$.inputUpdateOwnerBtn;
-
-    await element._loadGroup();
-    assert.isTrue(button.hasAttribute('disabled'));
-    assert.isFalse(element.$.Title.classList.contains('edited'));
-
-    element.$.groupOwnerInput.text = 'testId2';
-
-    await flush();
-    assert.isFalse(button.hasAttribute('disabled'));
-    assert.isTrue(element.$.groupOwner.classList.contains('edited'));
-
-    await element._handleSaveOwner();
-    assert.isTrue(button.hasAttribute('disabled'));
-    assert.isFalse(element.$.Title.classList.contains('edited'));
-  });
-
-  test('test for undefined group name', async () => {
-    groupStub.restore();
-
-    stubRestApi('getGroupConfig').returns(Promise.resolve({}));
-
-    assert.isUndefined(element.groupId);
-
-    element.groupId = 1;
-
-    assert.isDefined(element.groupId);
-
-    // Test that loading shows instead of filling
-    // in group details
-    await element._loadGroup();
-    assert.isTrue(element.$.loading.classList.contains('loading'));
-
-    assert.isTrue(element._loading);
-  });
-
-  test('test fire event', async () => {
-    element._groupConfig = {
-      name: 'test-group',
-    };
-    element.groupId = 'gg';
-    stubRestApi('saveGroupName').returns(Promise.resolve({status: 200}));
-
-    const showStub = sinon.stub(element, 'dispatchEvent');
-    await element._handleSaveName();
-    assert.isTrue(showStub.called);
-  });
-
-  test('_computeGroupDisabled', () => {
-    let admin = true;
-    let owner = false;
-    let groupIsInternal = true;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), false);
-
-    admin = false;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-
-    owner = true;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), false);
-
-    owner = false;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-
-    groupIsInternal = false;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-
-    admin = true;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-  });
-
-  test('_computeLoadingClass', () => {
-    assert.equal(element._computeLoadingClass(true), 'loading');
-    assert.equal(element._computeLoadingClass(false), '');
-  });
-
-  test('fires page-error', async () => {
-    groupStub.restore();
-
-    element.groupId = 1;
-
-    const response = {status: 404};
-    stubRestApi('getGroupConfig').callsFake((group, errFn) => {
-      errFn(response);
-      return Promise.resolve(undefined);
-    });
-
-    const promise = mockPromise();
-    addListenerForTest(document, 'page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      promise.resolve();
-    });
-
-    element._loadGroup();
-    await promise;
-  });
-
-  test('uuid', () => {
-    element._groupConfig = {
-      id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    };
-
-    assert.equal(element._groupConfig.id, element.$.uuid.text);
-
-    element._groupConfig = {
-      id: 'user%2Fgroup',
-    };
-
-    assert.equal('user/group', element.$.uuid.text);
-  });
-});
-
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
new file mode 100644
index 0000000..a6258b0
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -0,0 +1,319 @@
+/**
+ * @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-group';
+import {GrGroup} from './gr-group';
+import {
+  addListenerForTest,
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+  waitUntil,
+} from '../../../test/test-utils';
+import {createGroupInfo} from '../../../test/test-data-generators.js';
+import {GroupId, GroupInfo, GroupName} from '../../../types/common';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrCopyClipboard} from '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+
+const basicFixture = fixtureFromElement('gr-group');
+
+suite('gr-group tests', () => {
+  let element: GrGroup;
+  let groupStub: sinon.SinonStub;
+
+  const group: GroupInfo = {
+    ...createGroupInfo('6a1e70e1a88782771a91808c8af9bbb7a9871389'),
+    url: '#/admin/groups/uuid-6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    options: {
+      visible_to_all: false,
+    },
+    description: 'Gerrit Site Administrators',
+    group_id: 1,
+    owner: 'Administrators',
+    owner_id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    name: 'Administrators' as GroupName,
+  };
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(group));
+  });
+
+  test('loading displays before group config is loaded', () => {
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
+        'loading'
+      )
+    );
+    assert.isFalse(
+      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '#loading'))
+        .display === 'none'
+    );
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#loadedContent'
+      ).classList.contains('loading')
+    );
+    assert.isTrue(
+      getComputedStyle(
+        queryAndAssert<HTMLDivElement>(element, '#loadedContent')
+      ).display === 'none'
+    );
+  });
+
+  test('default values are populated with internal group', async () => {
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    element.groupId = '1' as GroupId;
+    await element.loadGroup();
+    assert.isTrue(element.groupIsInternal);
+    // The value returned is a boolean in a string
+    // thus we have to check with the string.
+    assert.equal(
+      queryAndAssert<GrSelect>(element, '#visibleToAll').bindValue,
+      'false'
+    );
+  });
+
+  test('default values with external group', async () => {
+    const groupExternal = {...group};
+    groupExternal.id = 'external-group-id' as GroupId;
+    groupStub.restore();
+    groupStub = stubRestApi('getGroupConfig').returns(
+      Promise.resolve(groupExternal)
+    );
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    element.groupId = '1' as GroupId;
+    await element.loadGroup();
+    assert.isFalse(element.groupIsInternal);
+    // The value returned is a boolean in a string
+    // thus we have to check with the string.
+    assert.equal(
+      queryAndAssert<GrSelect>(element, '#visibleToAll').bindValue,
+      'false'
+    );
+  });
+
+  test('rename group', async () => {
+    const groupName = 'test-group';
+    const groupName2 = 'test-group2';
+    element.groupId = '1' as GroupId;
+    element.groupConfig = {
+      name: groupName as GroupName,
+      id: '1' as GroupId,
+    };
+    element.originalName = groupName as GroupName;
+
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+    stubRestApi('saveGroupName').returns(
+      Promise.resolve({...new Response(), status: 200})
+    );
+
+    const button = queryAndAssert<GrButton>(element, '#inputUpdateNameBtn');
+
+    await element.loadGroup();
+    assert.isTrue(button.hasAttribute('disabled'));
+    assert.isFalse(
+      queryAndAssert<HTMLHeadingElement>(element, '#Title').classList.contains(
+        'edited'
+      )
+    );
+
+    queryAndAssert<GrAutocomplete>(element, '#groupNameInput').text =
+      groupName2;
+
+    await waitUntil(() => button.hasAttribute('disabled') === false);
+
+    assert.isTrue(
+      queryAndAssert<HTMLHeadingElement>(
+        element,
+        '#groupName'
+      ).classList.contains('edited')
+    );
+
+    await element.handleSaveName();
+    assert.isTrue(button.disabled);
+    assert.isFalse(
+      queryAndAssert<HTMLHeadingElement>(element, '#Title').classList.contains(
+        'edited'
+      )
+    );
+    assert.equal(element.originalName, groupName2);
+  });
+
+  test('rename group owner', async () => {
+    const groupName = 'test-group';
+    element.groupId = '1' as GroupId;
+    element.groupConfig = {
+      name: groupName as GroupName,
+      id: '1' as GroupId,
+    };
+    element.groupConfigOwner = 'testId';
+    element.groupOwner = true;
+
+    stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+
+    const button = queryAndAssert<GrButton>(element, '#inputUpdateOwnerBtn');
+
+    await element.loadGroup();
+    assert.isTrue(button.disabled);
+    assert.isFalse(
+      queryAndAssert<HTMLHeadingElement>(element, '#Title').classList.contains(
+        'edited'
+      )
+    );
+
+    queryAndAssert<GrAutocomplete>(element, '#groupOwnerInput').text =
+      'testId2';
+
+    await waitUntil(() => button.disabled === false);
+    assert.isTrue(
+      queryAndAssert<HTMLHeadingElement>(
+        element,
+        '#groupOwner'
+      ).classList.contains('edited')
+    );
+
+    await element.handleSaveOwner();
+    assert.isTrue(button.disabled);
+    assert.isFalse(
+      queryAndAssert<HTMLHeadingElement>(element, '#Title').classList.contains(
+        'edited'
+      )
+    );
+  });
+
+  test('test for undefined group name', async () => {
+    groupStub.restore();
+
+    stubRestApi('getGroupConfig').returns(Promise.resolve(undefined));
+
+    assert.isUndefined(element.groupId);
+
+    element.groupId = '1' as GroupId;
+
+    assert.isDefined(element.groupId);
+
+    // Test that loading shows instead of filling
+    // in group details
+    await element.loadGroup();
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
+        'loading'
+      )
+    );
+
+    assert.isTrue(element.loading);
+  });
+
+  test('test fire event', async () => {
+    element.groupConfig = {
+      name: 'test-group' as GroupName,
+      id: '1' as GroupId,
+    };
+    element.groupId = 'gg' as GroupId;
+    stubRestApi('saveGroupName').returns(
+      Promise.resolve({...new Response(), status: 200})
+    );
+
+    const showStub = sinon.stub(element, 'dispatchEvent');
+    await element.handleSaveName();
+    assert.isTrue(showStub.called);
+  });
+
+  test('computeGroupDisabled', () => {
+    element.isAdmin = true;
+    element.groupOwner = false;
+    element.groupIsInternal = true;
+    assert.equal(element.computeGroupDisabled(), false);
+
+    element.isAdmin = false;
+    assert.equal(element.computeGroupDisabled(), true);
+
+    element.groupOwner = true;
+    assert.equal(element.computeGroupDisabled(), false);
+
+    element.groupOwner = false;
+    assert.equal(element.computeGroupDisabled(), true);
+
+    element.groupIsInternal = false;
+    assert.equal(element.computeGroupDisabled(), true);
+
+    element.isAdmin = true;
+    assert.equal(element.computeGroupDisabled(), true);
+  });
+
+  test('computeLoadingClass', () => {
+    element.loading = true;
+    assert.equal(element.computeLoadingClass(), 'loading');
+    element.loading = false;
+    assert.equal(element.computeLoadingClass(), '');
+  });
+
+  test('fires page-error', async () => {
+    groupStub.restore();
+
+    element.groupId = '1' as GroupId;
+
+    const response = {...new Response(), status: 404};
+    stubRestApi('getGroupConfig').callsFake((_, errFn) => {
+      if (errFn !== undefined) {
+        errFn(response);
+      } else {
+        assert.fail('errFn is undefined');
+      }
+      return Promise.resolve(undefined);
+    });
+
+    const promise = mockPromise();
+    addListenerForTest(document, 'page-error', e => {
+      assert.deepEqual((e as CustomEvent).detail.response, response);
+      promise.resolve();
+    });
+
+    await element.loadGroup();
+    await promise;
+  });
+
+  test('uuid', async () => {
+    element.groupConfig = {
+      id: '6a1e70e1a88782771a91808c8af9bbb7a9871389' as GroupId,
+    };
+
+    await element.updateComplete;
+
+    assert.equal(
+      element.groupConfig.id,
+      queryAndAssert<GrCopyClipboard>(element, '#uuid').text
+    );
+
+    element.groupConfig = {
+      id: 'user%2Fgroup' as GroupId,
+    };
+
+    await element.updateComplete;
+
+    assert.equal(
+      'user/group',
+      queryAndAssert<GrCopyClipboard>(element, '#uuid').text
+    );
+  });
+});
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 f2b84c2..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,27 +16,21 @@
  */
 
 import '@polymer/paper-toggle-button/paper-toggle-button';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-menu-page-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';
@@ -51,9 +45,14 @@
   EditablePermissionRuleInfo,
   EditableProjectAccessGroups,
 } from '../gr-repo-access/gr-repo-access-interfaces';
-import {PolymerDomRepeatEvent} from '../../../types/types';
-import {appContext} from '../../../services/app-context';
-import {fireEvent} from '../../../utils/event-util';
+import {getAppContext} from '../../../services/app-context';
+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;
 
@@ -61,12 +60,6 @@
 
 type GroupsWithRulesMap = {[ruleId: string]: boolean};
 
-export interface GrPermission {
-  $: {
-    groupAutocomplete: GrAutocomplete;
-  };
-}
-
 interface ComputedLabelValue {
   value: number;
   text: string;
@@ -93,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;
 
@@ -107,7 +96,7 @@
   @property({type: String})
   name?: string;
 
-  @property({type: Object, observer: '_sortPermission', notify: true})
+  @property({type: Object})
   permission?: PermissionArrayItem<EditablePermissionInfo>;
 
   @property({type: Object})
@@ -116,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;
 
-  private readonly restApiService = appContext.restApiService;
+  @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];
@@ -193,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');
@@ -255,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 ||
@@ -285,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));
 
@@ -305,8 +459,8 @@
     return valuesArr;
   }
 
-  _computeGroupsWithRules(
-    rules: PermissionArray<EditablePermissionRuleInfo>
+  computeGroupsWithRules(
+    rules: PermissionArray<EditablePermissionRuleInfo | undefined>
   ): GroupsWithRulesMap {
     const groups: GroupsWithRulesMap = {};
     for (const rule of rules) {
@@ -315,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
       )
@@ -337,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 = {
@@ -353,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;
     }
 
@@ -371,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;
     }
@@ -405,15 +564,30 @@
     return RANGE_NAMES.includes(name.toUpperCase());
   }
 
+  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();
   }
+
+  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 3559194..0000000
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
+++ /dev/null
@@ -1,143 +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-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
-            >Exclusive
-          </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"
-          ></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.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
deleted file mode 100644
index 8f25db5..0000000
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
+++ /dev/null
@@ -1,409 +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-permission.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-permission');
-
-suite('gr-permission tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    stubRestApi('getSuggestedGroups').returns(
-        Promise.resolve({
-          'Administrators': {
-            id: '4c97682e6ce61b7247f3381b6f1789356666de7f',
-          },
-          'Anonymous Users': {
-            id: 'global%3AAnonymous-Users',
-          },
-        }));
-  });
-
-  suite('unit tests', () => {
-    test('_sortPermission', () => {
-      const permission = {
-        id: 'submit',
-        value: {
-          rules: {
-            'global:Project-Owners': {
-              action: 'ALLOW',
-              force: false,
-            },
-            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-              action: 'ALLOW',
-              force: false,
-            },
-          },
-        },
-      };
-
-      const expectedRules = [
-        {
-          id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
-          value: {action: 'ALLOW', force: false},
-        },
-        {
-          id: 'global:Project-Owners',
-          value: {action: 'ALLOW', force: false},
-        },
-      ];
-
-      element._sortPermission(permission);
-      assert.deepEqual(element._rules, expectedRules);
-    });
-
-    test('_computeLabel and _computeLabelValues', () => {
-      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': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-        },
-      };
-      let permission = {
-        id: 'label-Code-Review',
-        value: {
-          label: 'Code-Review',
-          rules: {
-            'global:Project-Owners': {
-              action: 'ALLOW',
-              force: false,
-              min: -2,
-              max: 2,
-            },
-            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-              action: 'ALLOW',
-              force: false,
-              min: -2,
-              max: 2,
-            },
-          },
-        },
-      };
-
-      const expectedLabelValues = [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged 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'},
-      ];
-
-      const expectedLabel = {
-        name: 'Code-Review',
-        values: expectedLabelValues,
-      };
-
-      assert.deepEqual(element._computeLabelValues(
-          labels['Code-Review'].values), expectedLabelValues);
-
-      assert.deepEqual(element._computeLabel(permission, labels),
-          expectedLabel);
-
-      permission = {
-        id: 'label-reviewDB',
-        value: {
-          label: 'reviewDB',
-          rules: {
-            'global:Project-Owners': {
-              action: 'ALLOW',
-              force: false,
-            },
-            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-              action: 'ALLOW',
-              force: false,
-            },
-          },
-        },
-      };
-
-      assert.isNotOk(element._computeLabel(permission, labels));
-    });
-
-    test('_computeSectionClass', () => {
-      let deleted = true;
-      let editing = false;
-      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
-      deleted = false;
-      assert.equal(element._computeSectionClass(editing, deleted), '');
-
-      editing = true;
-      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
-      deleted = true;
-      assert.equal(element._computeSectionClass(editing, deleted),
-          'editing deleted');
-    });
-
-    test('_computeGroupName', () => {
-      const groups = {
-        abc123: {name: 'test group'},
-        bcd234: {},
-      };
-      assert.equal(element._computeGroupName(groups, 'abc123'), 'test group');
-      assert.equal(element._computeGroupName(groups, 'bcd234'), 'bcd234');
-    });
-
-    test('_computeGroupsWithRules', () => {
-      const rules = [
-        {
-          id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
-          value: {action: 'ALLOW', force: false},
-        },
-        {
-          id: 'global:Project-Owners',
-          value: {action: 'ALLOW', force: false},
-        },
-      ];
-      const groupsWithRules = {
-        '4c97682e6ce6b7247f3381b6f1789356666de7f': true,
-        'global:Project-Owners': true,
-      };
-      assert.deepEqual(element._computeGroupsWithRules(rules),
-          groupsWithRules);
-    });
-
-    test('_getGroupSuggestions without existing rules', async () => {
-      element._groupsWithRules = {};
-
-      const groups = await element._getGroupSuggestions();
-      assert.deepEqual(groups, [
-        {
-          name: 'Administrators',
-          value: '4c97682e6ce61b7247f3381b6f1789356666de7f',
-        }, {
-          name: 'Anonymous Users',
-          value: 'global%3AAnonymous-Users',
-        },
-      ]);
-    });
-
-    test('_getGroupSuggestions with existing rules filters them', async () => {
-      element._groupsWithRules = {
-        '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
-      };
-
-      const groups = await element._getGroupSuggestions();
-      assert.deepEqual(groups, [{
-        name: 'Anonymous Users',
-        value: 'global%3AAnonymous-Users',
-      }]);
-    });
-
-    test('_handleRemovePermission', () => {
-      element.editing = true;
-      element.permission = {value: {rules: {}}};
-      element._handleRemovePermission();
-      assert.isTrue(element._deleted);
-      assert.isTrue(element.permission.value.deleted);
-
-      element.editing = false;
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.permission.value.deleted);
-    });
-
-    test('_handleUndoRemove', () => {
-      element.permission = {value: {deleted: true, rules: {}}};
-      element._handleUndoRemove();
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.permission.value.deleted);
-    });
-
-    test('_computeHasRange', () => {
-      assert.isTrue(element._computeHasRange('Query Limit'));
-
-      assert.isTrue(element._computeHasRange('Batch Changes Limit'));
-
-      assert.isFalse(element._computeHasRange('test'));
-    });
-  });
-
-  suite('interactions', () => {
-    setup(() => {
-      sinon.spy(element, '_computeLabel');
-      element.name = 'Priority';
-      element.section = 'refs/*';
-      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.permission = {
-        id: 'label-Code-Review',
-        value: {
-          label: 'Code-Review',
-          rules: {
-            'global:Project-Owners': {
-              action: 'ALLOW',
-              force: false,
-              min: -2,
-              max: 2,
-            },
-            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-              action: 'ALLOW',
-              force: false,
-              min: -2,
-              max: 2,
-            },
-          },
-        },
-      };
-      element._setupValues();
-      flush();
-    });
-
-    test('adding a rule', () => {
-      element.name = 'Priority';
-      element.section = 'refs/*';
-      element.groups = {};
-      element.$.groupAutocomplete.text = 'ldap/tests te.st';
-      const e = {
-        detail: {
-          value: 'ldap:CN=test+te.st',
-        },
-      };
-      element.editing = true;
-      assert.equal(element._rules.length, 2);
-      assert.equal(Object.keys(element._groupsWithRules).length, 2);
-      element._handleAddRuleItem(e);
-      flush();
-      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.deepEqual(element.permission.value.rules['ldap:CN=test te.st'],
-          {action: 'ALLOW', min: -2, max: 2, added: true});
-      assert.equal(element.$.groupAutocomplete.text, '');
-      // New rule should be removed if cancel from editing.
-      element.editing = false;
-      assert.equal(element._rules.length, 2);
-      assert.equal(Object.keys(element.permission.value.rules).length, 2);
-    });
-
-    test('removing an added rule', () => {
-      element.name = 'Priority';
-      element.section = 'refs/*';
-      element.groups = {};
-      element.$.groupAutocomplete.text = 'new group name';
-      assert.equal(element._rules.length, 2);
-      element.shadowRoot
-          .querySelector('gr-rule-editor').dispatchEvent(
-              new CustomEvent('added-rule-removed', {
-                composed: true, bubbles: true,
-              }));
-      flush();
-      assert.equal(element._rules.length, 1);
-    });
-
-    test('removing an added permission', () => {
-      const removeStub = sinon.stub();
-      element.addEventListener('added-permission-removed', removeStub);
-      element.editing = true;
-      element.name = 'Priority';
-      element.section = 'refs/*';
-      element.permission.value.added = true;
-      MockInteractions.tap(element.$.removeBtn);
-      assert.isTrue(removeStub.called);
-    });
-
-    test('removing the permission', () => {
-      element.editing = true;
-      element.name = 'Priority';
-      element.section = 'refs/*';
-
-      const removeStub = sinon.stub();
-      element.addEventListener('added-permission-removed', removeStub);
-
-      assert.isFalse(element.$.permission.classList.contains('deleted'));
-      assert.isFalse(element._deleted);
-      MockInteractions.tap(element.$.removeBtn);
-      assert.isTrue(element.$.permission.classList.contains('deleted'));
-      assert.isTrue(element._deleted);
-      MockInteractions.tap(element.$.undoRemoveBtn);
-      assert.isFalse(element.$.permission.classList.contains('deleted'));
-      assert.isFalse(element._deleted);
-      assert.isFalse(removeStub.called);
-    });
-
-    test('modify a permission', () => {
-      element.editing = true;
-      element.name = 'Priority';
-      element.section = 'refs/*';
-
-      assert.isFalse(element._originalExclusiveValue);
-      assert.isNotOk(element.permission.value.modified);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#exclusiveToggle'));
-      flush();
-      assert.isTrue(element.permission.value.exclusive);
-      assert.isTrue(element.permission.value.modified);
-      assert.isFalse(element._originalExclusiveValue);
-      element.editing = false;
-      assert.isFalse(element.permission.value.exclusive);
-    });
-
-    test('_handleValueChange', () => {
-      const modifiedHandler = sinon.stub();
-      element.permission = {value: {rules: {}}};
-      element.addEventListener('access-modified', modifiedHandler);
-      assert.isNotOk(element.permission.value.modified);
-      element._handleValueChange();
-      assert.isTrue(element.permission.value.modified);
-      assert.isTrue(modifiedHandler.called);
-    });
-
-    test('Exclusive hidden for owner permission', () => {
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#exclusiveToggle')).display,
-      'flex');
-      element.set(['permission', 'id'], 'owner');
-      flush();
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#exclusiveToggle')).display,
-      'none');
-    });
-
-    test('Exclusive hidden for any global permissions', () => {
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#exclusiveToggle')).display,
-      'flex');
-      element.section = 'GLOBAL_CAPABILITIES';
-      flush();
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#exclusiveToggle')).display,
-      'none');
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..b77a9ef0
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
@@ -0,0 +1,484 @@
+/**
+ * @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-permission';
+import {GrPermission} from './gr-permission';
+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';
+import {
+  AutocompleteCommitEventDetail,
+  GrAutocomplete,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrRuleEditor} from '../gr-rule-editor/gr-rule-editor';
+import {GrButton} from '../../shared/gr-button/gr-button';
+
+const basicFixture = fixtureFromElement('gr-permission');
+
+suite('gr-permission tests', () => {
+  let element: GrPermission;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    stubRestApi('getSuggestedGroups').returns(
+      Promise.resolve({
+        Administrators: {
+          id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId,
+        },
+        'Anonymous Users': {
+          id: 'global%3AAnonymous-Users' as GroupId,
+        },
+      })
+    );
+  });
+
+  suite('unit tests', () => {
+    test('sortPermission', async () => {
+      const permission = {
+        id: 'submit' as GitRef,
+        value: {
+          rules: {
+            'global:Project-Owners': {
+              action: PermissionAction.ALLOW,
+              force: false,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: PermissionAction.ALLOW,
+              force: false,
+            },
+          },
+        },
+      };
+
+      const expectedRules = [
+        {
+          id: '4c97682e6ce6b7247f3381b6f1789356666de7f' as GitRef,
+          value: {action: PermissionAction.ALLOW, force: false},
+        },
+        {
+          id: 'global:Project-Owners' as GitRef,
+          value: {action: PermissionAction.ALLOW, force: false},
+        },
+      ];
+
+      element.sortPermission(permission);
+      await element.updateComplete;
+      assert.deepEqual(element.rules, expectedRules);
+    });
+
+    test('computeLabel and computeLabelValues', async () => {
+      const labels = {
+        'Code-Review': {
+          default_value: 0,
+          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',
+          },
+        },
+      };
+      let permission = {
+        id: 'label-Code-Review' as GitRef,
+        value: {
+          label: 'Code-Review',
+          rules: {
+            'global:Project-Owners': {
+              action: PermissionAction.ALLOW,
+              force: false,
+              min: -2,
+              max: 2,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: PermissionAction.ALLOW,
+              force: false,
+              min: -2,
+              max: 2,
+            },
+          },
+        },
+      };
+
+      const expectedLabelValues = [
+        {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'},
+      ];
+
+      const expectedLabel = {
+        name: 'Code-Review',
+        values: expectedLabelValues,
+      };
+
+      element.permission = permission;
+      element.labels = labels;
+      await element.updateComplete;
+
+      assert.deepEqual(
+        element.computeLabelValues(labels['Code-Review'].values),
+        expectedLabelValues
+      );
+
+      assert.deepEqual(element.computeLabel(), expectedLabel);
+
+      permission = {
+        id: 'label-reviewDB' as GitRef,
+        value: {
+          label: 'reviewDB',
+          rules: {
+            'global:Project-Owners': {
+              action: PermissionAction.ALLOW,
+              force: false,
+              min: 0,
+              max: 0,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: PermissionAction.ALLOW,
+              force: false,
+              min: 0,
+              max: 0,
+            },
+          },
+        },
+      };
+
+      element.permission = permission;
+      await element.updateComplete;
+
+      assert.isNotOk(element.computeLabel());
+    });
+
+    test('computeSectionClass', async () => {
+      let deleted = true;
+      let editing = false;
+      assert.equal(element.computeSectionClass(editing, deleted), 'deleted');
+
+      deleted = false;
+      assert.equal(element.computeSectionClass(editing, deleted), '');
+
+      editing = true;
+      assert.equal(element.computeSectionClass(editing, deleted), 'editing');
+
+      deleted = true;
+      assert.equal(
+        element.computeSectionClass(editing, deleted),
+        'editing deleted'
+      );
+    });
+
+    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 GitRef),
+        'test group' as GroupName
+      );
+      assert.equal(
+        element.computeGroupName(groups, 'bcd234' as GitRef),
+        'bcd234' as GroupName
+      );
+    });
+
+    test('computeGroupsWithRules', async () => {
+      const rules = [
+        {
+          id: '4c97682e6ce6b7247f3381b6f1789356666de7f' as GitRef,
+          value: {action: PermissionAction.ALLOW, force: false},
+        },
+        {
+          id: 'global:Project-Owners' as GitRef,
+          value: {action: PermissionAction.ALLOW, force: false},
+        },
+      ];
+      const groupsWithRules = {
+        '4c97682e6ce6b7247f3381b6f1789356666de7f': true,
+        'global:Project-Owners': true,
+      };
+      assert.deepEqual(element.computeGroupsWithRules(rules), groupsWithRules);
+    });
+
+    test('getGroupSuggestions without existing rules', async () => {
+      element.groupsWithRules = {};
+      await element.updateComplete;
+
+      const groups = await element.getGroupSuggestions();
+      assert.deepEqual(groups, [
+        {
+          name: 'Administrators',
+          value: '4c97682e6ce61b7247f3381b6f1789356666de7f',
+        },
+        {
+          name: 'Anonymous Users',
+          value: 'global%3AAnonymous-Users',
+        },
+      ]);
+    });
+
+    test('getGroupSuggestions with existing rules filters them', async () => {
+      element.groupsWithRules = {
+        '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
+      };
+      await element.updateComplete;
+
+      const groups = await element.getGroupSuggestions();
+      assert.deepEqual(groups, [
+        {
+          name: 'Anonymous Users',
+          value: 'global%3AAnonymous-Users',
+        },
+      ]);
+    });
+
+    test('handleRemovePermission', async () => {
+      element.editing = true;
+      element.permission = {id: 'test' as GitRef, value: {rules: {}}};
+      element.handleRemovePermission();
+      await element.updateComplete;
+
+      assert.isTrue(element.deleted);
+      assert.isTrue(element.permission.value.deleted);
+
+      element.editing = false;
+      await element.updateComplete;
+      assert.isFalse(element.deleted);
+      assert.isNotOk(element.permission.value.deleted);
+    });
+
+    test('handleUndoRemove', async () => {
+      element.permission = {
+        id: 'test' as GitRef,
+        value: {deleted: true, rules: {}},
+      };
+      element.handleUndoRemove();
+      await element.updateComplete;
+
+      assert.isFalse(element.deleted);
+      assert.isNotOk(element.permission.value.deleted);
+    });
+
+    test('computeHasRange', async () => {
+      assert.isTrue(element.computeHasRange('Query Limit'));
+
+      assert.isTrue(element.computeHasRange('Batch Changes Limit'));
+
+      assert.isFalse(element.computeHasRange('test'));
+    });
+  });
+
+  suite('interactions', () => {
+    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 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.permission = {
+        id: 'label-Code-Review' as GitRef,
+        value: {
+          label: 'Code-Review',
+          rules: {
+            'global:Project-Owners': {
+              action: PermissionAction.ALLOW,
+              force: false,
+              min: -2,
+              max: 2,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: PermissionAction.ALLOW,
+              force: false,
+              min: -2,
+              max: 2,
+            },
+          },
+        },
+      };
+      element.setupValues();
+      await element.updateComplete;
+      flush();
+    });
+
+    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 = {
+        detail: {
+          value: 'ldap:CN=test+te.st',
+        },
+      } as CustomEvent<AutocompleteCommitEventDetail>;
+      element.editing = true;
+      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.deepEqual(element.permission!.value.rules['ldap:CN=test te.st'], {
+        action: PermissionAction.ALLOW,
+        min: -2,
+        max: 2,
+        added: true,
+      });
+      assert.equal(
+        queryAndAssert<GrAutocomplete>(element, '#groupAutocomplete').text,
+        ''
+      );
+      // New rule should be removed if cancel from editing.
+      element.editing = false;
+      await element.updateComplete;
+      assert.equal(element.rules!.length, 2);
+      assert.equal(Object.keys(element.permission!.value.rules).length, 2);
+    });
+
+    test('removing an added rule', async () => {
+      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);
+      queryAndAssert<GrRuleEditor>(element, 'gr-rule-editor').dispatchEvent(
+        new CustomEvent('added-rule-removed', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+      assert.equal(element.rules!.length, 1);
+    });
+
+    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', 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);
+
+      assert.isFalse(
+        queryAndAssert(element, '#permission').classList.contains('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);
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#undoRemoveBtn'));
+      await element.updateComplete;
+      assert.isFalse(
+        queryAndAssert(element, '#permission').classList.contains('deleted')
+      );
+      assert.isFalse(element.deleted);
+      assert.isFalse(removeStub.called);
+    });
+
+    test('modify a permission', async () => {
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*' as GitRef;
+      await element.updateComplete;
+
+      assert.isFalse(element.originalExclusiveValue);
+      assert.isNotOk(element.permission!.value.modified);
+      MockInteractions.tap(queryAndAssert(element, '#exclusiveToggle'));
+      await element.updateComplete;
+      assert.isTrue(element.permission!.value.exclusive);
+      assert.isTrue(element.permission!.value.modified);
+      assert.isFalse(element.originalExclusiveValue);
+      element.editing = false;
+      await element.updateComplete;
+      assert.isFalse(element.permission!.value.exclusive);
+    });
+
+    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);
+      MockInteractions.tap(queryAndAssert(element, '#exclusiveToggle'));
+      await element.updateComplete;
+      assert.isTrue(element.permission.value.modified);
+      assert.isTrue(modifiedHandler.called);
+    });
+
+    test('Exclusive hidden for owner permission', async () => {
+      queryAndAssert(element, '#exclusiveToggle');
+
+      element.permission!.id = 'owner' as GitRef;
+      element.requestUpdate();
+      await element.updateComplete;
+
+      assert.notOk(query(element, '#exclusiveToggle'));
+    });
+
+    test('Exclusive hidden for any global permissions', async () => {
+      queryAndAssert(element, '#exclusiveToggle');
+
+      element.section = 'GLOBAL_CAPABILITIES' as GitRef;
+      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-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index 9f51688..3393596 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -14,80 +14,160 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
+
 import '../../shared/gr-list-view/gr-list-view';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-plugin-list_html';
-import {customElement, property} from '@polymer/decorators';
 import {PluginInfo} from '../../../types/common';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
-import {ListViewParams} from '../../gr-app-types';
+import {AppElementAdminParams} from '../../gr-app-types';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
 
-interface PluginInfoWithName extends PluginInfo {
+// Exported for tests
+export interface PluginInfoWithName extends PluginInfo {
   name: string;
 }
 
 @customElement('gr-plugin-list')
-export class GrPluginList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrPluginList extends LitElement {
+  readonly path = '/admin/plugins';
 
   /**
    * URL params passed from the router.
    */
-  @property({type: Object, observer: '_paramsChanged'})
-  params?: ListViewParams;
+  @property({type: Object})
+  params?: AppElementAdminParams;
 
   /**
    * Offset of currently visible query results.
    */
-  @property({type: Number})
-  _offset = 0;
+  @state() private offset = 0;
 
-  @property({type: String})
-  readonly _path = '/admin/plugins';
+  // private but used in test
+  @state() plugins?: PluginInfoWithName[];
 
-  @property({type: Array})
-  _plugins?: PluginInfoWithName[];
+  @state() private pluginsPerPage = 25;
 
-  /**
-   * Because  we request one more than the pluginsPerPage, _shownPlugins
-   * maybe one less than _plugins.
-   **/
-  @property({type: Array, computed: 'computeShownItems(_plugins)'})
-  _shownPlugins?: PluginInfoWithName[];
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: Number})
-  _pluginsPerPage = 25;
+  @state() private filter = '';
 
-  @property({type: Boolean})
-  _loading = true;
-
-  @property({type: String})
-  _filter = '';
-
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
     fireTitleChange(this, 'Plugins');
   }
 
-  _paramsChanged(params: ListViewParams) {
-    this._loading = true;
-    this._filter = params?.filter ?? '';
-    this._offset = Number(params?.offset ?? 0);
-
-    return this._getPlugins(this._filter, this._pluginsPerPage, this._offset);
+  static override get styles() {
+    return [
+      tableStyles,
+      sharedStyles,
+      css`
+        .placeholder {
+          color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
   }
 
-  _getPlugins(filter: string, pluginsPerPage: number, offset?: number) {
+  override render() {
+    return html`
+      <gr-list-view
+        .filter=${this.filter}
+        .itemsPerPage=${this.pluginsPerPage}
+        .items=${this.plugins}
+        .loading=${this.loading}
+        .offset=${this.offset}
+        .path=${this.path}
+      >
+        <table id="list" class="genericList">
+          <tbody>
+            <tr class="headerRow">
+              <th class="name topHeader">Plugin Name</th>
+              <th class="version topHeader">Version</th>
+              <th class="apiVersion topHeader">API Version</th>
+              <th class="status topHeader">Status</th>
+            </tr>
+            ${this.renderLoading()}
+          </tbody>
+          ${this.renderPluginListsTable()}
+        </table>
+      </gr-list-view>
+    `;
+  }
+
+  private renderLoading() {
+    if (!this.loading) return;
+
+    return html`
+      <tr id="loading" class="loadingMsg loading">
+        <td>Loading...</td>
+      </tr>
+    `;
+  }
+
+  private renderPluginListsTable() {
+    if (this.loading) return;
+
+    return html`
+      <tbody>
+        ${this.plugins
+          ?.slice(0, SHOWN_ITEMS_COUNT)
+          .map(plugin => this.renderPluginList(plugin))}
+      </tbody>
+    `;
+  }
+
+  private renderPluginList(plugin: PluginInfoWithName) {
+    return html`
+      <tr class="table">
+        <td class="name">
+          ${plugin.index_url
+            ? html`<a href=${this.computePluginUrl(plugin.index_url)}
+                >${plugin.id}</a
+              >`
+            : plugin.id}
+        </td>
+        <td class="version">
+          ${plugin.version
+            ? plugin.version
+            : html`<span class="placeholder">--</span>`}
+        </td>
+        <td class="apiVersion">
+          ${plugin.api_version
+            ? plugin.api_version
+            : html`<span class="placeholder">--</span>`}
+        </td>
+        <td class="status">
+          ${plugin.disabled === true ? 'Disabled' : 'Enabled'}
+        </td>
+      </tr>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this.paramsChanged();
+    }
+  }
+
+  // private but used in test
+  paramsChanged() {
+    this.loading = true;
+    this.filter = this.params?.filter ?? '';
+    this.offset = Number(this.params?.offset ?? 0);
+
+    return this.getPlugins(this.filter, this.pluginsPerPage, this.offset);
+  }
+
+  private getPlugins(filter: string, pluginsPerPage: number, offset?: number) {
     const errFn: ErrorCallback = response => {
       firePageError(response);
     };
@@ -95,31 +175,21 @@
       .getPlugins(filter, pluginsPerPage, offset, errFn)
       .then(plugins => {
         if (!plugins) {
-          this._plugins = [];
+          this.plugins = [];
           return;
         }
-        this._plugins = Object.keys(plugins).map(key => {
+        this.plugins = Object.keys(plugins).map(key => {
           return {...plugins[key], name: key};
         });
-        this._loading = false;
+      })
+      .finally(() => {
+        this.loading = false;
       });
   }
 
-  _status(item: PluginInfo) {
-    return item.disabled === true ? 'Disabled' : 'Enabled';
-  }
-
-  _computePluginUrl(id: string) {
+  private computePluginUrl(id: string) {
     return getBaseUrl() + '/' + encodeURL(id, true);
   }
-
-  computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-
-  computeShownItems(plugins: PluginInfoWithName[]) {
-    return plugins.slice(0, SHOWN_ITEMS_COUNT);
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
deleted file mode 100644
index eeca478..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
+++ /dev/null
@@ -1,81 +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-table-styles">
-    .placeholder {
-      color: var(--deemphasized-text-color);
-    }
-  </style>
-  <gr-list-view
-    filter="[[_filter]]"
-    items-per-page="[[_pluginsPerPage]]"
-    items="[[_plugins]]"
-    loading="[[_loading]]"
-    offset="[[_offset]]"
-    path="[[_path]]"
-  >
-    <table id="list" class="genericList">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Plugin Name</th>
-          <th class="version topHeader">Version</th>
-          <th class="apiVersion topHeader">API Version</th>
-          <th class="status topHeader">Status</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_shownPlugins]]">
-          <tr class="table">
-            <td class="name">
-              <template is="dom-if" if="[[item.index_url]]">
-                <a href$="[[_computePluginUrl(item.index_url)]]">[[item.id]]</a>
-              </template>
-              <template is="dom-if" if="[[!item.index_url]]">
-                [[item.id]]
-              </template>
-            </td>
-            <td class="version">
-              <template is="dom-if" if="[[item.version]]">
-                [[item.version]]
-              </template>
-              <template is="dom-if" if="[[!item.version]]">
-                <span class="placeholder">--</span>
-              </template>
-            </td>
-            <td class="apiVersion">
-              <template is="dom-if" if="[[item.api_version]]">
-                [[item.api_version]]
-              </template>
-              <template is="dom-if" if="[[!item.api_version]]">
-                <span class="placeholder">--</span>
-              </template>
-            </td>
-            <td class="status">[[_status(item)]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </gr-list-view>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
deleted file mode 100644
index 62c89b2..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
+++ /dev/null
@@ -1,171 +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-list.js';
-import 'lodash/lodash.js';
-import {
-  addListenerForTest,
-  mockPromise,
-  stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-plugin-list');
-
-let counter;
-const pluginGenerator = () => {
-  const plugin = {
-    id: `test${++counter}`,
-    disabled: false,
-  };
-
-  if (counter !== 2) {
-    plugin.index_url = `plugins/test${counter}/`;
-  }
-  if (counter !== 3) {
-    plugin.version = `version-${counter}`;
-  }
-  if (counter !== 4) {
-    plugin.api_version = `api-version-${counter}`;
-  }
-  return plugin;
-};
-
-suite('gr-plugin-list tests', () => {
-  let element;
-  let plugins;
-
-  let value;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    counter = 0;
-  });
-
-  suite('list with plugins', async () => {
-    setup(async () => {
-      plugins = _.times(26, pluginGenerator);
-      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
-      await element._paramsChanged(value);
-      await flush();
-    });
-
-    test('plugin in the list is formatted correctly', async () => {
-      await flush();
-      assert.equal(element._plugins[4].id, 'test5');
-      assert.equal(element._plugins[4].index_url, 'plugins/test5/');
-      assert.equal(element._plugins[4].version, 'version-5');
-      assert.equal(element._plugins[4].api_version, 'api-version-5');
-      assert.equal(element._plugins[4].disabled, false);
-    });
-
-    test('with and without urls', async () => {
-      await flush();
-      const names = element.root.querySelectorAll('.name');
-      assert.isOk(names[1].querySelector('a'));
-      assert.equal(names[1].querySelector('a').innerText, 'test1');
-      assert.isNotOk(names[2].querySelector('a'));
-      assert.equal(names[2].innerText, 'test2');
-    });
-
-    test('versions', async () => {
-      await flush();
-      const versions = element.root.querySelectorAll('.version');
-      assert.equal(versions[2].innerText, 'version-2');
-      assert.equal(versions[3].innerText, '--');
-    });
-
-    test('api versions', async () => {
-      await flush();
-      const apiVersions = element.root.querySelectorAll(
-          '.apiVersion');
-      assert.equal(apiVersions[3].innerText, 'api-version-3');
-      assert.equal(apiVersions[4].innerText, '--');
-    });
-
-    test('_shownPlugins', () => {
-      assert.equal(element._shownPlugins.length, 25);
-    });
-  });
-
-  suite('list with less then 26 plugins', () => {
-    setup(async () => {
-      plugins = _.times(25, pluginGenerator);
-      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
-      await element._paramsChanged(value);
-      await flush();
-    });
-
-    test('_shownPlugins', () => {
-      assert.equal(element._shownPlugins.length, 25);
-    });
-  });
-
-  suite('filter', () => {
-    test('_paramsChanged', async () => {
-      const getPluginsStub = stubRestApi('getPlugins');
-      getPluginsStub.returns(Promise.resolve(plugins));
-      const value = {
-        filter: 'test',
-        offset: 25,
-      };
-      await element._paramsChanged(value);
-      assert.equal(getPluginsStub.lastCall.args[0], 'test');
-      assert.equal(getPluginsStub.lastCall.args[1], 25);
-      assert.equal(getPluginsStub.lastCall.args[2], 25);
-    });
-  });
-
-  suite('loading', () => {
-    test('correct contents are displayed', async () => {
-      assert.isTrue(element._loading);
-      assert.equal(element.computeLoadingClass(element._loading), 'loading');
-      assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-      element._loading = false;
-      element._plugins = _.times(25, pluginGenerator);
-
-      await flush();
-      assert.equal(element.computeLoadingClass(element._loading), '');
-      assert.equal(getComputedStyle(element.$.loading).display, 'none');
-    });
-  });
-
-  suite('404', () => {
-    test('fires page-error', async () => {
-      const response = {status: 404};
-      stubRestApi('getPlugins').callsFake(
-          (filter, pluginsPerPage, opt_offset, errFn) => {
-            errFn(response);
-            return Promise.resolve(undefined);
-          });
-
-      const promise = mockPromise();
-      addListenerForTest(document, 'page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        promise.resolve();
-      });
-
-      const value = {
-        filter: 'test',
-        offset: 25,
-      };
-      await element._paramsChanged(value);
-      await promise;
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
new file mode 100644
index 0000000..ca77a6d
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
@@ -0,0 +1,200 @@
+/**
+ * @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-list';
+import {GrPluginList, PluginInfoWithName} from './gr-plugin-list';
+import {
+  addListenerForTest,
+  mockPromise,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {PluginInfo} from '../../../types/common';
+import {AppElementAdminParams} from '../../gr-app-types';
+import {GerritView} from '../../../services/router/router-model';
+import {PageErrorEvent} from '../../../types/events';
+import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+
+const basicFixture = fixtureFromElement('gr-plugin-list');
+
+function pluginGenerator(counter: number) {
+  const plugin: PluginInfo = {
+    id: `test${counter}`,
+    version: `version-${counter}`,
+    disabled: false,
+  };
+
+  if (counter !== 2) {
+    plugin.index_url = `plugins/test${counter}/`;
+  }
+  if (counter !== 4) {
+    plugin.api_version = `api-version-${counter}`;
+  }
+  return plugin;
+}
+
+function createPluginList(n: number) {
+  const plugins = [];
+  for (let i = 0; i < n; ++i) {
+    const plugin = pluginGenerator(i) as PluginInfoWithName;
+    plugin.name = `test${i}`;
+    plugins.push(plugin);
+  }
+  return plugins;
+}
+
+function createPluginObjectList(n: number) {
+  const plugins: {[pluginName: string]: PluginInfo} | undefined = {};
+  for (let i = 0; i < n; ++i) {
+    plugins[`test${i}`] = pluginGenerator(i);
+  }
+  return plugins;
+}
+
+suite('gr-plugin-list tests', () => {
+  let element: GrPluginList;
+  let plugins: {[pluginName: string]: PluginInfo} | undefined;
+
+  const value: AppElementAdminParams = {view: GerritView.ADMIN, adminView: ''};
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  suite('list with plugins', async () => {
+    setup(async () => {
+      plugins = createPluginObjectList(26);
+      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
+      element.params = value;
+      await element.paramsChanged();
+      await element.updateComplete;
+    });
+
+    test('plugin in the list is formatted correctly', async () => {
+      await element.updateComplete;
+      assert.equal(element.plugins![5].id, 'test5');
+      assert.equal(element.plugins![5].index_url, 'plugins/test5/');
+      assert.equal(element.plugins![5].version, 'version-5');
+      assert.equal(element.plugins![5].api_version, 'api-version-5');
+      assert.equal(element.plugins![5].disabled, false);
+    });
+
+    test('with and without urls', async () => {
+      await element.updateComplete;
+      const names = queryAll<HTMLTableElement>(element, '.name');
+      assert.isOk(queryAndAssert<HTMLAnchorElement>(names[2], 'a'));
+      assert.equal(
+        queryAndAssert<HTMLAnchorElement>(names[2], 'a').innerText,
+        'test1'
+      );
+      assert.isNotOk(query(names[3], 'a'));
+      assert.equal(names[3].innerText, 'test2');
+    });
+
+    test('versions', async () => {
+      await element.updateComplete;
+      const versions = queryAll<HTMLTableElement>(element, '.version');
+      assert.equal(versions[3].innerText, 'version-2');
+    });
+
+    test('api versions', async () => {
+      await element.updateComplete;
+      const apiVersions = queryAll<HTMLTableElement>(element, '.apiVersion');
+      assert.equal(apiVersions[4].innerText, 'api-version-3');
+      assert.equal(apiVersions[5].innerText, '--');
+    });
+
+    test('plugins', () => {
+      assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+    });
+  });
+
+  suite('list with less then 26 plugins', () => {
+    setup(async () => {
+      plugins = createPluginObjectList(25);
+      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
+      element.params = value;
+      await element.paramsChanged();
+      await element.updateComplete;
+    });
+
+    test('plugins', () => {
+      assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+    });
+  });
+
+  suite('filter', () => {
+    test('paramsChanged', async () => {
+      const getPluginsStub = stubRestApi('getPlugins');
+      getPluginsStub.returns(Promise.resolve(plugins));
+      value.filter = 'test';
+      value.offset = 25;
+      element.params = value;
+      await element.paramsChanged();
+      assert.equal(getPluginsStub.lastCall.args[0], 'test');
+      assert.equal(getPluginsStub.lastCall.args[1], 25);
+      assert.equal(getPluginsStub.lastCall.args[2], 25);
+    });
+  });
+
+  suite('loading', () => {
+    test('correct contents are displayed', async () => {
+      assert.isTrue(element.loading);
+      assert.equal(
+        getComputedStyle(queryAndAssert<HTMLTableElement>(element, '#loading'))
+          .display,
+        'block'
+      );
+
+      element.loading = false;
+      element.plugins = createPluginList(25);
+
+      await element.updateComplete;
+      assert.isNotOk(query<HTMLTableElement>(element, '#loading'));
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', async () => {
+      const response = {status: 404} as Response;
+      stubRestApi('getPlugins').callsFake(
+        (_filter, _pluginsPerPage, _opt_offset, errFn) => {
+          if (errFn !== undefined) {
+            errFn(response);
+          }
+          return Promise.resolve(undefined);
+        }
+      );
+
+      const promise = mockPromise();
+      addListenerForTest(document, 'page-error', e => {
+        assert.deepEqual((e as PageErrorEvent).detail.response, response);
+        promise.resolve();
+      });
+
+      value.filter = 'test';
+      value.offset = 25;
+      element.params = value;
+      await element.paramsChanged();
+      await promise;
+    });
+  });
+});
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 56e981a..ad15ab6 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,
@@ -49,107 +42,295 @@
   PrimitiveValue,
 } from './gr-repo-access-interfaces';
 import {firePageError, fireAlert} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+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[];
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in the tests
+  originalInheritsFrom?: ProjectInfo;
 
-  private originalInheritsFrom?: ProjectInfo;
+  private readonly query: AutocompleteQuery;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this._query = () => this._getInheritFromSuggestions();
+    this.query = () => this.getInheritFromSuggestions();
     this.addEventListener('access-modified', () =>
       this._handleAccessModified()
     );
   }
 
-  _handleAccessModified() {
-    this._modified = true;
+  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;
+        }
+      `,
+    ];
   }
 
-  _repoChanged(repo: RepoName) {
-    this._loading = true;
+  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;
+  }
+
+  _repoChanged(repo?: RepoName) {
+    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 => {
@@ -161,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
@@ -208,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) {
@@ -242,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 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]) {
@@ -326,7 +488,7 @@
     return addRemoveObj;
   }
 
-  _updateAddObj(
+  private updateAddObj(
     addRemoveObj: {add: PropertyTreeNode},
     path: string[],
     value: PropertyTreeNode | PrimitiveValue
@@ -350,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];
@@ -360,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;
@@ -380,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));
       }
     }
   }
@@ -417,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;
@@ -431,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 =
@@ -441,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
     );
 
@@ -456,30 +623,25 @@
     return addRemoveObj;
   }
 
-  _handleCreateSection() {
-    if (!this._local) {
-      return;
-    }
+  private async 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 &&
@@ -499,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;
     }
@@ -515,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;
     }
@@ -543,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.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
deleted file mode 100644
index 1ccfd5e..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
+++ /dev/null
@@ -1,1287 +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-repo-access.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {toSortedPermissionsArray} from '../../../utils/access-util.js';
-import {
-  addListenerForTest,
-  mockPromise,
-  stubRestApi,
-} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-repo-access');
-
-suite('gr-repo-access tests', () => {
-  let element;
-
-  let repoStub;
-
-  const accessRes = {
-    local: {
-      'refs/*': {
-        permissions: {
-          owner: {
-            rules: {
-              234: {action: 'ALLOW'},
-              123: {action: 'DENY'},
-            },
-          },
-          read: {
-            rules: {
-              234: {action: 'ALLOW'},
-            },
-          },
-        },
-      },
-    },
-    groups: {
-      Administrators: {
-        name: 'Administrators',
-      },
-      Maintainers: {
-        name: 'Maintainers',
-      },
-    },
-    config_web_links: [{
-      name: 'gitiles',
-      target: '_blank',
-      url: 'https://my/site/+log/123/project.config',
-    }],
-    can_upload: true,
-  };
-  const accessRes2 = {
-    local: {
-      GLOBAL_CAPABILITIES: {
-        permissions: {
-          accessDatabase: {
-            rules: {
-              group1: {
-                action: 'ALLOW',
-              },
-            },
-          },
-        },
-      },
-    },
-  };
-  const repoRes = {
-    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',
-        },
-      },
-    },
-  };
-  const capabilitiesRes = {
-    accessDatabase: {
-      id: 'accessDatabase',
-      name: 'Access Database',
-    },
-    createAccount: {
-      id: 'createAccount',
-      name: 'Create Account',
-    },
-  };
-  setup(async () => {
-    element = basicFixture.instantiate();
-    stubRestApi('getAccount').returns(Promise.resolve(null));
-    repoStub = stubRestApi('getRepo').returns(Promise.resolve(repoRes));
-    element._loading = false;
-    element._ownerOf = [];
-    element._canUpload = false;
-    await flush();
-  });
-
-  test('_repoChanged called when repo name changes', async () => {
-    sinon.stub(element, '_repoChanged');
-    element.repo = 'New Repo';
-    await flush();
-    assert.isTrue(element._repoChanged.called);
-  });
-
-  test('_repoChanged', async () => {
-    const accessStub = stubRestApi(
-        'getRepoAccessRights');
-
-    accessStub.withArgs('New Repo').returns(
-        Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-    accessStub.withArgs('Another New Repo')
-        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-    const capabilitiesStub = stubRestApi(
-        'getCapabilities');
-    capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
-
-    await element._repoChanged('New Repo');
-    assert.isTrue(accessStub.called);
-    assert.isTrue(capabilitiesStub.called);
-    assert.isTrue(repoStub.called);
-    assert.isNotOk(element._inheritsFrom);
-    assert.deepEqual(element._local, accessRes.local);
-    assert.deepEqual(element._sections,
-        toSortedPermissionsArray(accessRes.local));
-    assert.deepEqual(element._labels, repoRes.labels);
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.weblinks')).display,
-    'block');
-
-    await element._repoChanged('Another New Repo');
-    assert.deepEqual(element._sections,
-        toSortedPermissionsArray(accessRes2.local));
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.weblinks')).display,
-    'none');
-  });
-
-  test('_repoChanged when repo changes to undefined returns', async () => {
-    const capabilitiesRes = {
-      accessDatabase: {
-        id: 'accessDatabase',
-        name: 'Access Database',
-      },
-    };
-    const accessStub = stubRestApi('getRepoAccessRights')
-        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-    const capabilitiesStub = stubRestApi(
-        'getCapabilities').returns(Promise.resolve(capabilitiesRes));
-
-    await element._repoChanged();
-    assert.isFalse(accessStub.called);
-    assert.isFalse(capabilitiesStub.called);
-    assert.isFalse(repoStub.called);
-  });
-
-  test('_computeParentHref', () => {
-    const repoName = 'test-repo';
-    assert.equal(element._computeParentHref(repoName),
-        '/admin/repos/test-repo,access');
-  });
-
-  test('_computeMainClass', () => {
-    let ownerOf = ['refs/*'];
-    const editing = true;
-    const canUpload = false;
-    assert.equal(element._computeMainClass(ownerOf, canUpload), 'admin');
-    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
-        'admin editing');
-    ownerOf = [];
-    assert.equal(element._computeMainClass(ownerOf, canUpload), '');
-    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
-        'editing');
-  });
-
-  test('inherit section', async () => {
-    element._local = {};
-    element._ownerOf = [];
-    sinon.stub(element, '_computeParentHref');
-    await flush();
-
-    // 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(element._computeParentHref.called);
-    // When in edit mode, the autocomplete should appear.
-    element._editing = true;
-    // When editing, the autocomplete should still not be shown.
-    assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
-
-    element._editing = false;
-    element._inheritsFrom = {
-      id: '1234',
-      name: 'another-repo',
-    };
-    await flush();
-
-    // When there is a parent project, the link should be displayed.
-    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
-    assert.notEqual(getComputedStyle(element.$.inheritFromName).display,
-        'none');
-    assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
-        'none');
-    assert.isTrue(element._computeParentHref.called);
-    element._editing = true;
-    // When editing, the autocomplete should be shown.
-    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
-    assert.equal(getComputedStyle(element.$.inheritFromName).display, 'none');
-    assert.notEqual(getComputedStyle(element.$.editInheritFromInput).display,
-        'none');
-  });
-
-  test('_handleUpdateInheritFrom', async () => {
-    element._inheritFromFilter = 'foo bar baz';
-    element._handleUpdateInheritFrom({detail: {value: 'abc+123'}});
-    await flush();
-    assert.isOk(element._inheritsFrom);
-    assert.equal(element._inheritsFrom.id, 'abc+123');
-    assert.equal(element._inheritsFrom.name, 'foo bar baz');
-  });
-
-  test('_computeLoadingClass', () => {
-    assert.equal(element._computeLoadingClass(true), 'loading');
-    assert.equal(element._computeLoadingClass(false), '');
-  });
-
-  test('fires page-error', async () => {
-    const response = {status: 404};
-
-    stubRestApi('getRepoAccessRights').callsFake((repoName, errFn) => {
-      errFn(response);
-      return Promise.resolve(undefined);
-    });
-
-    const promise = mockPromise();
-    addListenerForTest(document, 'page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      promise.resolve();
-    });
-
-    element.repo = 'test';
-    await promise;
-  });
-
-  suite('with defined sections', () => {
-    const testEditSaveCancelBtns = async (
-        shouldShowSave,
-        shouldShowSaveReview
-    ) => {
-      // Edit button is visible and Save button is hidden.
-      assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
-      assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
-      assert.equal(element.$.editBtn.innerText, 'EDIT');
-      assert.equal(
-          getComputedStyle(element.$.editInheritFromInput).display,
-          'none'
-      );
-      element._inheritsFrom = {
-        id: 'test-project',
-      };
-      await flush();
-      assert.equal(
-          getComputedStyle(
-              element.shadowRoot.querySelector('#editInheritFromInput')
-          ).display,
-          'none'
-      );
-
-      MockInteractions.tap(element.$.editBtn);
-      await flush();
-
-      // Edit button changes to Cancel button, and Save button is visible but
-      // disabled.
-      assert.equal(element.$.editBtn.innerText, 'CANCEL');
-      if (shouldShowSaveReview) {
-        assert.notEqual(
-            getComputedStyle(element.$.saveReviewBtn).display,
-            'none'
-        );
-        assert.isTrue(element.$.saveReviewBtn.disabled);
-      }
-      if (shouldShowSave) {
-        assert.notEqual(getComputedStyle(element.$.saveBtn).display, 'none');
-        assert.isTrue(element.$.saveBtn.disabled);
-      }
-      assert.notEqual(
-          getComputedStyle(
-              element.shadowRoot.querySelector('#editInheritFromInput')
-          ).display,
-          'none'
-      );
-
-      // Save button should be enabled after access is modified
-      element.dispatchEvent(
-          new CustomEvent('access-modified', {
-            composed: true,
-            bubbles: true,
-          })
-      );
-      if (shouldShowSaveReview) {
-        assert.isFalse(element.$.saveReviewBtn.disabled);
-      }
-      if (shouldShowSave) {
-        assert.isFalse(element.$.saveBtn.disabled);
-      }
-    };
-
-    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();
-    });
-
-    test('removing an added section', async () => {
-      element.editing = true;
-      await flush();
-      assert.equal(element._sections.length, 1);
-      element.shadowRoot
-          .querySelector('gr-access-section').dispatchEvent(
-              new CustomEvent('added-section-removed', {
-                composed: true, bubbles: true,
-              }));
-      await flush();
-      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 with upload privilege',
-        async () => {
-          element._canUpload = true;
-          await flush();
-          testEditSaveCancelBtns(false, true);
-        });
-
-    test('button visibility for ref owner', async () => {
-      element._ownerOf = ['refs/for/*'];
-      await flush();
-      testEditSaveCancelBtns(true, false);
-    });
-
-    test('button visibility for ref owner and upload', async () => {
-      element._ownerOf = ['refs/for/*'];
-      element._canUpload = true;
-      await flush();
-      testEditSaveCancelBtns(true, false);
-    });
-
-    test('_handleAccessModified called with event fired', async () => {
-      sinon.spy(element, '_handleAccessModified');
-      element.dispatchEvent(
-          new CustomEvent('access-modified', {
-            composed: true, bubbles: true,
-          }));
-      await flush();
-      assert.isTrue(element._handleAccessModified.called);
-    });
-
-    test('_handleAccessModified called when parent changes', async () => {
-      element._inheritsFrom = {
-        id: 'test-project',
-      };
-      await flush();
-      element.shadowRoot.querySelector('#editInheritFromInput').dispatchEvent(
-          new CustomEvent('commit', {
-            detail: {},
-            composed: true, bubbles: true,
-          }));
-      sinon.spy(element, '_handleAccessModified');
-      element.dispatchEvent(
-          new CustomEvent('access-modified', {
-            detail: {},
-            composed: true, bubbles: true,
-          }));
-      await flush();
-      assert.isTrue(element._handleAccessModified.called);
-    });
-
-    test('_handleSaveForReview', async () => {
-      const saveStub =
-          stubRestApi('setRepoAccessRightsForReview');
-      sinon.stub(element, '_computeAddAndRemove').returns({
-        add: {},
-        remove: {},
-      });
-      element._handleSaveForReview();
-      await flush();
-      assert.isFalse(saveStub.called);
-    });
-
-    test('_recursivelyRemoveDeleted', () => {
-      const obj = {
-        'refs/*': {
-          permissions: {
-            owner: {
-              rules: {
-                234: {action: 'ALLOW'},
-                123: {action: 'DENY', deleted: true},
-              },
-            },
-            read: {
-              deleted: true,
-              rules: {
-                234: {action: 'ALLOW'},
-              },
-            },
-          },
-        },
-      };
-      const expectedResult = {
-        'refs/*': {
-          permissions: {
-            owner: {
-              rules: {
-                234: {action: 'ALLOW'},
-              },
-            },
-          },
-        },
-      };
-      element._recursivelyRemoveDeleted(obj);
-      assert.deepEqual(obj, expectedResult);
-    });
-
-    test('_recursivelyUpdateAddRemoveObj on new added section', () => {
-      const obj = {
-        'refs/for/*': {
-          permissions: {
-            'label-Code-Review': {
-              rules: {
-                e798fed07afbc9173a587f876ef8760c78d240c1: {
-                  min: -2,
-                  max: 2,
-                  action: 'ALLOW',
-                  added: true,
-                },
-              },
-              added: true,
-              label: 'Code-Review',
-            },
-            'labelAs-Code-Review': {
-              rules: {
-                'ldap:gerritcodereview-eng': {
-                  min: -2,
-                  max: 2,
-                  action: 'ALLOW',
-                  added: true,
-                  deleted: true,
-                },
-              },
-              added: true,
-              label: 'Code-Review',
-            },
-          },
-          added: true,
-        },
-      };
-
-      const expectedResult = {
-        add: {
-          'refs/for/*': {
-            permissions: {
-              'label-Code-Review': {
-                rules: {
-                  e798fed07afbc9173a587f876ef8760c78d240c1: {
-                    min: -2,
-                    max: 2,
-                    action: 'ALLOW',
-                    added: true,
-                  },
-                },
-                added: true,
-                label: 'Code-Review',
-              },
-              'labelAs-Code-Review': {
-                rules: {},
-                added: true,
-                label: 'Code-Review',
-              },
-            },
-            added: true,
-          },
-        },
-        remove: {},
-      };
-      const updateObj = {add: {}, remove: {}};
-      element._recursivelyUpdateAddRemoveObj(obj, updateObj);
-      assert.deepEqual(updateObj, expectedResult);
-    });
-
-    test('_handleSaveForReview with no changes', () => {
-      assert.deepEqual(element._computeAddAndRemove(), {add: {}, remove: {}});
-    });
-
-    test('_handleSaveForReview parent change', async () => {
-      element._inheritsFrom = {
-        id: 'test-project',
-      };
-      element._originalInheritsFrom = {
-        id: 'test-project-original',
-      };
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), {
-        parent: 'test-project', add: {}, remove: {},
-      });
-    });
-
-    test('_handleSaveForReview new parent with spaces', async () => {
-      element._inheritsFrom = {id: 'spaces+in+project+name'};
-      element._originalInheritsFrom = {id: 'old-project'};
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), {
-        parent: 'spaces in project name', add: {}, remove: {},
-      });
-    });
-
-    test('_handleSaveForReview rules', async () => {
-      // Delete a rule.
-      element._local['refs/*'].permissions.owner.rules[123].deleted = true;
-      await flush();
-      let expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Undo deleting a rule.
-      delete element._local['refs/*'].permissions.owner.rules[123].deleted;
-
-      // Modify a rule.
-      element._local['refs/*'].permissions.owner.rules[123].modified = true;
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {action: 'DENY', modified: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    test('_computeAddAndRemove permissions', async () => {
-      // Add a new rule to a permission.
-      let expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                  },
-                },
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-
-      element.shadowRoot
-          .querySelector('gr-access-section').shadowRoot
-          .querySelector('gr-permission')
-          ._handleAddRuleItem(
-              {detail: {value: 'Maintainers'}});
-
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Remove the added rule.
-      delete element._local['refs/*'].permissions.owner.rules.Maintainers;
-
-      // Delete a permission.
-      element._local['refs/*'].permissions.owner.deleted = true;
-      await flush();
-      expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Undo delete permission.
-      delete element._local['refs/*'].permissions.owner.deleted;
-
-      // Modify a permission.
-      element._local['refs/*'].permissions.owner.modified = true;
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                  123: {action: 'DENY'},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    test('_computeAddAndRemove sections', async () => {
-      // Add a new permission to a section
-      let expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {},
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-      element.shadowRoot
-          .querySelector('gr-access-section')._handleAddPermission();
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add a new rule to the new permission.
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    min: -2,
-                    max: 2,
-                    action: 'ALLOW',
-                    added: true,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-      const newPermission =
-          dom(element.shadowRoot
-              .querySelector('gr-access-section').root).querySelectorAll(
-              'gr-permission')[2];
-      newPermission._handleAddRuleItem(
-          {detail: {value: 'Maintainers'}});
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Modify a section reference.
-      element._local['refs/*'].updatedId = 'refs/for/bar';
-      element._local['refs/*'].modified = true;
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/for/bar': {
-            modified: true,
-            updatedId: 'refs/for/bar',
-            permissions: {
-              'owner': {
-                rules: {
-                  234: {action: 'ALLOW'},
-                  123: {action: 'DENY'},
-                },
-              },
-              'read': {
-                rules: {
-                  234: {action: 'ALLOW'},
-                },
-              },
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    min: -2,
-                    max: 2,
-                    action: 'ALLOW',
-                    added: true,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Delete a section.
-      element._local['refs/*'].deleted = true;
-      await flush();
-      expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    test('_computeAddAndRemove new section', async () => {
-      // Add a new permission to a section
-      let expectedInput = {
-        add: {
-          'refs/for/*': {
-            added: true,
-            permissions: {},
-          },
-        },
-        remove: {},
-      };
-      MockInteractions.tap(element.$.addReferenceBtn);
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      expectedInput = {
-        add: {
-          'refs/for/*': {
-            added: true,
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {},
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-      const newSection = dom(element.root)
-          .querySelectorAll('gr-access-section')[1];
-      newSection._handleAddPermission();
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add rule to the new permission.
-      expectedInput = {
-        add: {
-          'refs/for/*': {
-            added: true,
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-
-      newSection.shadowRoot
-          .querySelector('gr-permission')._handleAddRuleItem(
-              {detail: {value: 'Maintainers'}});
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Modify a the reference from the default value.
-      element._local['refs/for/*'].updatedId = 'refs/for/new';
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/for/new': {
-            added: true,
-            updatedId: 'refs/for/new',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    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();
-      let expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-            },
-          },
-        },
-      };
-      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);
-
-      // Also modify a different rule inside of another permission.
-      element._local['refs/*'].permissions.read.modified = true;
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              read: {
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-              read: {rules: {}},
-            },
-          },
-        },
-      };
-      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();
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              read: {
-                exclusive: true,
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-              read: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add a rule to the existing permission;
-      const readPermission =
-          dom(element.shadowRoot
-              .querySelector('gr-access-section').root).querySelectorAll(
-              'gr-permission')[1];
-      readPermission._handleAddRuleItem(
-          {detail: {value: 'Maintainers'}});
-      await flush();
-
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              read: {
-                exclusive: true,
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                  Maintainers: {action: 'ALLOW', added: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-              read: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Change one of the refs
-      element._local['refs/*'].updatedId = 'refs/for/bar';
-      element._local['refs/*'].modified = true;
-      await flush();
-
-      expectedInput = {
-        add: {
-          'refs/for/bar': {
-            modified: true,
-            updatedId: 'refs/for/bar',
-            permissions: {
-              read: {
-                exclusive: true,
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                  Maintainers: {action: 'ALLOW', added: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      element._local['refs/*'].deleted = true;
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add a new section.
-      MockInteractions.tap(element.$.addReferenceBtn);
-      let newSection = dom(element.root)
-          .querySelectorAll('gr-access-section')[1];
-      newSection._handleAddPermission();
-      await flush();
-      newSection.shadowRoot
-          .querySelector('gr-permission')._handleAddRuleItem(
-              {detail: {value: 'Maintainers'}});
-      // Modify a the reference from the default value.
-      element._local['refs/for/*'].updatedId = 'refs/for/new';
-      await flush();
-
-      expectedInput = {
-        add: {
-          'refs/for/new': {
-            added: true,
-            updatedId: 'refs/for/new',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Modify newly added rule inside new ref.
-      element._local['refs/for/*'].permissions['label-Code-Review'].
-          rules['Maintainers'].modified = true;
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/for/new': {
-            added: true,
-            updatedId: 'refs/for/new',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    modified: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add a second new section.
-      MockInteractions.tap(element.$.addReferenceBtn);
-      await flush();
-      newSection = dom(element.root)
-          .querySelectorAll('gr-access-section')[2];
-      newSection._handleAddPermission();
-      await flush();
-      newSection.shadowRoot
-          .querySelector('gr-permission')._handleAddRuleItem(
-              {detail: {value: 'Maintainers'}});
-      // Modify a the reference from the default value.
-      element._local['refs/for/**'].updatedId = 'refs/for/new2';
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/for/new': {
-            added: true,
-            updatedId: 'refs/for/new',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    modified: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-          'refs/for/new2': {
-            added: true,
-            updatedId: 'refs/for/new2',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      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);
-    });
-
-    test('_handleSave', async () => {
-      const repoAccessInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {action: 'DENY', modified: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      stubRestApi('getRepoAccessRights').returns(
-          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-      sinon.stub(GerritNav, 'navigateToChange');
-      let resolver;
-      const saveStub = stubRestApi(
-          'setRepoAccessRights')
-          .returns(new Promise(r => resolver = r));
-
-      element.repo = 'test-repo';
-      sinon.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
-
-      element._modified = true;
-      MockInteractions.tap(element.$.saveBtn);
-      await flush();
-      assert.equal(element.$.saveBtn.hasAttribute('loading'), true);
-      resolver({_number: 1});
-      await flush();
-      assert.isTrue(saveStub.called);
-      assert.isTrue(GerritNav.navigateToChange.notCalled);
-    });
-
-    test('_handleSaveForReview', async () => {
-      const repoAccessInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {action: 'DENY', modified: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      stubRestApi('getRepoAccessRights').returns(
-          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-      sinon.stub(GerritNav, 'navigateToChange');
-      let resolver;
-      const saveForReviewStub = stubRestApi(
-          'setRepoAccessRightsForReview')
-          .returns(new Promise(r => resolver = r));
-
-      element.repo = 'test-repo';
-      sinon.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
-
-      element._modified = true;
-      MockInteractions.tap(element.$.saveReviewBtn);
-      await flush();
-      assert.equal(element.$.saveReviewBtn.hasAttribute('loading'), true);
-      resolver({_number: 1});
-      await flush();
-      assert.isTrue(saveForReviewStub.called);
-      assert.isTrue(GerritNav.navigateToChange
-          .lastCall.calledWithExactly({_number: 1}));
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..caa2d13
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
@@ -0,0 +1,1455 @@
+/**
+ * @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-repo-access';
+import {GrRepoAccess} from './gr-repo-access';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {toSortedPermissionsArray} from '../../../utils/access-util';
+import {
+  addListenerForTest,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  ChangeInfo,
+  GitRef,
+  RepoName,
+  UrlEncodedRepoName,
+} from '../../../types/common';
+import {PermissionAction} from '../../../constants/constants';
+import {PageErrorEvent} from '../../../types/events';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {
+  AutocompleteCommitEvent,
+  GrAutocomplete,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrAccessSection} from '../gr-access-section/gr-access-section';
+import {GrPermission} from '../gr-permission/gr-permission';
+import {createChange} from '../../../test/test-data-generators';
+import {fixture, html} from '@open-wc/testing-helpers';
+
+suite('gr-repo-access tests', () => {
+  let element: GrRepoAccess;
+
+  let repoStub: sinon.SinonStub;
+
+  const accessRes = {
+    local: {
+      'refs/*': {
+        permissions: {
+          owner: {
+            rules: {
+              234: {action: PermissionAction.ALLOW},
+              123: {action: PermissionAction.DENY},
+            },
+          },
+          read: {
+            rules: {
+              234: {action: PermissionAction.ALLOW},
+            },
+          },
+        },
+      },
+    },
+    groups: {
+      Administrators: {
+        name: 'Administrators',
+      },
+      Maintainers: {
+        name: 'Maintainers',
+      },
+    },
+    config_web_links: [
+      {
+        name: 'gitiles',
+        target: '_blank',
+        url: 'https://my/site/+log/123/project.config',
+      },
+    ],
+    can_upload: true,
+  };
+  const accessRes2 = {
+    local: {
+      GLOBAL_CAPABILITIES: {
+        permissions: {
+          accessDatabase: {
+            rules: {
+              group1: {
+                action: PermissionAction.ALLOW,
+              },
+            },
+          },
+        },
+      },
+    },
+  };
+  const repoRes = {
+    id: '' as UrlEncodedRepoName,
+    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,
+      },
+    },
+  };
+  const capabilitiesRes = {
+    accessDatabase: {
+      id: 'accessDatabase',
+      name: 'Access Database',
+    },
+    createAccount: {
+      id: 'createAccount',
+      name: 'Create Account',
+    },
+  };
+  setup(async () => {
+    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 element.updateComplete;
+  });
+
+  test('_repoChanged called when repo name changes', async () => {
+    const repoChangedStub = sinon.stub(element, '_repoChanged');
+    element.repo = 'New Repo' as RepoName;
+    await element.updateComplete;
+    assert.isTrue(repoChangedStub.called);
+  });
+
+  test('_repoChanged', async () => {
+    const accessStub = stubRestApi('getRepoAccessRights');
+
+    accessStub
+      .withArgs('New Repo' as RepoName)
+      .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+    accessStub
+      .withArgs('Another New Repo' as RepoName)
+      .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
+    const capabilitiesStub = stubRestApi('getCapabilities');
+    capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
+
+    await element._repoChanged('New Repo' as RepoName);
+    assert.isTrue(accessStub.called);
+    assert.isTrue(capabilitiesStub.called);
+    assert.isTrue(repoStub.called);
+    assert.isNotOk(element.inheritsFrom);
+    assert.deepEqual(element.local, accessRes.local);
+    assert.deepEqual(
+      element.sections,
+      toSortedPermissionsArray(accessRes.local)
+    );
+    assert.deepEqual(element.labels, repoRes.labels);
+    assert.equal(
+      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '.weblinks'))
+        .display,
+      'block'
+    );
+
+    await element._repoChanged('Another New Repo' as RepoName);
+    assert.deepEqual(
+      element.sections,
+      toSortedPermissionsArray(accessRes2.local)
+    );
+    assert.equal(
+      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '.weblinks'))
+        .display,
+      'none'
+    );
+  });
+
+  test('_repoChanged when repo changes to undefined returns', async () => {
+    const capabilitiesRes = {
+      accessDatabase: {
+        id: 'accessDatabase',
+        name: 'Access Database',
+      },
+    };
+    const accessStub = stubRestApi('getRepoAccessRights').returns(
+      Promise.resolve(JSON.parse(JSON.stringify(accessRes2)))
+    );
+    const capabilitiesStub = stubRestApi('getCapabilities').returns(
+      Promise.resolve(capabilitiesRes)
+    );
+
+    await element._repoChanged();
+    assert.isFalse(accessStub.called);
+    assert.isFalse(capabilitiesStub.called);
+    assert.isFalse(repoStub.called);
+  });
+
+  test('computeParentHref', () => {
+    element.inheritsFrom!.name = 'test-repo' as RepoName;
+    assert.equal(element.computeParentHref(), '/admin/repos/test-repo,access');
+  });
+
+  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 element.updateComplete;
+
+    // Nothing should appear when no inherit from and not in edit mode.
+    assert.equal(
+      getComputedStyle(
+        queryAndAssert<HTMLHeadingElement>(element, '#inheritsFrom')
+      ).display,
+      'none'
+    );
+    // When in edit mode, the autocomplete should appear.
+    element.editing = true;
+    // When editing, the autocomplete should still not be shown.
+    assert.equal(
+      getComputedStyle(
+        queryAndAssert<HTMLHeadingElement>(element, '#inheritsFrom')
+      ).display,
+      'none'
+    );
+
+    element.editing = false;
+    element.inheritsFrom = {
+      id: '1234' as UrlEncodedRepoName,
+      name: 'another-repo' as RepoName,
+    };
+    await element.updateComplete;
+
+    // When there is a parent project, the link should be displayed.
+    assert.notEqual(
+      getComputedStyle(
+        queryAndAssert<HTMLHeadingElement>(element, '#inheritsFrom')
+      ).display,
+      'none'
+    );
+    assert.notEqual(
+      getComputedStyle(
+        queryAndAssert<HTMLAnchorElement>(element, '#inheritFromName')
+      ).display,
+      'none'
+    );
+    assert.equal(
+      getComputedStyle(
+        queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+      ).display,
+      'none'
+    );
+    assert.isTrue(computeParentHrefStub.called);
+    element.editing = true;
+    await element.updateComplete;
+    // When editing, the autocomplete should be shown.
+    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')
+      ).display,
+      'none'
+    );
+  });
+
+  test('handleUpdateInheritFrom', async () => {
+    element.inheritFromFilter = 'foo bar baz' as RepoName;
+    await element.updateComplete;
+    element.handleUpdateInheritFrom({
+      detail: {value: 'abc+123'},
+    } as CustomEvent);
+    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 () => {
+    const response = {status: 404} as Response;
+
+    stubRestApi('getRepoAccessRights').callsFake((_repoName, errFn) => {
+      if (errFn !== undefined) {
+        errFn(response);
+      }
+      return Promise.resolve(undefined);
+    });
+
+    const promise = mockPromise();
+    addListenerForTest(document, 'page-error', e => {
+      assert.deepEqual((e as PageErrorEvent).detail.response, response);
+      promise.resolve();
+    });
+
+    element.repo = 'test' as RepoName;
+    await promise;
+  });
+
+  suite('with defined sections', () => {
+    const testEditSaveCancelBtns = async (
+      shouldShowSave: boolean,
+      shouldShowSaveReview: boolean
+    ) => {
+      // Edit button is visible and Save button is hidden.
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#saveReviewBtn'))
+          .display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#saveBtn')).display,
+        'none'
+      );
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#editBtn')).display,
+        'none'
+      );
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#editBtn').innerText,
+        'EDIT'
+      );
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+        ).display,
+        'none'
+      );
+      element.inheritsFrom = {
+        id: 'test-project' as UrlEncodedRepoName,
+      };
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+        ).display,
+        'none'
+      );
+
+      queryAndAssert<GrButton>(element, '#editBtn').click();
+      await element.updateComplete;
+
+      // Edit button changes to Cancel button, and Save button is visible but
+      // disabled.
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#editBtn').innerText,
+        'CANCEL'
+      );
+      if (shouldShowSaveReview) {
+        assert.notEqual(
+          getComputedStyle(queryAndAssert<GrButton>(element, '#saveReviewBtn'))
+            .display,
+          'none'
+        );
+        assert.isTrue(
+          queryAndAssert<GrButton>(element, '#saveReviewBtn').disabled
+        );
+      }
+      if (shouldShowSave) {
+        assert.notEqual(
+          getComputedStyle(queryAndAssert<GrButton>(element, '#saveBtn'))
+            .display,
+          'none'
+        );
+        assert.isTrue(queryAndAssert<GrButton>(element, '#saveBtn').disabled);
+      }
+      assert.notEqual(
+        getComputedStyle(
+          queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+        ).display,
+        'none'
+      );
+
+      // Save button should be enabled after access is modified
+      element.dispatchEvent(
+        new CustomEvent('access-modified', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      if (shouldShowSaveReview) {
+        assert.isFalse(
+          queryAndAssert<GrButton>(element, '#saveReviewBtn').disabled
+        );
+      }
+      if (shouldShowSave) {
+        assert.isFalse(queryAndAssert<GrButton>(element, '#saveBtn').disabled);
+      }
+    };
+
+    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 element.updateComplete;
+    });
+
+    test('removing an added section', async () => {
+      element.editing = true;
+      await element.updateComplete;
+      assert.equal(element.sections!.length, 1);
+      queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      ).dispatchEvent(
+        new CustomEvent('added-section-removed', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await element.updateComplete;
+      assert.equal(element.sections!.length, 0);
+    });
+
+    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 element.updateComplete;
+      testEditSaveCancelBtns(false, true);
+    });
+
+    test('button visibility for ref owner', async () => {
+      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 element.updateComplete;
+      testEditSaveCancelBtns(true, false);
+    });
+
+    test('_handleAccessModified called with event fired', async () => {
+      const handleAccessModifiedSpy = sinon.spy(
+        element,
+        '_handleAccessModified'
+      );
+      element.dispatchEvent(
+        new CustomEvent('access-modified', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await element.updateComplete;
+      assert.isTrue(handleAccessModifiedSpy.called);
+    });
+
+    test('_handleAccessModified called when parent changes', async () => {
+      element.inheritsFrom = {
+        id: 'test-project' as UrlEncodedRepoName,
+      };
+      await element.updateComplete;
+      queryAndAssert<GrAutocomplete>(
+        element,
+        '#editInheritFromInput'
+      ).dispatchEvent(
+        new CustomEvent('commit', {
+          detail: {},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      const handleAccessModifiedSpy = sinon.spy(
+        element,
+        '_handleAccessModified'
+      );
+      element.dispatchEvent(
+        new CustomEvent('access-modified', {
+          detail: {},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await element.updateComplete;
+      assert.isTrue(handleAccessModifiedSpy.called);
+    });
+
+    test('handleSaveForReview', async () => {
+      const saveStub = stubRestApi('setRepoAccessRightsForReview');
+      sinon.stub(element, 'computeAddAndRemove').returns({
+        add: {},
+        remove: {},
+      });
+      element.handleSaveForReview(new Event('test'));
+      await element.updateComplete;
+      assert.isFalse(saveStub.called);
+    });
+
+    test('recursivelyRemoveDeleted', () => {
+      const obj = {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {
+                234: {action: 'ALLOW'},
+                123: {action: 'DENY', deleted: true},
+              },
+            },
+            read: {
+              deleted: true,
+              rules: {
+                234: {action: 'ALLOW'},
+              },
+            },
+          },
+        },
+      };
+      const expectedResult = {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {
+                234: {action: 'ALLOW'},
+              },
+            },
+          },
+        },
+      };
+      element.recursivelyRemoveDeleted(obj);
+      assert.deepEqual(obj, expectedResult);
+    });
+
+    test('recursivelyUpdateAddRemoveObj on new added section', () => {
+      const obj = {
+        'refs/for/*': {
+          permissions: {
+            'label-Code-Review': {
+              rules: {
+                e798fed07afbc9173a587f876ef8760c78d240c1: {
+                  min: -2,
+                  max: 2,
+                  action: 'ALLOW',
+                  added: true,
+                },
+              },
+              added: true,
+              label: 'Code-Review',
+            },
+            'labelAs-Code-Review': {
+              rules: {
+                'ldap:gerritcodereview-eng': {
+                  min: -2,
+                  max: 2,
+                  action: 'ALLOW',
+                  added: true,
+                  deleted: true,
+                },
+              },
+              added: true,
+              label: 'Code-Review',
+            },
+          },
+          added: true,
+        },
+      };
+
+      const expectedResult = {
+        add: {
+          'refs/for/*': {
+            permissions: {
+              'label-Code-Review': {
+                rules: {
+                  e798fed07afbc9173a587f876ef8760c78d240c1: {
+                    min: -2,
+                    max: 2,
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+                added: true,
+                label: 'Code-Review',
+              },
+              'labelAs-Code-Review': {
+                rules: {},
+                added: true,
+                label: 'Code-Review',
+              },
+            },
+            added: true,
+          },
+        },
+        remove: {},
+      };
+      const updateObj = {add: {}, remove: {}};
+      element.recursivelyUpdateAddRemoveObj(obj, updateObj);
+      assert.deepEqual(updateObj, expectedResult);
+    });
+
+    test('handleSaveForReview with no changes', () => {
+      assert.deepEqual(element.computeAddAndRemove(), {add: {}, remove: {}});
+    });
+
+    test('handleSaveForReview parent change', async () => {
+      element.inheritsFrom = {
+        id: 'test-project' as UrlEncodedRepoName,
+      };
+      element.originalInheritsFrom = {
+        id: 'test-project-original' as UrlEncodedRepoName,
+      };
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), {
+        parent: 'test-project',
+        add: {},
+        remove: {},
+      });
+    });
+
+    test('handleSaveForReview new parent with spaces', async () => {
+      element.inheritsFrom = {
+        id: 'spaces+in+project+name' as UrlEncodedRepoName,
+      };
+      element.originalInheritsFrom = {id: 'old-project' as UrlEncodedRepoName};
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), {
+        parent: 'spaces in project name',
+        add: {},
+        remove: {},
+      });
+    });
+
+    test('handleSaveForReview rules', async () => {
+      // Delete a rule.
+      element.local!['refs/*'].permissions.owner.rules[123].deleted = true;
+      await element.updateComplete;
+      let expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Undo deleting a rule.
+      delete element.local!['refs/*'].permissions.owner.rules[123].deleted;
+
+      // Modify a rule.
+      element.local!['refs/*'].permissions.owner.rules[123].modified = true;
+      await element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+    });
+
+    test('computeAddAndRemove permissions', async () => {
+      // Add a new rule to a permission.
+      let expectedInput = {};
+
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      const grAccessSection = queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      );
+      await queryAndAssert<GrPermission>(
+        grAccessSection,
+        'gr-permission'
+      ).handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Remove the added rule.
+      delete element.local!['refs/*'].permissions.owner.rules.Maintainers;
+
+      // Delete a permission.
+      element.local!['refs/*'].permissions.owner.deleted = true;
+      await element.updateComplete;
+
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Undo delete permission.
+      delete element.local!['refs/*'].permissions.owner.deleted;
+
+      // Modify a permission.
+      element.local!['refs/*'].permissions.owner.modified = true;
+      await element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  123: {action: 'DENY'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+    });
+
+    test('computeAddAndRemove sections', async () => {
+      // Add a new permission to a section
+      let expectedInput = {};
+
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {},
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      ).handleAddPermission();
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Add a new rule to the new permission.
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    min: -2,
+                    max: 2,
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      const grAccessSection = queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      );
+      await queryAll<GrPermission>(
+        grAccessSection,
+        'gr-permission'
+      )[2].handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Modify a section reference.
+      element.local!['refs/*'].updatedId = 'refs/for/bar';
+      element.local!['refs/*'].modified = true;
+      await element.updateComplete;
+
+      expectedInput = {
+        add: {
+          'refs/for/bar': {
+            modified: true,
+            updatedId: 'refs/for/bar',
+            permissions: {
+              owner: {
+                rules: {
+                  234: {action: 'ALLOW'},
+                  123: {action: 'DENY'},
+                },
+              },
+              read: {
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    min: -2,
+                    max: 2,
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Delete a section.
+      element.local!['refs/*'].deleted = true;
+      await element.updateComplete;
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+    });
+
+    test('computeAddAndRemove new section', async () => {
+      // Add a new permission to a section
+      let expectedInput = {};
+
+      expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {},
+          },
+        },
+        remove: {},
+      };
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {},
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      const newSection = queryAll<GrAccessSection>(
+        element,
+        'gr-access-section'
+      )[1];
+      newSection.handleAddPermission();
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Add rule to the new permission.
+      expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+
+      await queryAndAssert<GrPermission>(
+        newSection,
+        'gr-permission'
+      ).handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Modify a the reference from the default value.
+      element.local!['refs/for/*'].updatedId = 'refs/for/new';
+      await element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+    });
+
+    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 element.updateComplete;
+      let expectedInput = {};
+
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      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 element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Also modify a different rule inside of another permission.
+      element.local!['refs/*'].permissions.read.modified = true;
+      await element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      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 element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Add a rule to the existing permission;
+      const grAccessSection = queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      );
+      await queryAll<GrPermission>(
+        grAccessSection,
+        'gr-permission'
+      )[1].handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
+
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  Maintainers: {action: 'ALLOW', added: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Change one of the refs
+      element.local!['refs/*'].updatedId = 'refs/for/bar';
+      element.local!['refs/*'].modified = true;
+      await element.updateComplete;
+
+      expectedInput = {
+        add: {
+          'refs/for/bar': {
+            modified: true,
+            updatedId: 'refs/for/bar',
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  Maintainers: {action: 'ALLOW', added: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      element.local!['refs/*'].deleted = true;
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Add a new section.
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+      let newSection = queryAll<GrAccessSection>(
+        element,
+        'gr-access-section'
+      )[1];
+      newSection.handleAddPermission();
+      await element.updateComplete;
+      await queryAndAssert<GrPermission>(
+        newSection,
+        'gr-permission'
+      ).handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
+      // Modify a the reference from the default value.
+      element.local!['refs/for/*'].updatedId = 'refs/for/new';
+      await element.updateComplete;
+
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Modify newly added rule inside new ref.
+      element.local!['refs/for/*'].permissions['label-Code-Review'].rules[
+        'Maintainers'
+      ].modified = true;
+      await element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    modified: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Add a second new section.
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+      newSection = queryAll<GrAccessSection>(element, 'gr-access-section')[2];
+      newSection.handleAddPermission();
+      await element.updateComplete;
+      await queryAndAssert<GrPermission>(
+        newSection,
+        'gr-permission'
+      ).handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
+      // Modify a the reference from the default value.
+      element.local!['refs/for/**'].updatedId = 'refs/for/new2';
+      await element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    modified: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+          'refs/for/new2': {
+            added: true,
+            updatedId: 'refs/for/new2',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+    });
+
+    test('Unsaved added refs are discarded when edit cancelled', async () => {
+      // Unsaved changes are discarded when editing is cancelled.
+      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 () => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      stubRestApi('getRepoAccessRights').returns(
+        Promise.resolve(JSON.parse(JSON.stringify(accessRes)))
+      );
+      const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+      let resolver: (value: Response | PromiseLike<Response>) => void;
+      const saveStub = stubRestApi('setRepoAccessRights').returns(
+        new Promise(r => (resolver = r))
+      );
+
+      element.repo = 'test-repo' as RepoName;
+      sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
+
+      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 element.updateComplete;
+      assert.isTrue(saveStub.called);
+      assert.isTrue(navigateToChangeStub.notCalled);
+    });
+
+    test('handleSaveForReview', async () => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      stubRestApi('getRepoAccessRights').returns(
+        Promise.resolve(JSON.parse(JSON.stringify(accessRes)))
+      );
+      const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+      let resolver: (value: ChangeInfo | PromiseLike<ChangeInfo>) => void;
+      const saveForReviewStub = stubRestApi(
+        'setRepoAccessRightsForReview'
+      ).returns(new Promise(r => (resolver = r)));
+
+      element.repo = 'test-repo' as RepoName;
+      sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
+
+      element.modified = true;
+      queryAndAssert<GrButton>(element, '#saveReviewBtn').click();
+      await element.updateComplete;
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveReviewBtn').hasAttribute(
+          'loading'
+        ),
+        true
+      );
+      resolver!(createChange());
+      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 de43dc6..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
@@ -14,20 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/shared-styles';
 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-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-change-dialog/gr-create-change-dialog';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo-commands_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {
   BranchName,
   ConfigInfo,
@@ -41,8 +36,15 @@
   firePageError,
   fireTitleChange,
 } from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, query, property, state} from 'lit/decorators';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const GC_MESSAGE = 'Garbage collection completed successfully.';
 const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
@@ -52,80 +54,167 @@
 const CREATE_CHANGE_FAILED_MESSAGE = 'Failed to create change.';
 const CREATE_CHANGE_SUCCEEDED_MESSAGE = 'Navigating to change';
 
-export interface GrRepoCommands {
-  $: {
-    createChangeOverlay: GrOverlay;
-    createNewChangeModal: GrCreateChangeDialog;
-  };
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-commands': GrRepoCommands;
+  }
 }
 
 @customElement('gr-repo-commands')
-export class GrRepoCommands extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrRepoCommands extends LitElement {
+  @query('#createChangeOverlay')
+  private readonly createChangeOverlay?: GrOverlay;
 
-  // This is a required property. Without `repo` being set the component is not
-  // useful. Thus using !.
+  @query('#createNewChangeModal')
+  private readonly createNewChangeModal?: GrCreateChangeDialog;
+
   @property({type: String})
-  repo!: RepoName;
+  repo?: RepoName;
 
-  @property({type: Boolean})
-  _loading = true;
+  @state() private loading = true;
 
-  @property({type: Object})
-  _repoConfig?: ConfigInfo;
+  @state() private repoConfig?: ConfigInfo;
 
-  @property({type: Boolean})
-  _canCreate = false;
+  @state() private canCreateChange = false;
 
-  @property({type: Boolean})
-  _creatingChange = false;
+  @state() private creatingChange = false;
 
-  @property({type: Boolean})
-  _editingConfig = false;
+  @state() private editingConfig = false;
 
-  @property({type: Boolean})
-  _runningGC = false;
+  @state() private runningGC = false;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadRepo();
-
     fireTitleChange(this, 'Repo Commands');
   }
 
-  _loadRepo() {
+  static override get styles() {
+    return [
+      fontStyles,
+      formStyles,
+      subpageStyles,
+      sharedStyles,
+      css`
+        #form gr-button {
+          margin-bottom: var(--spacing-xxl);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    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' : ''}>
+          Loading...
+        </div>
+        <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>
+            <gr-button
+              ?loading=${this.creatingChange}
+              @click=${() => {
+                this.createNewChange();
+              }}
+            >
+              Create change
+            </gr-button>
+            <h3 class="heading-3">Edit repo config</h3>
+            <gr-button
+              id="editRepoConfig"
+              ?loading=${this.editingConfig}
+              @click=${() => {
+                this.handleEditRepoConfig();
+              }}
+            >
+              Edit repo config
+            </gr-button>
+            ${this.renderRepoGarbageCollector()}
+            <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>
+            </gr-endpoint-decorator>
+          </div>
+        </div>
+      </div>
+      <gr-overlay id="createChangeOverlay" with-backdrop>
+        <gr-dialog
+          id="createChangeDialog"
+          confirm-label="Create"
+          ?disabled=${!this.canCreateChange}
+          @confirm=${() => {
+            this.handleCreateChange();
+          }}
+          @cancel=${() => {
+            this.handleCloseCreateChange();
+          }}
+        >
+          <div class="header" slot="header">Create Change</div>
+          <div class="main" slot="main">
+            <gr-create-change-dialog
+              id="createNewChangeModal"
+              .repoName=${this.repo}
+              .privateByDefault=${this.repoConfig?.private_by_default}
+              @can-create-change=${() => {
+                this.handleCanCreateChange();
+              }}
+            ></gr-create-change-dialog>
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private renderRepoGarbageCollector() {
+    if (!this.repoConfig?.actions || !this.repoConfig?.actions['gc']?.enabled)
+      return;
+
+    return html`
+      <h3 class="heading-3">${this.repoConfig?.actions['gc']?.label}</h3>
+      <gr-button
+        title=${this.repoConfig?.actions['gc']?.title || ''}
+        ?loading=${this.runningGC}
+        @click=${() => this.handleRunningGC()}
+      >
+        ${this.repoConfig?.actions['gc']?.label}
+      </gr-button>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('repo')) {
+      this.loadRepo();
+    }
+  }
+
+  // private but used in test
+  loadRepo() {
+    if (!this.repo) return;
+
     const errFn: ErrorCallback = response => {
-      // Do not process the error, if the component is not attached to the DOM
-      // anymore, which at least in tests can happen.
-      if (!this.isConnected) return;
       firePageError(response);
     };
 
-    this.restApiService.getProjectConfig(this.repo, errFn).then(config => {
-      if (!config) return;
-      // Do not process the response, if the component is not attached to the
-      // DOM anymore, which at least in tests can happen.
-      if (!this.isConnected) return;
-      this._repoConfig = config;
-      this._loading = false;
-    });
+    this.restApiService
+      .getProjectConfig(this.repo, errFn)
+      .then(config => {
+        if (!config) return;
+        this.repoConfig = config;
+      })
+      .finally(() => {
+        this.loading = false;
+      });
   }
 
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-
-  _isLoading() {
-    return this._loading;
-  }
-
-  _handleRunningGC() {
+  private handleRunningGC() {
     if (!this.repo) return;
-    this._runningGC = true;
+    this.runningGC = true;
     return this.restApiService
       .runRepoGC(this.repo)
       .then(response => {
@@ -134,31 +223,40 @@
         }
       })
       .finally(() => {
-        this._runningGC = false;
+        this.runningGC = false;
       });
   }
 
-  _createNewChange() {
-    this.$.createChangeOverlay.open();
+  // private but used in test
+  createNewChange() {
+    assertIsDefined(this.createChangeOverlay, 'createChangeOverlay');
+    this.createChangeOverlay.open();
   }
 
-  _handleCreateChange() {
-    this._creatingChange = true;
-    this.$.createNewChangeModal.handleCreateChange().finally(() => {
-      this._creatingChange = false;
+  // private but used in test
+  handleCreateChange() {
+    assertIsDefined(this.createNewChangeModal, 'createNewChangeModal');
+    this.creatingChange = true;
+    this.createNewChangeModal.handleCreateChange().finally(() => {
+      this.creatingChange = false;
     });
-    this._handleCloseCreateChange();
+    this.handleCloseCreateChange();
   }
 
-  _handleCloseCreateChange() {
-    this.$.createChangeOverlay.close();
+  // private but used in test
+  handleCloseCreateChange() {
+    assertIsDefined(this.createChangeOverlay, 'createChangeOverlay');
+    this.createChangeOverlay.close();
   }
 
   /**
    * Returns a Promise for testing.
+   *
+   * private but used in test
    */
-  _handleEditRepoConfig() {
-    this._editingConfig = true;
+  handleEditRepoConfig() {
+    if (!this.repo) return;
+    this.editingConfig = true;
     return this.restApiService
       .createChange(
         this.repo,
@@ -182,13 +280,13 @@
         );
       })
       .finally(() => {
-        this._editingConfig = false;
+        this.editingConfig = false;
       });
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-repo-commands': GrRepoCommands;
+  private handleCanCreateChange() {
+    assertIsDefined(this.createNewChangeModal, 'createNewChangeModal');
+    this.canCreateChange =
+      !!this.createNewChangeModal.branch && !!this.createNewChangeModal.subject;
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
deleted file mode 100644
index 9948f8f..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
+++ /dev/null
@@ -1,92 +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">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    #form gr-button {
-      margin-bottom: var(--spacing-xxl);
-    }
-  </style>
-  <div class="main gr-form-styles read-only">
-    <h1 id="Title" class="heading-1">Repository Commands</h1>
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h2 id="options" class="heading-2">Command</h2>
-      <div id="form">
-        <h3 class="heading-3">Create change</h3>
-        <gr-button loading="[[_creatingChange]]" on-click="_createNewChange">
-          Create change
-        </gr-button>
-        <h3 class="heading-3">Edit repo config</h3>
-        <gr-button
-          id="editRepoConfig"
-          loading="[[_editingConfig]]"
-          on-click="_handleEditRepoConfig"
-        >
-          Edit repo config
-        </gr-button>
-        <h3 class="heading-3" hidden="[[!_repoConfig.actions.gc.enabled]]">
-          [[_repoConfig.actions.gc.label]]
-        </h3>
-        <gr-button
-          hidden="[[!_repoConfig.actions.gc.enabled]]"
-          title="[[_repoConfig.actions.gc.title]]"
-          loading="[[_runningGC]]"
-          on-click="_handleRunningGC"
-        >
-          [[_repoConfig.actions.gc.label]]
-        </gr-button>
-        <gr-endpoint-decorator name="repo-command">
-          <gr-endpoint-param name="config" value="[[_repoConfig]]">
-          </gr-endpoint-param>
-          <gr-endpoint-param name="repoName" value="[[repo]]">
-          </gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </div>
-    </div>
-  </div>
-  <gr-overlay id="createChangeOverlay" with-backdrop="">
-    <gr-dialog
-      id="createChangeDialog"
-      confirm-label="Create"
-      disabled="[[!_canCreate]]"
-      on-confirm="_handleCreateChange"
-      on-cancel="_handleCloseCreateChange"
-    >
-      <div class="header" slot="header">Create Change</div>
-      <div class="main" slot="main">
-        <gr-create-change-dialog
-          id="createNewChangeModal"
-          can-create="{{_canCreate}}"
-          repo-name="[[repo]]"
-        ></gr-create-change-dialog>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
deleted file mode 100644
index ac48484..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
+++ /dev/null
@@ -1,142 +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-repo-commands.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {
-  addListenerForTest,
-  mockPromise,
-  stubRestApi,
-} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-repo-commands');
-
-suite('gr-repo-commands tests', () => {
-  let element;
-
-  let repoStub;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    // Note that this probably does not achieve what it is supposed to, because
-    // getProjectConfig() is called as soon as the element is attached, so
-    // stubbing it here has not effect anymore.
-    repoStub = stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-  });
-
-  suite('create new change dialog', () => {
-    test('_createNewChange opens modal', () => {
-      const openStub = sinon.stub(element.$.createChangeOverlay, 'open');
-      element._createNewChange();
-      assert.isTrue(openStub.called);
-    });
-
-    test('_handleCreateChange called when confirm fired', () => {
-      sinon.stub(element, '_handleCreateChange');
-      element.$.createChangeDialog.dispatchEvent(
-          new CustomEvent('confirm', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCreateChange.called);
-    });
-
-    test('_handleCloseCreateChange called when cancel fired', () => {
-      sinon.stub(element, '_handleCloseCreateChange');
-      element.$.createChangeDialog.dispatchEvent(
-          new CustomEvent('cancel', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCloseCreateChange.called);
-    });
-  });
-
-  suite('edit repo config', () => {
-    let createChangeStub;
-    let urlStub;
-    let handleSpy;
-    let alertStub;
-
-    setup(() => {
-      createChangeStub = stubRestApi('createChange');
-      urlStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
-      sinon.stub(GerritNav, 'navigateToRelativeUrl');
-      handleSpy = sinon.spy(element, '_handleEditRepoConfig');
-      alertStub = sinon.stub();
-      element.repo = 'test';
-      element.addEventListener('show-alert', alertStub);
-    });
-
-    test('successful creation of change', () => {
-      const change = {_number: '1'};
-      createChangeStub.returns(Promise.resolve(change));
-      MockInteractions.tap(element.$.editRepoConfig);
-      assert.isTrue(element.$.editRepoConfig.loading);
-      return handleSpy.lastCall.returnValue.then(() => {
-        flush();
-
-        assert.isTrue(alertStub.called);
-        assert.equal(alertStub.lastCall.args[0].detail.message,
-            'Navigating to change');
-        assert.isTrue(urlStub.called);
-        assert.deepEqual(urlStub.lastCall.args,
-            [change, 'project.config', 1]);
-        assert.isFalse(element.$.editRepoConfig.loading);
-      });
-    });
-
-    test('unsuccessful creation of change', () => {
-      createChangeStub.returns(Promise.resolve(null));
-      MockInteractions.tap(element.$.editRepoConfig);
-      assert.isTrue(element.$.editRepoConfig.loading);
-      return handleSpy.lastCall.returnValue.then(() => {
-        flush();
-
-        assert.isTrue(alertStub.called);
-        assert.equal(alertStub.lastCall.args[0].detail.message,
-            'Failed to create change.');
-        assert.isFalse(urlStub.called);
-        assert.isFalse(element.$.editRepoConfig.loading);
-      });
-    });
-  });
-
-  suite('404', () => {
-    test('fires page-error', async () => {
-      repoStub.restore();
-
-      element.repo = 'test';
-
-      const response = {status: 404};
-      stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
-        errFn(response);
-        return Promise.resolve(undefined);
-      });
-
-      await flush();
-      const promise = mockPromise();
-      addListenerForTest(document, 'page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        promise.resolve();
-      });
-
-      element._loadRepo();
-      await promise;
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
new file mode 100644
index 0000000..42d3333
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
@@ -0,0 +1,180 @@
+/**
+ * @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-repo-commands.js';
+import {GrRepoCommands} from './gr-repo-commands';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  addListenerForTest,
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {PageErrorEvent} from '../../../types/events';
+import {RepoName} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+
+const basicFixture = fixtureFromElement('gr-repo-commands');
+
+suite('gr-repo-commands tests', () => {
+  let element: GrRepoCommands;
+  let repoStub: sinon.SinonStub;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+    // Note that this probably does not achieve what it is supposed to, because
+    // getProjectConfig() is called as soon as the element is attached, so
+    // stubbing it here has not effect anymore.
+    repoStub = stubRestApi('getProjectConfig').returns(
+      Promise.resolve(undefined)
+    );
+  });
+
+  suite('create new change dialog', () => {
+    test('createNewChange opens modal', () => {
+      const openStub = sinon.stub(
+        queryAndAssert<GrOverlay>(element, '#createChangeOverlay'),
+        'open'
+      );
+      element.createNewChange();
+      assert.isTrue(openStub.called);
+    });
+
+    test('handleCreateChange called when confirm fired', () => {
+      const handleCreateChangeStub = sinon.stub(element, 'handleCreateChange');
+      queryAndAssert<GrDialog>(element, '#createChangeDialog').dispatchEvent(
+        new CustomEvent('confirm', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleCreateChangeStub.called);
+    });
+
+    test('handleCloseCreateChange called when cancel fired', () => {
+      const handleCloseCreateChangeStub = sinon.stub(
+        element,
+        'handleCloseCreateChange'
+      );
+      queryAndAssert<GrDialog>(element, '#createChangeDialog').dispatchEvent(
+        new CustomEvent('cancel', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleCloseCreateChangeStub.called);
+    });
+  });
+
+  suite('edit repo config', () => {
+    let createChangeStub: sinon.SinonStub;
+    let urlStub: sinon.SinonStub;
+    let handleSpy: sinon.SinonSpy;
+    let alertStub: sinon.SinonStub;
+
+    setup(() => {
+      createChangeStub = stubRestApi('createChange');
+      urlStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
+      sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      handleSpy = sinon.spy(element, 'handleEditRepoConfig');
+      alertStub = sinon.stub();
+      element.repo = 'test' as RepoName;
+      element.addEventListener('show-alert', alertStub);
+    });
+
+    test('successful creation of change', async () => {
+      const change = {_number: '1'};
+      createChangeStub.returns(Promise.resolve(change));
+      MockInteractions.tap(
+        queryAndAssert<GrButton>(element, '#editRepoConfig')
+      );
+      await element.updateComplete;
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#editRepoConfig').loading
+      );
+
+      await handleSpy.lastCall.returnValue;
+      await element.updateComplete;
+
+      assert.isTrue(alertStub.called);
+      assert.equal(
+        alertStub.lastCall.args[0].detail.message,
+        'Navigating to change'
+      );
+      assert.isTrue(urlStub.called);
+      assert.deepEqual(urlStub.lastCall.args, [change, 'project.config', 1]);
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#editRepoConfig').loading
+      );
+    });
+
+    test('unsuccessful creation of change', async () => {
+      createChangeStub.returns(Promise.resolve(null));
+      MockInteractions.tap(
+        queryAndAssert<GrButton>(element, '#editRepoConfig')
+      );
+      await element.updateComplete;
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#editRepoConfig').loading
+      );
+
+      await handleSpy.lastCall.returnValue;
+      await element.updateComplete;
+
+      assert.isTrue(alertStub.called);
+      assert.equal(
+        alertStub.lastCall.args[0].detail.message,
+        'Failed to create change.'
+      );
+      assert.isFalse(urlStub.called);
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#editRepoConfig').loading
+      );
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', async () => {
+      repoStub.restore();
+
+      element.repo = 'test' as RepoName;
+
+      const response = {status: 404} as Response;
+      stubRestApi('getProjectConfig').callsFake((_repo, errFn) => {
+        if (errFn !== undefined) {
+          errFn(response);
+        }
+        return Promise.resolve(undefined);
+      });
+
+      await element.updateComplete;
+      const promise = mockPromise();
+      addListenerForTest(document, 'page-error', e => {
+        assert.deepEqual((e as PageErrorEvent).detail.response, response);
+        promise.resolve();
+      });
+
+      element.loadRepo();
+      await promise;
+    });
+  });
+});
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 7800653..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
@@ -18,7 +18,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {RepoName, DashboardId, DashboardInfo} from '../../../types/common';
 import {firePageError} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
@@ -41,7 +41,7 @@
   @property({type: Array})
   _dashboards?: DashboardRef[];
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
@@ -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 052e07a..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,10 +16,7 @@
  */
 
 import '@polymer/iron-input/iron-input';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
-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';
@@ -27,106 +24,356 @@
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo-detail-list_html';
 import {encodeURL} from '../../../utils/url-util';
-import {customElement, property} from '@polymer/decorators';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import {
-  RepoName,
-  ProjectInfo,
   BranchInfo,
-  GitRef,
-  TagInfo,
   GitPersonInfo,
+  GitRef,
+  ProjectInfo,
+  RepoName,
+  TagInfo,
+  WebLinkInfo,
 } from '../../../types/common';
 import {AppElementRepoParams} from '../../gr-app-types';
-import {PolymerDomRepeatEvent} from '../../../types/types';
 import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
 import {firePageError} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, query, property, state} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
+import {assertIsDefined} from '../../../utils/common-util';
+import {ifDefined} from 'lit/directives/if-defined';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
-export interface GrRepoDetailList {
-  $: {
-    overlay: GrOverlay;
-    createOverlay: GrOverlay;
-    createNewModal: GrCreatePointerDialog;
-  };
-}
-
 @customElement('gr-repo-detail-list')
-export class GrRepoDetailList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrRepoDetailList extends LitElement {
+  @query('#overlay') private readonly overlay?: GrOverlay;
 
-  @property({type: Object, observer: '_paramsChanged'})
+  @query('#createOverlay') private readonly createOverlay?: GrOverlay;
+
+  @query('#createNewModal')
+  private readonly createNewModal?: GrCreatePointerDialog;
+
+  @property({type: Object})
   params?: AppElementRepoParams;
 
-  @property({type: String})
-  detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
+  // private but used in test
+  @state() detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
 
-  @property({type: Boolean})
-  _editing = false;
+  // private but used in test
+  @state() isOwner = false;
 
-  @property({type: Boolean})
-  _isOwner = false;
+  @state() private loggedIn = false;
 
-  @property({type: Boolean})
-  _loggedIn = false;
+  @state() private offset = 0;
 
-  @property({type: Number})
-  _offset = 0;
+  // private but used in test
+  @state() repo?: RepoName;
 
-  @property({type: String})
-  _repo?: RepoName;
+  // private but used in test
+  @state() items?: BranchInfo[] | TagInfo[];
 
-  @property({type: Array})
-  _items?: BranchInfo[] | TagInfo[];
+  @state() private readonly itemsPerPage = 25;
 
-  // _shownItems should be BranchInfo[] | TagInfo[],
-  // but TS incorrectly assumes that in the loop for(const item of _shownItems)
-  // item has type BranchInfo, not BranchInfo | TagInfo.
-  @property({type: Array, computed: 'computeShownItems(_items)'})
-  _shownItems?: Array<BranchInfo | TagInfo>;
+  @state() private loading = true;
 
-  @property({type: Number})
-  _itemsPerPage = 25;
+  @state() private filter?: string;
 
-  @property({type: Boolean})
-  _loading = true;
+  @state() private refName?: GitRef;
 
-  @property({type: String})
-  _filter?: string;
+  @state() private newItemName = false;
 
-  @property({type: String})
-  _refName?: GitRef;
+  // private but used in test
+  @state() isEditing = false;
 
-  @property({type: Boolean})
-  _hasNewItemName = false;
+  // private but used in test
+  @state() revisedRef?: GitRef;
 
-  @property({type: Boolean})
-  _isEditing = false;
+  private readonly restApiService = getAppContext().restApiService;
 
-  @property({type: String})
-  _revisedRef?: GitRef;
-
-  private readonly restApiService = appContext.restApiService;
-
-  _determineIfOwner(repo: RepoName) {
-    return this.restApiService
-      .getRepoAccess(repo)
-      .then(access => (this._isOwner = !!access?.[repo]?.is_owner));
+  static override get styles() {
+    return [
+      formStyles,
+      tableStyles,
+      sharedStyles,
+      css`
+        .tags td.name {
+          min-width: 25em;
+        }
+        td.name,
+        td.revision,
+        td.message {
+          word-break: break-word;
+        }
+        td.revision.tags {
+          width: 27em;
+        }
+        td.message,
+        td.tagger {
+          max-width: 15em;
+        }
+        .editing .editItem {
+          display: inherit;
+        }
+        .editItem,
+        .editing .editBtn,
+        .canEdit .revisionNoEditing,
+        .editing .revisionWithEditing,
+        .revisionEdit,
+        .hideItem {
+          display: none;
+        }
+        .revisionEdit gr-button {
+          margin-left: var(--spacing-m);
+        }
+        .editBtn {
+          margin-left: var(--spacing-l);
+        }
+        .canEdit .revisionEdit {
+          align-items: center;
+          display: flex;
+        }
+        .deleteButton:not(.show) {
+          display: none;
+        }
+        .tagger.hide {
+          display: none;
+        }
+      `,
+    ];
   }
 
-  _paramsChanged(params?: AppElementRepoParams) {
-    if (!params?.repo) {
+  override render() {
+    return html`
+      <gr-list-view
+        .createNew=${this.loggedIn}
+        .filter=${this.filter}
+        .itemsPerPage=${this.itemsPerPage}
+        .items=${this.items}
+        .loading=${this.loading}
+        .offset=${this.offset}
+        .path=${this.getPath(this.repo, this.detailType)}
+        @create-clicked=${() => {
+          this.handleCreateClicked();
+        }}
+      >
+        <table id="list" class="genericList gr-form-styles">
+          <tbody>
+            <tr class="headerRow">
+              <th class="name topHeader">Name</th>
+              <th class="revision topHeader">Revision</th>
+              <th
+                class="message topHeader ${this.detailType ===
+                RepoDetailView.BRANCHES
+                  ? 'hideItem'
+                  : ''}"
+              >
+                Message
+              </th>
+              <th
+                class="tagger topHeader ${this.detailType ===
+                RepoDetailView.BRANCHES
+                  ? 'hideItem'
+                  : ''}"
+              >
+                Tagger
+              </th>
+              <th class="repositoryBrowser topHeader">Repository Browser</th>
+              <th class="delete topHeader"></th>
+            </tr>
+            <tr
+              id="loading"
+              class="loadingMsg ${this.loading ? 'loading' : ''}"
+            >
+              <td>Loading...</td>
+            </tr>
+          </tbody>
+          <tbody class=${this.loading ? 'loading' : ''}>
+            ${this.items
+              ?.slice(0, SHOWN_ITEMS_COUNT)
+              .map((item, index) => this.renderItemList(item, index))}
+          </tbody>
+        </table>
+        <gr-overlay id="overlay" with-backdrop>
+          <gr-confirm-delete-item-dialog
+            class="confirmDialog"
+            .item=${this.refName}
+            .itemTypeName=${this.computeItemName(this.detailType)}
+            @confirm=${() => this.handleDeleteItemConfirm()}
+            @cancel=${() => {
+              this.handleConfirmDialogCancel();
+            }}
+          ></gr-confirm-delete-item-dialog>
+        </gr-overlay>
+      </gr-list-view>
+      <gr-overlay id="createOverlay" with-backdrop>
+        <gr-dialog
+          id="createDialog"
+          ?disabled=${!this.newItemName}
+          confirm-label="Create"
+          @confirm=${() => {
+            this.handleCreateItem();
+          }}
+          @cancel=${() => {
+            this.handleCloseCreate();
+          }}
+        >
+          <div class="header" slot="header">
+            Create ${this.computeItemName(this.detailType)}
+          </div>
+          <div class="main" slot="main">
+            <gr-create-pointer-dialog
+              id="createNewModal"
+              .detailType=${this.computeItemName(this.detailType)}
+              .itemDetail=${this.detailType}
+              .repoName=${this.repo}
+              @update-item-name=${() => {
+                this.handleUpdateItemName();
+              }}
+            ></gr-create-pointer-dialog>
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private renderItemList(item: BranchInfo | TagInfo, index: number) {
+    return html`
+      <tr class="table">
+        <td class="${this.detailType} name">
+          <a href=${ifDefined(this.computeFirstWebLink(item))}>
+            ${this.stripRefs(item.ref, this.detailType)}
+          </a>
+        </td>
+        <td
+          class="${this.detailType} revision ${this.computeCanEditClass(
+            item.ref,
+            this.detailType,
+            this.isOwner
+          )}"
+        >
+          <span class="revisionNoEditing"> ${item.revision} </span>
+          <span class="revisionEdit ${this.isEditing ? 'editing' : ''}">
+            <span class="revisionWithEditing"> ${item.revision} </span>
+            <gr-button
+              class="editBtn"
+              link
+              data-index=${index}
+              @click=${() => {
+                this.handleEditRevision(index);
+              }}
+            >
+              edit
+            </gr-button>
+            <iron-input
+              class="editItem"
+              .bindValue=${this.revisedRef}
+              @bind-value-changed=${this.handleRevisedRefBindValueChanged}
+            >
+              <input />
+            </iron-input>
+            <gr-button
+              class="cancelBtn editItem"
+              link
+              @click=${() => {
+                this.handleCancelRevision();
+              }}
+            >
+              Cancel
+            </gr-button>
+            <gr-button
+              class="saveBtn editItem"
+              link
+              data-index=${index}
+              ?disabled=${!this.revisedRef}
+              @click=${() => {
+                this.handleSaveRevision(index);
+              }}
+            >
+              Save
+            </gr-button>
+          </span>
+        </td>
+        <td
+          class="message ${this.detailType === RepoDetailView.BRANCHES
+            ? 'hideItem'
+            : ''}"
+        >
+          ${(item as TagInfo)?.message
+            ? (item as TagInfo).message?.split(PGP_START)[0]
+            : ''}
+        </td>
+        <td
+          class="tagger ${this.detailType === RepoDetailView.BRANCHES
+            ? 'hideItem'
+            : ''}"
+        >
+          ${this.renderTagger((item as TagInfo).tagger)}
+        </td>
+        <td class="repositoryBrowser">
+          ${this.computeWeblink(item).map(link => this.renderWeblink(link))}
+        </td>
+        <td class="delete">
+          <gr-button
+            class="deleteButton ${item.can_delete ? 'show' : ''}"
+            link
+            data-index=${index}
+            @click=${() => {
+              this.handleDeleteItem(index);
+            }}
+          >
+            Delete
+          </gr-button>
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderTagger(tagger?: GitPersonInfo) {
+    if (!tagger) return;
+
+    return html`
+      <div class="tagger">
+        <gr-account-label .account=${tagger} clickable> </gr-account-label>
+        (<gr-date-formatter withTooltip .dateStr=${tagger.date}>
+        </gr-date-formatter
+        >)
+      </div>
+    `;
+  }
+
+  private renderWeblink(link: WebLinkInfo) {
+    return html`
+      <a href=${link.url} class="webLink" rel="noopener" target="_blank">
+        (${link.name})
+      </a>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this.paramsChanged();
+    }
+  }
+
+  // private but used in test
+  determineIfOwner(repo: RepoName) {
+    return this.restApiService
+      .getRepoAccess(repo)
+      .then(access => (this.isOwner = !!access?.[repo]?.is_owner));
+  }
+
+  // private but used in test
+  paramsChanged() {
+    if (!this.params?.repo) {
       return Promise.reject(new Error('undefined repo'));
     }
 
@@ -134,40 +381,40 @@
     // to false and polymer removes this component, hence check for params
     if (
       !(
-        params?.detail === RepoDetailView.BRANCHES ||
-        params?.detail === RepoDetailView.TAGS
+        this.params?.detail === RepoDetailView.BRANCHES ||
+        this.params?.detail === RepoDetailView.TAGS
       )
     ) {
       return;
     }
 
-    this._repo = params.repo;
+    this.repo = this.params.repo;
 
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn && this._repo) {
-        this._determineIfOwner(this._repo);
+    this.getLoggedIn().then(loggedIn => {
+      this.loggedIn = loggedIn;
+      if (loggedIn && this.repo) {
+        this.determineIfOwner(this.repo);
       }
     });
 
-    this.detailType = params.detail;
+    this.detailType = this.params.detail;
 
-    this._filter = params?.filter ?? '';
-    this._offset = Number(params?.offset ?? 0);
+    this.filter = this.params?.filter ?? '';
+    this.offset = Number(this.params?.offset ?? 0);
     if (!this.detailType)
       return Promise.reject(new Error('undefined detailType'));
 
-    return this._getItems(
-      this._filter,
-      this._repo,
-      this._itemsPerPage,
-      this._offset,
+    return this.getItems(
+      this.filter,
+      this.repo,
+      this.itemsPerPage,
+      this.offset,
       this.detailType
     );
   }
 
   // TODO(TS) Move this to object for easier read, understand.
-  _getItems(
+  private getItems(
     filter: string | undefined,
     repo: RepoName | undefined,
     itemsPerPage: number,
@@ -177,9 +424,9 @@
     if (filter === undefined || !repo || offset === undefined) {
       return Promise.reject(new Error('filter or repo or offset undefined'));
     }
-    this._loading = true;
-    this._items = [];
-    flush();
+    this.loading = true;
+    this.items = [];
+
     const errFn: ErrorCallback = response => {
       firePageError(response);
     };
@@ -188,52 +435,42 @@
       return this.restApiService
         .getRepoBranches(filter, repo, itemsPerPage, offset, errFn)
         .then(items => {
-          if (!items) {
-            return;
-          }
-          this._items = items;
-          this._loading = false;
+          this.items = items ?? [];
+          this.loading = false;
+        })
+        .finally(() => {
+          this.loading = false;
         });
     } else if (detailType === RepoDetailView.TAGS) {
       return this.restApiService
         .getRepoTags(filter, repo, itemsPerPage, offset, errFn)
         .then(items => {
-          if (!items) {
-            return;
-          }
-          this._items = items;
-          this._loading = false;
+          this.items = items ?? [];
+        })
+        .finally(() => {
+          this.loading = false;
         });
     }
     return Promise.reject(new Error('unknown detail type'));
   }
 
-  _getPath(repo?: RepoName, detailType?: RepoDetailView) {
+  private getPath(repo?: RepoName, detailType?: RepoDetailView) {
     return `/admin/repos/${encodeURL(repo ?? '', false)},${detailType}`;
   }
 
-  _computeWeblink(repo: ProjectInfo | BranchInfo | TagInfo) {
-    if (!repo.web_links) {
-      return '';
-    }
+  private computeWeblink(repo: ProjectInfo | BranchInfo | TagInfo) {
+    if (!repo.web_links) return [];
     const webLinks = repo.web_links;
-    return webLinks.length ? webLinks : null;
+    return webLinks.length ? webLinks : [];
   }
 
-  _computeFirstWebLink(repo: ProjectInfo | BranchInfo | TagInfo) {
-    const webLinks = this._computeWeblink(repo);
-    return webLinks ? webLinks[0].url : null;
+  private computeFirstWebLink(repo: ProjectInfo | BranchInfo | TagInfo) {
+    const webLinks = this.computeWeblink(repo);
+    return webLinks.length > 0 ? webLinks[0].url : undefined;
   }
 
-  _computeMessage(message?: string) {
-    if (!message) {
-      return;
-    }
-    // Strip PGP info.
-    return message.split(PGP_START)[0];
-  }
-
-  _stripRefs(item: GitRef, detailType?: RepoDetailView) {
+  // private but used in test
+  stripRefs(item: GitRef, detailType?: RepoDetailView) {
     if (detailType === RepoDetailView.BRANCHES) {
       return item.replace('refs/heads/', '');
     } else if (detailType === RepoDetailView.TAGS) {
@@ -242,62 +479,61 @@
     throw new Error('unknown detailType');
   }
 
-  _getLoggedIn() {
+  // private but used in test
+  getLoggedIn() {
     return this.restApiService.getLoggedIn();
   }
 
-  _computeEditingClass(isEditing: boolean) {
-    return isEditing ? 'editing' : '';
-  }
-
-  _computeCanEditClass(
+  private computeCanEditClass(
     ref?: GitRef,
     detailType?: RepoDetailView,
     isOwner?: boolean
   ) {
     if (ref === undefined || detailType === undefined) return '';
-    return isOwner && this._stripRefs(ref, detailType) === 'HEAD'
+    return isOwner && this.stripRefs(ref, detailType) === 'HEAD'
       ? 'canEdit'
       : '';
   }
 
-  _handleEditRevision(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
-    this._revisedRef = e.model.get('item.revision') as unknown as GitRef;
-    this._isEditing = true;
+  private handleEditRevision(index: number) {
+    if (!this.items) return;
+
+    this.revisedRef = this.items[index].revision as GitRef;
+    this.isEditing = true;
   }
 
-  _handleCancelRevision() {
-    this._isEditing = false;
+  private handleCancelRevision() {
+    this.isEditing = false;
   }
 
-  _handleSaveRevision(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
-    if (this._revisedRef && this._repo)
-      this._setRepoHead(this._repo, this._revisedRef, e);
+  // private but used in test
+  handleSaveRevision(index: number) {
+    if (this.revisedRef && this.repo)
+      this.setRepoHead(this.repo, this.revisedRef, index);
   }
 
-  _setRepoHead(
-    repo: RepoName,
-    ref: GitRef,
-    e: PolymerDomRepeatEvent<BranchInfo | TagInfo>
-  ) {
+  // private but used in test
+  setRepoHead(repo: RepoName, ref: GitRef, index: number) {
+    if (!this.items) return;
     return this.restApiService.setRepoHead(repo, ref).then(res => {
       if (res.status < 400) {
-        this._isEditing = false;
-        e.model.set('item.revision', ref);
-        // This is needed to refresh _items property with fresh data,
+        this.isEditing = false;
+        this.items![index].revision = ref;
+        // This is needed to refresh 'items' property with fresh data,
         // specifically can_delete from the json response.
-        this._getItems(
-          this._filter,
-          this._repo,
-          this._itemsPerPage,
-          this._offset,
+        this.getItems(
+          this.filter,
+          this.repo,
+          this.itemsPerPage,
+          this.offset,
           this.detailType!
         );
       }
     });
   }
 
-  _computeItemName(detailType?: RepoDetailView) {
+  // private but used in test
+  computeItemName(detailType?: RepoDetailView) {
     if (detailType === undefined) return '';
     if (detailType === RepoDetailView.BRANCHES) {
       return 'Branch';
@@ -307,35 +543,36 @@
     throw new Error('unknown detailType');
   }
 
-  _handleDeleteItemConfirm() {
-    this.$.overlay.close();
-    if (!this._repo || !this._refName) {
+  private handleDeleteItemConfirm() {
+    assertIsDefined(this.overlay, 'overlay');
+    this.overlay.close();
+    if (!this.repo || !this.refName) {
       return Promise.reject(new Error('undefined repo or refName'));
     }
     if (this.detailType === RepoDetailView.BRANCHES) {
       return this.restApiService
-        .deleteRepoBranches(this._repo, this._refName)
+        .deleteRepoBranches(this.repo, this.refName)
         .then(itemDeleted => {
           if (itemDeleted.status === 204) {
-            this._getItems(
-              this._filter,
-              this._repo,
-              this._itemsPerPage,
-              this._offset,
+            this.getItems(
+              this.filter,
+              this.repo,
+              this.itemsPerPage,
+              this.offset,
               this.detailType!
             );
           }
         });
     } else if (this.detailType === RepoDetailView.TAGS) {
       return this.restApiService
-        .deleteRepoTags(this._repo, this._refName)
+        .deleteRepoTags(this.repo, this.refName)
         .then(itemDeleted => {
           if (itemDeleted.status === 204) {
-            this._getItems(
-              this._filter,
-              this._repo,
-              this._itemsPerPage,
-              this._offset,
+            this.getItems(
+              this.filter,
+              this.repo,
+              this.itemsPerPage,
+              this.offset,
               this.detailType!
             );
           }
@@ -344,61 +581,49 @@
     return Promise.reject(new Error('unknown detail type'));
   }
 
-  _handleConfirmDialogCancel() {
-    this.$.overlay.close();
+  private handleConfirmDialogCancel() {
+    assertIsDefined(this.overlay, 'overlay');
+    this.overlay.close();
   }
 
-  _handleDeleteItem(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
-    const name = this._stripRefs(
-      e.model.get('item.ref'),
+  private handleDeleteItem(index: number) {
+    if (!this.items) return;
+    assertIsDefined(this.overlay, 'overlay');
+    const name = this.stripRefs(
+      this.items[index].ref,
       this.detailType
     ) as GitRef;
-    if (!name) {
-      return;
-    }
-    this._refName = name;
-    this.$.overlay.open();
+    if (!name) return;
+    this.refName = name;
+    this.overlay.open();
   }
 
-  _computeHideDeleteClass(owner?: boolean, canDelete?: boolean) {
-    if (canDelete || owner) {
-      return 'show';
-    }
-
-    return '';
+  // private but used in test
+  handleCreateItem() {
+    assertIsDefined(this.createNewModal, 'createNewModal');
+    this.createNewModal.handleCreateItem();
+    this.handleCloseCreate();
   }
 
-  _handleCreateItem() {
-    this.$.createNewModal.handleCreateItem();
-    this._handleCloseCreate();
+  // private but used in test
+  handleCloseCreate() {
+    assertIsDefined(this.createOverlay, 'createOverlay');
+    this.createOverlay.close();
   }
 
-  _handleCloseCreate() {
-    this.$.createOverlay.close();
+  // private but used in test
+  handleCreateClicked() {
+    assertIsDefined(this.createOverlay, 'createOverlay');
+    this.createOverlay.open();
   }
 
-  _handleCreateClicked() {
-    this.$.createOverlay.open();
+  private handleUpdateItemName() {
+    assertIsDefined(this.createNewModal, 'createNewModal');
+    this.newItemName = !!this.createNewModal.itemName;
   }
 
-  _hideIfBranch(type?: RepoDetailView) {
-    if (type === RepoDetailView.BRANCHES) {
-      return 'hideItem';
-    }
-
-    return '';
-  }
-
-  _computeHideTagger(tagger?: GitPersonInfo) {
-    return tagger ? '' : 'hide';
-  }
-
-  computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-
-  computeShownItems(items: BranchInfo[] | TagInfo[]) {
-    return items.slice(0, SHOWN_ITEMS_COUNT);
+  private handleRevisedRefBindValueChanged(e: BindValueChangeEvent) {
+    this.revisedRef = e.detail.value as GitRef;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
deleted file mode 100644
index 429a6d6..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_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-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    .tags td.name {
-      min-width: 25em;
-    }
-    td.name,
-    td.revision,
-    td.message {
-      word-break: break-word;
-    }
-    td.revision.tags {
-      width: 27em;
-    }
-    td.message,
-    td.tagger {
-      max-width: 15em;
-    }
-    .editing .editItem {
-      display: inherit;
-    }
-    .editItem,
-    .editing .editBtn,
-    .canEdit .revisionNoEditing,
-    .editing .revisionWithEditing,
-    .revisionEdit,
-    .hideItem {
-      display: none;
-    }
-    .revisionEdit gr-button {
-      margin-left: var(--spacing-m);
-    }
-    .editBtn {
-      margin-left: var(--spacing-l);
-    }
-    .canEdit .revisionEdit {
-      align-items: center;
-      display: flex;
-    }
-    .deleteButton:not(.show) {
-      display: none;
-    }
-    .tagger.hide {
-      display: none;
-    }
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <gr-list-view
-    create-new="[[_loggedIn]]"
-    filter="[[_filter]]"
-    items-per-page="[[_itemsPerPage]]"
-    items="[[_items]]"
-    loading="[[_loading]]"
-    offset="[[_offset]]"
-    on-create-clicked="_handleCreateClicked"
-    path="[[_getPath(_repo, detailType)]]"
-  >
-    <table id="list" class="genericList gr-form-styles">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Name</th>
-          <th class="revision topHeader">Revision</th>
-          <th class$="message topHeader [[_hideIfBranch(detailType)]]">
-            Message
-          </th>
-          <th class$="tagger topHeader [[_hideIfBranch(detailType)]]">
-            Tagger
-          </th>
-          <th class="repositoryBrowser topHeader">Repository Browser</th>
-          <th class="delete topHeader"></th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_shownItems]]">
-          <tr class="table">
-            <td class$="[[detailType]] name">
-              <a href$="[[_computeFirstWebLink(item)]]">
-                [[_stripRefs(item.ref, detailType)]]
-              </a>
-            </td>
-            <td
-              class$="[[detailType]] revision [[_computeCanEditClass(item.ref, detailType, _isOwner)]]"
-            >
-              <span class="revisionNoEditing"> [[item.revision]] </span>
-              <span class$="revisionEdit [[_computeEditingClass(_isEditing)]]">
-                <span class="revisionWithEditing"> [[item.revision]] </span>
-                <gr-button
-                  link=""
-                  on-click="_handleEditRevision"
-                  class="editBtn"
-                >
-                  edit
-                </gr-button>
-                <iron-input bind-value="{{_revisedRef}}" class="editItem">
-                  <input is="iron-input" bind-value="{{_revisedRef}}" />
-                </iron-input>
-                <gr-button
-                  link=""
-                  on-click="_handleCancelRevision"
-                  class="cancelBtn editItem"
-                >
-                  Cancel
-                </gr-button>
-                <gr-button
-                  link=""
-                  on-click="_handleSaveRevision"
-                  class="saveBtn editItem"
-                  disabled="[[!_revisedRef]]"
-                >
-                  Save
-                </gr-button>
-              </span>
-            </td>
-            <td class$="message [[_hideIfBranch(detailType)]]">
-              [[_computeMessage(item.message)]]
-            </td>
-            <td class$="tagger [[_hideIfBranch(detailType)]]">
-              <div class$="tagger [[_computeHideTagger(item.tagger)]]">
-                <gr-account-link account="[[item.tagger]]"> </gr-account-link>
-                (<gr-date-formatter withTooltip date-str="[[item.tagger.date]]">
-                </gr-date-formatter
-                >)
-              </div>
-            </td>
-            <td class="repositoryBrowser">
-              <template
-                is="dom-repeat"
-                items="[[_computeWeblink(item)]]"
-                as="link"
-              >
-                <a
-                  href$="[[link.url]]"
-                  class="webLink"
-                  rel="noopener"
-                  target="_blank"
-                >
-                  ([[link.name]])
-                </a>
-              </template>
-            </td>
-            <td class="delete">
-              <gr-button
-                link=""
-                class$="deleteButton [[_computeHideDeleteClass(_isOwner, item.can_delete)]]"
-                on-click="_handleDeleteItem"
-              >
-                Delete
-              </gr-button>
-            </td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-    <gr-overlay id="overlay" with-backdrop="">
-      <gr-confirm-delete-item-dialog
-        class="confirmDialog"
-        on-confirm="_handleDeleteItemConfirm"
-        on-cancel="_handleConfirmDialogCancel"
-        item="[[_refName]]"
-        item-type-name="[[_computeItemName(detailType)]]"
-      ></gr-confirm-delete-item-dialog>
-    </gr-overlay>
-  </gr-list-view>
-  <gr-overlay id="createOverlay" with-backdrop="">
-    <gr-dialog
-      id="createDialog"
-      disabled="[[!_hasNewItemName]]"
-      confirm-label="Create"
-      on-confirm="_handleCreateItem"
-      on-cancel="_handleCloseCreate"
-    >
-      <div class="header" slot="header">
-        Create [[_computeItemName(detailType)]]
-      </div>
-      <div class="main" slot="main">
-        <gr-create-pointer-dialog
-          id="createNewModal"
-          detail-type="[[_computeItemName(detailType)]]"
-          has-new-item-name="{{_hasNewItemName}}"
-          item-detail="[[detailType]]"
-          repo-name="[[_repo]]"
-        ></gr-create-pointer-dialog>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
deleted file mode 100644
index d5eb5d5..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
+++ /dev/null
@@ -1,503 +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-repo-detail-list.js';
-import 'lodash/lodash.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {
-  addListenerForTest,
-  mockPromise,
-  stubRestApi,
-} from '../../../test/test-utils.js';
-import {RepoDetailView} from '../../core/gr-navigation/gr-navigation.js';
-
-const basicFixture = fixtureFromElement('gr-repo-detail-list');
-
-let counter;
-const branchGenerator = () => {
-  return {
-    ref: `refs/heads/test${++counter}`,
-    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
-    web_links: [
-      {
-        name: 'diffusion',
-        url: `https://git.example.org/branch/test;refs/heads/test${counter}`,
-      },
-    ],
-  };
-};
-const tagGenerator = () => {
-  return {
-    ref: `refs/tags/test${++counter}`,
-    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
-    web_links: [
-      {
-        name: 'diffusion',
-        url: `https://git.example.org/tag/test;refs/tags/test${counter}`,
-      },
-    ],
-    message: 'Annotated tag',
-    tagger: {
-      name: 'Test User',
-      email: 'test.user@gmail.com',
-      date: '2017-09-19 14:54:00.000000000',
-      tz: 540,
-    },
-  };
-};
-
-suite('gr-repo-detail-list', () => {
-  suite('Branches', () => {
-    let element;
-    let branches;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.detailType = 'branches';
-      counter = 0;
-      sinon.stub(page, 'show');
-    });
-
-    suite('list of repo branches', () => {
-      setup(async () => {
-        branches = [{
-          ref: 'HEAD',
-          revision: 'master',
-        }].concat(_.times(25, branchGenerator));
-        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
-
-        const params = {
-          repo: 'test',
-          detail: 'branches',
-        };
-        await element._paramsChanged(params);
-        await flush();
-      });
-
-      test('test for branch in the list', () => {
-        assert.equal(element._items[2].ref, 'refs/heads/test2');
-      });
-
-      test('test for web links in the branches list', () => {
-        assert.equal(element._items[2].web_links[0].url,
-            'https://git.example.org/branch/test;refs/heads/test2');
-      });
-
-      test('test for refs/heads/ being striped from ref', () => {
-        assert.equal(element._stripRefs(element._items[2].ref,
-            element.detailType), 'test2');
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-
-      test('Edit HEAD button not admin', async () => {
-        sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        stubRestApi('getRepoAccess').returns(
-            Promise.resolve({
-              test: {is_owner: false},
-            }));
-        await element._determineIfOwner('test');
-        assert.equal(element._isOwner, false);
-        assert.equal(getComputedStyle(dom(element.root)
-            .querySelector('.revisionNoEditing')).display, 'inline');
-        assert.equal(getComputedStyle(dom(element.root)
-            .querySelector('.revisionEdit')).display, 'none');
-      });
-
-      test('Edit HEAD button admin', async () => {
-        const saveBtn = element.root.querySelector('.saveBtn');
-        const cancelBtn = element.root.querySelector('.cancelBtn');
-        const editBtn = element.root.querySelector('.editBtn');
-        const revisionNoEditing = dom(element.root)
-            .querySelector('.revisionNoEditing');
-        const revisionWithEditing = dom(element.root)
-            .querySelector('.revisionWithEditing');
-
-        sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        stubRestApi('getRepoAccess').returns(
-            Promise.resolve({
-              test: {is_owner: true},
-            }));
-        sinon.stub(element, '_handleSaveRevision');
-        await element._determineIfOwner('test');
-        assert.equal(element._isOwner, true);
-        // The revision container for non-editing enabled row is not visible.
-        assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
-
-        // The revision container for editing enabled row is visible.
-        assert.notEqual(getComputedStyle(dom(element.root)
-            .querySelector('.revisionEdit')).display, 'none');
-
-        // The revision and edit button are visible.
-        assert.notEqual(getComputedStyle(revisionWithEditing).display,
-            'none');
-        assert.notEqual(getComputedStyle(editBtn).display, 'none');
-
-        // The input, cancel, and save buttons are not visible.
-        const hiddenElements = dom(element.root)
-            .querySelectorAll('.canEdit .editItem');
-
-        for (const item of hiddenElements) {
-          assert.equal(getComputedStyle(item).display, 'none');
-        }
-
-        MockInteractions.tap(editBtn);
-        await flush();
-        // The revision and edit button are not visible.
-        assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
-        assert.equal(getComputedStyle(editBtn).display, 'none');
-
-        // The input, cancel, and save buttons are not visible.
-        for (const item of hiddenElements) {
-          assert.notEqual(getComputedStyle(item).display, 'none');
-        }
-
-        // The revised ref was set correctly
-        assert.equal(element._revisedRef, 'master');
-
-        assert.isFalse(saveBtn.disabled);
-
-        // Delete the ref.
-        element._revisedRef = '';
-        assert.isTrue(saveBtn.disabled);
-
-        // Change the ref to something else
-        element._revisedRef = 'newRef';
-        element._repo = 'test';
-        assert.isFalse(saveBtn.disabled);
-
-        // Save button calls handleSave. since this is stubbed, the edit
-        // section remains open.
-        MockInteractions.tap(saveBtn);
-        assert.isTrue(element._handleSaveRevision.called);
-
-        // When cancel is tapped, the edit secion closes.
-        MockInteractions.tap(cancelBtn);
-        await flush();
-
-        // The revision and edit button are visible.
-        assert.notEqual(getComputedStyle(revisionWithEditing).display,
-            'none');
-        assert.notEqual(getComputedStyle(editBtn).display, 'none');
-
-        // The input, cancel, and save buttons are not visible.
-        for (const item of hiddenElements) {
-          assert.equal(getComputedStyle(item).display, 'none');
-        }
-      });
-
-      test('_handleSaveRevision with invalid rev', async () => {
-        const event = {model: {set: sinon.stub()}};
-        element._isEditing = true;
-        stubRestApi('setRepoHead').returns(
-            Promise.resolve({
-              status: 400,
-            })
-        );
-
-        await element._setRepoHead('test', 'newRef', event);
-        assert.isTrue(element._isEditing);
-        assert.isFalse(event.model.set.called);
-      });
-
-      test('_handleSaveRevision with valid rev', async () => {
-        const event = {model: {set: sinon.stub()}};
-        element._isEditing = true;
-        stubRestApi('setRepoHead').returns(
-            Promise.resolve({
-              status: 200,
-            })
-        );
-
-        await element._setRepoHead('test', 'newRef', event);
-        assert.isFalse(element._isEditing);
-        assert.isTrue(event.model.set.called);
-      });
-
-      test('test _computeItemName', () => {
-        assert.deepEqual(element._computeItemName('branches'), 'Branch');
-        assert.deepEqual(element._computeItemName('tags'), 'Tag');
-      });
-    });
-
-    suite('list with less then 25 branches', () => {
-      setup(async () => {
-        branches = _.times(25, branchGenerator);
-        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
-
-        const params = {
-          repo: 'test',
-          detail: 'branches',
-        };
-
-        await element._paramsChanged(params);
-        await flush();
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      test('_paramsChanged', async () => {
-        const stub = stubRestApi('getRepoBranches').returns(
-            Promise.resolve(branches));
-        const params = {
-          detail: 'branches',
-          repo: 'test',
-          filter: 'test',
-          offset: 25,
-        };
-        await element._paramsChanged(params);
-        assert.equal(stub.lastCall.args[0], 'test');
-        assert.equal(stub.lastCall.args[1], 'test');
-        assert.equal(stub.lastCall.args[2], 25);
-        assert.equal(stub.lastCall.args[3], 25);
-      });
-    });
-
-    suite('404', () => {
-      test('fires page-error', async () => {
-        const response = {status: 404};
-        stubRestApi('getRepoBranches').callsFake(
-            (filter, repo, reposBranchesPerPage, opt_offset, errFn) => {
-              errFn(response);
-              return Promise.resolve();
-            });
-
-        const promise = mockPromise();
-        addListenerForTest(document, 'page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          promise.resolve();
-        });
-
-        const params = {
-          detail: 'branches',
-          repo: 'test',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params);
-        await promise;
-      });
-    });
-  });
-
-  suite('Tags', () => {
-    let element;
-    let tags;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.detailType = 'tags';
-      counter = 0;
-      sinon.stub(page, 'show');
-    });
-
-    test('_computeMessage', () => {
-      let message = 'v2.15-rc1↵-----BEGIN PGP SIGNATURE-----↵Version: GnuPG v' +
-      '1↵↵iQIcBAABAgAGBQJZ27O7AAoJEF/XxZqaEoiMy6kQAMoQCpGr3J6JITI4BVWsr7QM↵xy' +
-      'EcWH5YPUko5EPTbkABHmaVyFmKGkuIQdn6c+NIbqJOk+5XT4oUyRSo1T569HPJ↵3kyxEJi' +
-      'T1ryvp5BIHwdvHx58fjw1+YkiWLZuZq1FFkUYqnWTYCrkv7Fok98pdOmV↵CL1Hgugi5uK8' +
-      '/kxf1M7+Nv6piaZ140pwSb1h6QdAjaZVfaBCnoxlG4LRUqHvEYay↵f4QYgFT67auHIGkZ4' +
-      'moUcsp2Du/1jSsCWL/CPwjPFGbbckVAjLCMT9yD3NKwpEZF↵pfsiZyHI9dL0M+QjVrM+RD' +
-      'HwIIJwra8R0IMkDlQ6MDrFlKNqNBbo588S6UPrm71L↵YuiwWlcrK9ZIybxT6LzbR65Rvez' +
-      'DSitQ+xeIfpZE19/X6BCnvlARLE8k/tC2JksI↵lEZi7Lf3FQdIcwwyt98tJkS9HX9v9jbC' +
-      '5QXifnoj3Li8tHSLuQ1dJCxHQiis6ojI↵OWUFkm0IHBXVNHA2dqYBdM+pL12mlI3wp6Ica' +
-      '4cdEVDwzu+j1xnVSFUa+d+Y2xJF↵7mytuyhHiKG4hm+zbhMv6WD8Q3FoDsJZeLY99l0hYQ' +
-      'SnnkMduFVroIs45pAs8gUA↵RvYla8mm9w/543IJAPzzFarPVLSsSyQ7tJl3UBzjKRNH/rX' +
-      'W+F22qyWD1zyHPUIR↵C00ItmwlAvveImYKpQAH↵=L+K9↵-----END PGP SIGNATURE---' +
-      '--';
-      assert.equal(element._computeMessage(message), 'v2.15-rc1↵');
-      message = 'v2.15-rc1';
-      assert.equal(element._computeMessage(message), 'v2.15-rc1');
-    });
-
-    suite('list of repo tags', () => {
-      setup(async () => {
-        tags = _.times(26, tagGenerator);
-        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
-
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-        };
-
-        await element._paramsChanged(params);
-        await flush();
-      });
-
-      test('test for tag in the list', async () => {
-        assert.equal(element._items[1].ref, 'refs/tags/test2');
-      });
-
-      test('test for tag message in the list', async () => {
-        assert.equal(element._items[1].message, 'Annotated tag');
-      });
-
-      test('test for tagger in the tag list', async () => {
-        const tagger = {
-          name: 'Test User',
-          email: 'test.user@gmail.com',
-          date: '2017-09-19 14:54:00.000000000',
-          tz: 540,
-        };
-
-        assert.deepEqual(element._items[1].tagger, tagger);
-      });
-
-      test('test for web links in the tags list', async () => {
-        assert.equal(element._items[1].web_links[0].url,
-            'https://git.example.org/tag/test;refs/tags/test2');
-      });
-
-      test('test for refs/tags/ being striped from ref', async () => {
-        assert.equal(element._stripRefs(element._items[1].ref,
-            element.detailType), 'test2');
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-
-      test('_computeHideTagger', () => {
-        const testObject1 = {
-          tagger: 'test',
-        };
-        assert.equal(element._computeHideTagger(testObject1), '');
-
-        assert.equal(element._computeHideTagger(undefined), 'hide');
-      });
-    });
-
-    suite('list with less then 25 tags', () => {
-      setup(async () => {
-        tags = _.times(25, tagGenerator);
-        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
-
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-        };
-
-        await element._paramsChanged(params);
-        await flush();
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      test('_paramsChanged', async () => {
-        const stub = stubRestApi('getRepoTags').returns(Promise.resolve(tags));
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-          filter: 'test',
-          offset: 25,
-        };
-        await element._paramsChanged(params);
-        assert.equal(stub.lastCall.args[0], 'test');
-        assert.equal(stub.lastCall.args[1], 'test');
-        assert.equal(stub.lastCall.args[2], 25);
-        assert.equal(stub.lastCall.args[3], 25);
-      });
-    });
-
-    suite('create new', () => {
-      test('_handleCreateClicked called when create-click fired', () => {
-        sinon.stub(element, '_handleCreateClicked');
-        element.shadowRoot
-            .querySelector('gr-list-view').dispatchEvent(
-                new CustomEvent('create-clicked', {
-                  composed: true, bubbles: true,
-                }));
-        assert.isTrue(element._handleCreateClicked.called);
-      });
-
-      test('_handleCreateClicked opens modal', () => {
-        const openStub = sinon.stub(element.$.createOverlay, 'open');
-        element._handleCreateClicked();
-        assert.isTrue(openStub.called);
-      });
-
-      test('_handleCreateItem called when confirm fired', () => {
-        sinon.stub(element, '_handleCreateItem');
-        element.$.createDialog.dispatchEvent(
-            new CustomEvent('confirm', {
-              composed: true, bubbles: true,
-            }));
-        assert.isTrue(element._handleCreateItem.called);
-      });
-
-      test('_handleCloseCreate called when cancel fired', () => {
-        sinon.stub(element, '_handleCloseCreate');
-        element.$.createDialog.dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-        assert.isTrue(element._handleCloseCreate.called);
-      });
-    });
-
-    suite('404', () => {
-      test('fires page-error', async () => {
-        const response = {status: 404};
-        stubRestApi('getRepoTags').callsFake(
-            (filter, repo, reposTagsPerPage, opt_offset, errFn) => {
-              errFn(response);
-              return Promise.resolve();
-            });
-
-        const promise = mockPromise();
-        addListenerForTest(document, 'page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          promise.resolve();
-        });
-
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params);
-        await promise;
-      });
-    });
-
-    test('test _computeHideDeleteClass', () => {
-      assert.deepEqual(element._computeHideDeleteClass(true, false), 'show');
-      assert.deepEqual(element._computeHideDeleteClass(false, true), 'show');
-      assert.deepEqual(element._computeHideDeleteClass(false, false), '');
-    });
-
-    test('_computeItemName', () => {
-      assert.equal(element._computeItemName(RepoDetailView.BRANCHES), 'Branch');
-      assert.equal(element._computeItemName(RepoDetailView.TAGS),
-          'Tag');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
new file mode 100644
index 0000000..a82c4e3
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
@@ -0,0 +1,602 @@
+/**
+ * @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-repo-detail-list.js';
+import {GrRepoDetailList} from './gr-repo-detail-list';
+import {page} from '../../../utils/page-wrapper-utils';
+import {
+  addListenerForTest,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+import {
+  BranchInfo,
+  EmailAddress,
+  GitRef,
+  GroupId,
+  GroupName,
+  ProjectAccessGroups,
+  ProjectAccessInfoMap,
+  RepoName,
+  TagInfo,
+  Timestamp,
+  TimezoneOffset,
+} from '../../../types/common';
+import {GerritView} from '../../../services/router/router-model';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {PageErrorEvent} from '../../../types/events';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrListView} from '../../shared/gr-list-view/gr-list-view';
+import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+
+const basicFixture = fixtureFromElement('gr-repo-detail-list');
+
+function branchGenerator(counter: number) {
+  return {
+    ref: `refs/heads/test${counter}` as GitRef,
+    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://git.example.org/branch/test;refs/heads/test${counter}`,
+      },
+    ],
+  };
+}
+
+function createBranchesList(n: number) {
+  const branches = [];
+  for (let i = 0; i < n; ++i) {
+    branches.push(branchGenerator(i));
+  }
+  return branches;
+}
+
+function tagGenerator(counter: number) {
+  return {
+    ref: `refs/tags/test${counter}` as GitRef,
+    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+    can_delete: false,
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://git.example.org/tag/test;refs/tags/test${counter}`,
+      },
+    ],
+    message: 'Annotated tag',
+    tagger: {
+      name: 'Test User',
+      email: 'test.user@gmail.com' as EmailAddress,
+      date: '2017-09-19 14:54:00.000000000' as Timestamp,
+      tz: 540 as TimezoneOffset,
+    },
+  };
+}
+
+function createTagsList(n: number) {
+  const tags = [];
+  for (let i = 0; i < n; ++i) {
+    tags.push(tagGenerator(i));
+  }
+  return tags;
+}
+
+suite('gr-repo-detail-list', () => {
+  suite('Branches', () => {
+    let element: GrRepoDetailList;
+    let branches: BranchInfo[];
+
+    setup(async () => {
+      element = basicFixture.instantiate();
+      await element.updateComplete;
+      element.detailType = RepoDetailView.BRANCHES;
+      sinon.stub(page, 'show');
+    });
+
+    suite('list of repo branches', () => {
+      setup(async () => {
+        branches = [
+          {
+            ref: 'HEAD' as GitRef,
+            revision: 'master',
+          },
+        ].concat(createBranchesList(25));
+        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.BRANCHES,
+        };
+        await element.paramsChanged();
+        await element.updateComplete;
+      });
+
+      test('test for branch in the list', () => {
+        assert.equal(element.items![3].ref, 'refs/heads/test2');
+      });
+
+      test('test for web links in the branches list', () => {
+        assert.equal(
+          element.items![3].web_links![0].url,
+          'https://git.example.org/branch/test;refs/heads/test2'
+        );
+      });
+
+      test('test for refs/heads/ being striped from ref', () => {
+        assert.equal(
+          element.stripRefs(element.items![3].ref, element.detailType),
+          'test2'
+        );
+      });
+
+      test('items', () => {
+        assert.equal(queryAll<HTMLTableElement>(element, '.table').length, 25);
+      });
+
+      test('Edit HEAD button not admin', async () => {
+        sinon.stub(element, 'getLoggedIn').returns(Promise.resolve(true));
+        stubRestApi('getRepoAccess').returns(
+          Promise.resolve({
+            test: {
+              revision: 'xxxx',
+              local: {
+                'refs/*': {
+                  permissions: {
+                    owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
+                  },
+                },
+              },
+              owner_of: ['refs/*'] as GitRef[],
+              groups: {
+                xxxx: {
+                  id: 'xxxx' as GroupId,
+                  url: 'test',
+                  name: 'test' as GroupName,
+                },
+              } as ProjectAccessGroups,
+              config_web_links: [{name: 'gitiles', url: 'test'}],
+            },
+          } as ProjectAccessInfoMap)
+        );
+        await element.determineIfOwner('test' as RepoName);
+        assert.equal(element.isOwner, false);
+        assert.equal(
+          getComputedStyle(
+            queryAndAssert<HTMLSpanElement>(element, '.revisionNoEditing')
+          ).display,
+          'inline'
+        );
+        assert.equal(
+          getComputedStyle(
+            queryAndAssert<HTMLSpanElement>(element, '.revisionEdit')
+          ).display,
+          'none'
+        );
+      });
+
+      test('Edit HEAD button admin', async () => {
+        const saveBtn = queryAndAssert<GrButton>(element, '.saveBtn');
+        const cancelBtn = queryAndAssert<GrButton>(element, '.cancelBtn');
+        const editBtn = queryAndAssert<GrButton>(element, '.editBtn');
+        const revisionNoEditing = queryAndAssert<HTMLSpanElement>(
+          element,
+          '.revisionNoEditing'
+        );
+        const revisionWithEditing = queryAndAssert<HTMLSpanElement>(
+          element,
+          '.revisionWithEditing'
+        );
+
+        sinon.stub(element, 'getLoggedIn').returns(Promise.resolve(true));
+        stubRestApi('getRepoAccess').returns(
+          Promise.resolve({
+            test: {
+              revision: 'xxxx',
+              local: {
+                'refs/*': {
+                  permissions: {
+                    owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
+                  },
+                },
+              },
+              is_owner: true,
+              owner_of: ['refs/*'] as GitRef[],
+              groups: {
+                xxxx: {
+                  id: 'xxxx' as GroupId,
+                  url: 'test',
+                  name: 'test' as GroupName,
+                },
+              } as ProjectAccessGroups,
+              config_web_links: [{name: 'gitiles', url: 'test'}],
+            },
+          } as ProjectAccessInfoMap)
+        );
+        const handleSaveRevisionStub = sinon.stub(
+          element,
+          'handleSaveRevision'
+        );
+        await element.determineIfOwner('test' as RepoName);
+        assert.equal(element.isOwner, true);
+        // The revision container for non-editing enabled row is not visible.
+        assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
+
+        // The revision container for editing enabled row is visible.
+        assert.notEqual(
+          getComputedStyle(
+            queryAndAssert<HTMLSpanElement>(element, '.revisionEdit')
+          ).display,
+          'none'
+        );
+
+        // The revision and edit button are visible.
+        assert.notEqual(getComputedStyle(revisionWithEditing).display, 'none');
+        assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+        // The input, cancel, and save buttons are not visible.
+        const hiddenElements = queryAll<HTMLTableElement>(
+          element,
+          '.canEdit .editItem'
+        );
+
+        for (const item of hiddenElements) {
+          assert.equal(getComputedStyle(item).display, 'none');
+        }
+
+        MockInteractions.tap(editBtn);
+        await element.updateComplete;
+        // The revision and edit button are not visible.
+        assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
+        assert.equal(getComputedStyle(editBtn).display, 'none');
+
+        // The input, cancel, and save buttons are not visible.
+        for (const item of hiddenElements) {
+          assert.notEqual(getComputedStyle(item).display, 'none');
+        }
+
+        // The revised ref was set correctly
+        assert.equal(element.revisedRef, 'master' as GitRef);
+
+        assert.isFalse(saveBtn.disabled);
+
+        // Delete the ref.
+        element.revisedRef = '' as GitRef;
+        await element.updateComplete;
+        assert.isTrue(saveBtn.disabled);
+
+        // Change the ref to something else
+        element.revisedRef = 'newRef' as GitRef;
+        element.repo = 'test' as RepoName;
+        await element.updateComplete;
+        assert.isFalse(saveBtn.disabled);
+
+        // Save button calls handleSave. since this is stubbed, the edit
+        // section remains open.
+        MockInteractions.tap(saveBtn);
+        assert.isTrue(handleSaveRevisionStub.called);
+
+        // When cancel is tapped, the edit secion closes.
+        MockInteractions.tap(cancelBtn);
+        await element.updateComplete;
+
+        // The revision and edit button are visible.
+        assert.notEqual(getComputedStyle(revisionWithEditing).display, 'none');
+        assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+        // The input, cancel, and save buttons are not visible.
+        for (const item of hiddenElements) {
+          assert.equal(getComputedStyle(item).display, 'none');
+        }
+      });
+
+      test('handleSaveRevision with invalid rev', async () => {
+        element.isEditing = true;
+        stubRestApi('setRepoHead').returns(
+          Promise.resolve({
+            status: 400,
+          } as Response)
+        );
+
+        await element.setRepoHead('test' as RepoName, 'newRef' as GitRef, 1);
+        assert.isTrue(element.isEditing);
+      });
+
+      test('handleSaveRevision with valid rev', async () => {
+        element.isEditing = true;
+        stubRestApi('setRepoHead').returns(
+          Promise.resolve({
+            status: 200,
+          } as Response)
+        );
+
+        await element.setRepoHead('test' as RepoName, 'newRef' as GitRef, 1);
+        assert.isFalse(element.isEditing);
+      });
+
+      test('test computeItemName', () => {
+        assert.deepEqual(
+          element.computeItemName(RepoDetailView.BRANCHES),
+          'Branch'
+        );
+        assert.deepEqual(element.computeItemName(RepoDetailView.TAGS), 'Tag');
+      });
+    });
+
+    suite('list with less then 25 branches', () => {
+      setup(async () => {
+        branches = createBranchesList(25);
+        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.BRANCHES,
+        };
+
+        await element.paramsChanged();
+        await element.updateComplete;
+      });
+
+      test('items', () => {
+        assert.equal(queryAll<HTMLTableElement>(element, '.table').length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('paramsChanged', async () => {
+        const stub = stubRestApi('getRepoBranches').returns(
+          Promise.resolve(branches)
+        );
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.BRANCHES,
+          filter: 'test',
+          offset: 25,
+        };
+        await element.paramsChanged();
+        assert.equal(stub.lastCall.args[0], 'test');
+        assert.equal(stub.lastCall.args[1], 'test');
+        assert.equal(stub.lastCall.args[2], 25);
+        assert.equal(stub.lastCall.args[3], 25);
+      });
+    });
+
+    suite('404', () => {
+      test('fires page-error', async () => {
+        const response = {status: 404} as Response;
+        stubRestApi('getRepoBranches').callsFake(
+          (_filter, _repo, _reposBranchesPerPage, _opt_offset, errFn) => {
+            if (errFn !== undefined) {
+              errFn(response);
+            }
+            return Promise.resolve([]);
+          }
+        );
+
+        const promise = mockPromise();
+        addListenerForTest(document, 'page-error', e => {
+          assert.deepEqual((e as PageErrorEvent).detail.response, response);
+          promise.resolve();
+        });
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.BRANCHES,
+          filter: 'test',
+          offset: 25,
+        };
+        element.paramsChanged();
+        await promise;
+      });
+    });
+  });
+
+  suite('Tags', () => {
+    let element: GrRepoDetailList;
+    let tags: TagInfo[];
+
+    setup(async () => {
+      element = basicFixture.instantiate();
+      await element.updateComplete;
+      element.detailType = RepoDetailView.TAGS;
+      sinon.stub(page, 'show');
+    });
+
+    suite('list of repo tags', () => {
+      setup(async () => {
+        tags = createTagsList(26);
+        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.TAGS,
+        };
+
+        await element.paramsChanged();
+        await element.updateComplete;
+      });
+
+      test('test for tag in the list', async () => {
+        assert.equal(element.items![2].ref, 'refs/tags/test2');
+      });
+
+      test('test for tag message in the list', async () => {
+        assert.equal((element.items as TagInfo[])![2].message, 'Annotated tag');
+      });
+
+      test('test for tagger in the tag list', async () => {
+        const tagger = {
+          name: 'Test User',
+          email: 'test.user@gmail.com' as EmailAddress,
+          date: '2017-09-19 14:54:00.000000000' as Timestamp,
+          tz: 540 as TimezoneOffset,
+        };
+
+        assert.deepEqual((element.items as TagInfo[])![2].tagger, tagger);
+      });
+
+      test('test for web links in the tags list', async () => {
+        assert.equal(
+          element.items![2].web_links![0].url,
+          'https://git.example.org/tag/test;refs/tags/test2'
+        );
+      });
+
+      test('test for refs/tags/ being striped from ref', async () => {
+        assert.equal(
+          element.stripRefs(element.items![2].ref, element.detailType),
+          'test2'
+        );
+      });
+
+      test('items', () => {
+        assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+      });
+    });
+
+    suite('list with less then 25 tags', () => {
+      setup(async () => {
+        tags = createTagsList(25);
+        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.TAGS,
+        };
+
+        await element.paramsChanged();
+        await element.updateComplete;
+      });
+
+      test('items', () => {
+        assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('paramsChanged', async () => {
+        const stub = stubRestApi('getRepoTags').returns(Promise.resolve(tags));
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.TAGS,
+          filter: 'test',
+          offset: 25,
+        };
+        await element.paramsChanged();
+        assert.equal(stub.lastCall.args[0], 'test');
+        assert.equal(stub.lastCall.args[1], 'test');
+        assert.equal(stub.lastCall.args[2], 25);
+        assert.equal(stub.lastCall.args[3], 25);
+      });
+    });
+
+    suite('create new', () => {
+      test('handleCreateClicked called when create-click fired', () => {
+        const handleCreateClickedStub = sinon.stub(
+          element,
+          'handleCreateClicked'
+        );
+        queryAndAssert<GrListView>(element, 'gr-list-view').dispatchEvent(
+          new CustomEvent('create-clicked', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+        assert.isTrue(handleCreateClickedStub.called);
+      });
+
+      test('handleCreateClicked opens modal', () => {
+        queryAndAssert<GrOverlay>(element, '#createOverlay');
+        const openStub = sinon.stub(
+          queryAndAssert<GrOverlay>(element, '#createOverlay'),
+          'open'
+        );
+        element.handleCreateClicked();
+        assert.isTrue(openStub.called);
+      });
+
+      test('handleCreateItem called when confirm fired', () => {
+        const handleCreateItemStub = sinon.stub(element, 'handleCreateItem');
+        queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
+          new CustomEvent('confirm', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+        assert.isTrue(handleCreateItemStub.called);
+      });
+
+      test('handleCloseCreate called when cancel fired', () => {
+        const handleCloseCreateStub = sinon.stub(element, 'handleCloseCreate');
+        queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
+          new CustomEvent('cancel', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+        assert.isTrue(handleCloseCreateStub.called);
+      });
+    });
+
+    suite('404', () => {
+      test('fires page-error', async () => {
+        const response = {status: 404} as Response;
+        stubRestApi('getRepoTags').callsFake(
+          (_filter, _repo, _reposTagsPerPage, _opt_offset, errFn) => {
+            if (errFn !== undefined) {
+              errFn(response);
+            }
+            return Promise.resolve([]);
+          }
+        );
+
+        const promise = mockPromise();
+        addListenerForTest(document, 'page-error', e => {
+          assert.deepEqual((e as PageErrorEvent).detail.response, response);
+          promise.resolve();
+        });
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.TAGS,
+          filter: 'test',
+          offset: 25,
+        };
+        element.paramsChanged();
+        await promise;
+      });
+    });
+
+    test('computeItemName', () => {
+      assert.equal(element.computeItemName(RepoDetailView.BRANCHES), 'Branch');
+      assert.equal(element.computeItemName(RepoDetailView.TAGS), 'Tag');
+    });
+  });
+});
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 ff9f665..0c2ca7c 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
@@ -14,24 +14,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
 import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo-list_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, observe, computed} from '@polymer/decorators';
 import {AppElementAdminParams} from '../../gr-app-types';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {RepoName, ProjectInfoWithName} from '../../../types/common';
+import {
+  RepoName,
+  ProjectInfoWithName,
+  WebLinkInfo,
+} from '../../../types/common';
 import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
 import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -39,149 +42,260 @@
   }
 }
 
-export interface GrRepoList {
-  $: {
-    createOverlay: GrOverlay;
-    createNewModal: GrCreateRepoDialog;
-  };
-}
-
 @customElement('gr-repo-list')
-export class GrRepoList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrRepoList extends LitElement {
+  readonly path = '/admin/repos';
+
+  @query('#createOverlay') private createOverlay?: GrOverlay;
+
+  @query('#createNewModal') private createNewModal?: GrCreateRepoDialog;
 
   @property({type: Object})
   params?: AppElementAdminParams;
 
-  @property({type: Number})
-  _offset = 0;
+  // private but used in test
+  @state() offset = 0;
 
-  @property({type: String})
-  readonly _path = '/admin/repos';
+  @state() private newRepoName = false;
 
-  @property({type: Boolean})
-  _hasNewRepoName = false;
+  @state() private createNewCapability = false;
 
-  @property({type: Boolean})
-  _createNewCapability = false;
+  // private but used in test
+  @state() repos: ProjectInfoWithName[] = [];
 
-  @property({type: Array})
-  _repos: ProjectInfoWithName[] = [];
+  // private but used in test
+  @state() reposPerPage = 25;
 
-  @property({type: Number})
-  _reposPerPage = 25;
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() filter = '';
 
-  @property({type: String})
-  _filter = '';
+  private readonly restApiService = getAppContext().restApiService;
 
-  @computed('_repos')
-  get _shownRepos() {
-    return this._repos.slice(0, SHOWN_ITEMS_COUNT);
-  }
-
-  private readonly restApiService = appContext.restApiService;
-
-  override connectedCallback() {
+  override async connectedCallback() {
     super.connectedCallback();
-    this._getCreateRepoCapability();
+    await this.getCreateRepoCapability();
     fireTitleChange(this, 'Repos');
-    this._maybeOpenCreateOverlay(this.params);
+    this.maybeOpenCreateOverlay(this.params);
   }
 
-  @observe('params')
-  _paramsChanged(params: AppElementAdminParams) {
-    this._loading = true;
-    this._filter = params?.filter ?? '';
-    this._offset = Number(params?.offset ?? 0);
+  static override get styles() {
+    return [
+      tableStyles,
+      sharedStyles,
+      css`
+        .genericList tr td:last-of-type {
+          text-align: left;
+        }
+        .genericList tr th:last-of-type {
+          text-align: left;
+        }
+        .readOnly {
+          text-align: center;
+        }
+        .changesLink,
+        .name,
+        .repositoryBrowser,
+        .readOnly {
+          white-space: nowrap;
+        }
+      `,
+    ];
+  }
 
-    return this._getRepos(this._filter, this._reposPerPage, this._offset);
+  override render() {
+    return html`
+      <gr-list-view
+        .createNew=${this.createNewCapability}
+        .filter=${this.filter}
+        .itemsPerPage=${this.reposPerPage}
+        .items=${this.repos}
+        .loading=${this.loading}
+        .offset=${this.offset}
+        .path=${this.path}
+        @create-clicked=${() => this.handleCreateClicked()}
+      >
+        <table id="list" class="genericList">
+          <tbody>
+            <tr class="headerRow">
+              <th class="name topHeader">Repository Name</th>
+              <th class="repositoryBrowser topHeader">Repository Browser</th>
+              <th class="changesLink topHeader">Changes</th>
+              <th class="topHeader readOnly">Read only</th>
+              <th class="description topHeader">Repository Description</th>
+            </tr>
+            <tr
+              id="loading"
+              class="loadingMsg ${this.computeLoadingClass(this.loading)}"
+            >
+              <td>Loading...</td>
+            </tr>
+          </tbody>
+          <tbody class=${this.computeLoadingClass(this.loading)}>
+            ${this.renderRepoList()}
+          </tbody>
+        </table>
+      </gr-list-view>
+      <gr-overlay id="createOverlay" with-backdrop>
+        <gr-dialog
+          id="createDialog"
+          class="confirmDialog"
+          ?disabled=${!this.newRepoName}
+          confirm-label="Create"
+          @confirm=${() => this.handleCreateRepo()}
+          @cancel=${() => this.handleCloseCreate()}
+        >
+          <div class="header" slot="header">Create Repository</div>
+          <div class="main" slot="main">
+            <gr-create-repo-dialog
+              id="createNewModal"
+              @new-repo-name=${() => this.handleNewRepoName()}
+            ></gr-create-repo-dialog>
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private renderRepoList() {
+    const shownRepos = this.repos.slice(0, SHOWN_ITEMS_COUNT);
+    return shownRepos.map(item => this.renderRepo(item));
+  }
+
+  private renderRepo(item: ProjectInfoWithName) {
+    return html`
+      <tr class="table">
+        <td class="name">
+          <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>
+        </td>
+        <td class="readOnly">
+          ${item.state === ProjectState.READ_ONLY ? 'Y' : ''}
+        </td>
+        <td class="description">${item.description}</td>
+      </tr>
+    `;
+  }
+
+  private renderWebLinks(links: ProjectInfoWithName) {
+    const webLinks = links.web_links ? links.web_links : [];
+    return webLinks.map(link => this.renderWebLink(link));
+  }
+
+  private renderWebLink(link: WebLinkInfo) {
+    return html`
+      <a href=${link.url} class="webLink" rel="noopener" target="_blank">
+        ${link.name}
+      </a>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this._paramsChanged();
+    }
+  }
+
+  async _paramsChanged() {
+    const params = this.params;
+    this.loading = true;
+    this.filter = params?.filter ?? '';
+    this.offset = Number(params?.offset ?? 0);
+
+    return await this.getRepos();
   }
 
   /**
-   * Opens the create overlay if the route has a hash 'create'
+   * Opens the create overlay if the route has a hash 'create'.
+   *
+   * private but used in test
    */
-  _maybeOpenCreateOverlay(params?: AppElementAdminParams) {
+  maybeOpenCreateOverlay(params?: AppElementAdminParams) {
     if (params?.openCreateModal) {
-      this.$.createOverlay.open();
+      this.createOverlay?.open();
     }
   }
 
-  _computeRepoUrl(name: string) {
-    return getBaseUrl() + this._path + '/' + encodeURL(name, true);
+  private computeRepoUrl(name: string) {
+    return `${getBaseUrl()}${this.path}/${encodeURL(name, true)}`;
   }
 
-  _computeChangesLink(name: string) {
+  private computeChangesLink(name: string) {
     return GerritNav.getUrlForProjectChanges(name as RepoName);
   }
 
-  _getCreateRepoCapability() {
-    return this.restApiService.getAccount().then(account => {
-      if (!account) {
-        return;
-      }
-      return this.restApiService
-        .getAccountCapabilities(['createProject'])
-        .then(capabilities => {
-          if (capabilities?.createProject) {
-            this._createNewCapability = true;
-          }
-        });
-    });
-  }
+  private async getCreateRepoCapability() {
+    const account = await this.restApiService.getAccount();
 
-  _getRepos(filter: string, reposPerPage: number, offset?: number) {
-    this._repos = [];
-    return this.restApiService
-      .getRepos(filter, reposPerPage, offset)
-      .then(repos => {
-        // Late response.
-        if (filter !== this._filter || !repos) {
-          return;
-        }
-        this._repos = repos;
-        this._loading = false;
-      });
-  }
+    if (!account) return;
 
-  _refreshReposList() {
-    this.restApiService.invalidateReposCache();
-    return this._getRepos(this._filter, this._reposPerPage, this._offset);
-  }
-
-  _handleCreateRepo() {
-    this.$.createNewModal.handleCreateRepo().then(() => {
-      this._refreshReposList();
-    });
-  }
-
-  _handleCloseCreate() {
-    this.$.createOverlay.close();
-  }
-
-  _handleCreateClicked() {
-    this.$.createOverlay.open().then(() => {
-      this.$.createNewModal.focus();
-    });
-  }
-
-  _readOnly(repo: ProjectInfoWithName) {
-    return repo.state === ProjectState.READ_ONLY ? 'Y' : '';
-  }
-
-  _computeWeblink(repo: ProjectInfoWithName) {
-    if (!repo.web_links) {
-      return '';
+    const accountCapabilities =
+      await this.restApiService.getAccountCapabilities(['createProject']);
+    if (accountCapabilities?.createProject) {
+      this.createNewCapability = true;
     }
-    const webLinks = repo.web_links;
-    return webLinks.length ? webLinks : null;
+
+    return account;
   }
 
+  // private but used in test
+  async getRepos() {
+    this.repos = [];
+
+    // We save the filter before getting the repos
+    // and then we check the value hasn't changed aftwards.
+    const filter = this.filter;
+
+    const repos = await this.restApiService.getRepos(
+      this.filter,
+      this.reposPerPage,
+      this.offset
+    );
+
+    // Late response.
+    if (filter !== this.filter || !repos) return;
+
+    this.repos = repos;
+    this.loading = false;
+
+    return repos;
+  }
+
+  private async refreshReposList() {
+    this.restApiService.invalidateReposCache();
+    return await this.getRepos();
+  }
+
+  // private but used in test
+  async handleCreateRepo() {
+    await this.createNewModal?.handleCreateRepo();
+    await this.refreshReposList();
+  }
+
+  // private but used in test
+  handleCloseCreate() {
+    this.createOverlay?.close();
+  }
+
+  // private but used in test
+  handleCreateClicked() {
+    this.createOverlay?.open().then(() => {
+      this.createNewModal?.focus();
+    });
+  }
+
+  // private but used in test
   computeLoadingClass(loading: boolean) {
     return loading ? 'loading' : '';
   }
+
+  private handleNewRepoName() {
+    if (!this.createNewModal) return;
+    this.newRepoName = this.createNewModal.nameChanged;
+  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
deleted file mode 100644
index e1a7f489..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
+++ /dev/null
@@ -1,116 +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-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style>
-    .genericList tr td:last-of-type {
-      text-align: left;
-    }
-    .genericList tr th:last-of-type {
-      text-align: left;
-    }
-    .readOnly {
-      text-align: center;
-    }
-    .changesLink,
-    .name,
-    .repositoryBrowser,
-    .readOnly {
-      white-space: nowrap;
-    }
-  </style>
-  <gr-list-view
-    create-new="[[_createNewCapability]]"
-    filter="[[_filter]]"
-    items-per-page="[[_reposPerPage]]"
-    items="[[_repos]]"
-    loading="[[_loading]]"
-    offset="[[_offset]]"
-    on-create-clicked="_handleCreateClicked"
-    path="[[_path]]"
-  >
-    <table id="list" class="genericList">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Repository Name</th>
-          <th class="repositoryBrowser topHeader">Repository Browser</th>
-          <th class="changesLink topHeader">Changes</th>
-          <th class="topHeader readOnly">Read only</th>
-          <th class="description topHeader">Repository Description</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_shownRepos]]">
-          <tr class="table">
-            <td class="name">
-              <a href$="[[_computeRepoUrl(item.name)]]">[[item.name]]</a>
-            </td>
-            <td class="repositoryBrowser">
-              <template
-                is="dom-repeat"
-                items="[[_computeWeblink(item)]]"
-                as="link"
-              >
-                <a
-                  href$="[[link.url]]"
-                  class="webLink"
-                  rel="noopener"
-                  target="_blank"
-                >
-                  [[link.name]]
-                </a>
-              </template>
-            </td>
-            <td class="changesLink">
-              <a href$="[[_computeChangesLink(item.name)]]">view all</a>
-            </td>
-            <td class="readOnly">[[_readOnly(item)]]</td>
-            <td class="description">[[item.description]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </gr-list-view>
-  <gr-overlay id="createOverlay" with-backdrop="">
-    <gr-dialog
-      id="createDialog"
-      class="confirmDialog"
-      disabled="[[!_hasNewRepoName]]"
-      confirm-label="Create"
-      on-confirm="_handleCreateRepo"
-      on-cancel="_handleCloseCreate"
-    >
-      <div class="header" slot="header">Create Repository</div>
-      <div class="main" slot="main">
-        <gr-create-repo-dialog
-          has-new-repo-name="{{_hasNewRepoName}}"
-          id="createNewModal"
-        ></gr-create-repo-dialog>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
deleted file mode 100644
index 8fef4d0..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
+++ /dev/null
@@ -1,189 +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-repo-list.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import 'lodash/lodash.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-repo-list');
-
-function createRepo(name, counter) {
-  return {
-    id: `${name}${counter}`,
-    name: `${name}`,
-    state: 'ACTIVE',
-    web_links: [
-      {
-        name: 'diffusion',
-        url: `https://phabricator.example.org/r/project/${name}${counter}`,
-      },
-    ],
-  };
-}
-
-let counter;
-const repoGenerator = () => createRepo('test', ++counter);
-
-suite('gr-repo-list tests', () => {
-  let element;
-  let repos;
-
-  let value;
-
-  setup(() => {
-    sinon.stub(page, 'show');
-    element = basicFixture.instantiate();
-    counter = 0;
-  });
-
-  suite('list with repos', () => {
-    setup(async () => {
-      repos = _.times(26, repoGenerator);
-      stubRestApi('getRepos').returns(Promise.resolve(repos));
-      await element._paramsChanged(value);
-      await flush();
-    });
-
-    test('test for test repo in the list', async () => {
-      await flush();
-      assert.equal(element._repos[1].id, 'test2');
-    });
-
-    test('_shownRepos', () => {
-      assert.equal(element._shownRepos.length, 25);
-    });
-
-    test('_maybeOpenCreateOverlay', () => {
-      const overlayOpen = sinon.stub(element.$.createOverlay, 'open');
-      element._maybeOpenCreateOverlay();
-      assert.isFalse(overlayOpen.called);
-      const params = {};
-      element._maybeOpenCreateOverlay(params);
-      assert.isFalse(overlayOpen.called);
-      params.openCreateModal = true;
-      element._maybeOpenCreateOverlay(params);
-      assert.isTrue(overlayOpen.called);
-    });
-  });
-
-  suite('list with less then 25 repos', () => {
-    setup(async () => {
-      repos = _.times(25, repoGenerator);
-      stubRestApi('getRepos').returns(Promise.resolve(repos));
-      await element._paramsChanged(value);
-      await flush();
-    });
-
-    test('_shownRepos', () => {
-      assert.equal(element._shownRepos.length, 25);
-    });
-  });
-
-  suite('filter', () => {
-    let reposFiltered;
-    setup(() => {
-      repos = _.times(25, repoGenerator);
-      reposFiltered = _.times(1, repoGenerator);
-    });
-
-    test('_paramsChanged', async () => {
-      const repoStub = stubRestApi('getRepos');
-      repoStub.returns(Promise.resolve(repos));
-      const value = {
-        filter: 'test',
-        offset: 25,
-      };
-      await element._paramsChanged(value);
-      assert.isTrue(repoStub.lastCall.calledWithExactly('test', 25, 25));
-    });
-
-    test('latest repos requested are always set', async () => {
-      const repoStub = stubRestApi('getRepos');
-      repoStub.withArgs('test').returns(Promise.resolve(repos));
-      repoStub.withArgs('filter').returns(Promise.resolve(reposFiltered));
-      element._filter = 'test';
-
-      // Repos are not set because the element._filter differs.
-      await element._getRepos('filter', 25, 0);
-      assert.deepEqual(element._repos, []);
-    });
-
-    test('filter is case insensitive', async () => {
-      const repoStub = stubRestApi('getRepos');
-      const repos = [createRepo('aSDf', 0)];
-      repoStub.withArgs('asdf').returns(Promise.resolve(repos));
-      element._filter = 'asdf';
-      await element._getRepos('asdf', 25, 0);
-      assert.equal(element._repos.length, 1);
-    });
-  });
-
-  suite('loading', () => {
-    test('correct contents are displayed', () => {
-      assert.isTrue(element._loading);
-      assert.equal(element.computeLoadingClass(element._loading), 'loading');
-      assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-      element._loading = false;
-      element._repos = _.times(25, repoGenerator);
-
-      flush();
-      assert.equal(element.computeLoadingClass(element._loading), '');
-      assert.equal(getComputedStyle(element.$.loading).display, 'none');
-    });
-  });
-
-  suite('create new', () => {
-    test('_handleCreateClicked called when create-click fired', () => {
-      sinon.stub(element, '_handleCreateClicked');
-      element.shadowRoot
-          .querySelector('gr-list-view').dispatchEvent(
-              new CustomEvent('create-clicked', {
-                composed: true, bubbles: true,
-              }));
-      assert.isTrue(element._handleCreateClicked.called);
-    });
-
-    test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
-          Promise.resolve());
-      element._handleCreateClicked();
-      assert.isTrue(openStub.called);
-    });
-
-    test('_handleCreateRepo called when confirm fired', () => {
-      sinon.stub(element, '_handleCreateRepo');
-      element.$.createDialog.dispatchEvent(
-          new CustomEvent('confirm', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCreateRepo.called);
-    });
-
-    test('_handleCloseCreate called when cancel fired', () => {
-      sinon.stub(element, '_handleCloseCreate');
-      element.$.createDialog.dispatchEvent(
-          new CustomEvent('cancel', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCloseCreate.called);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
new file mode 100644
index 0000000..1d4e360
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
@@ -0,0 +1,257 @@
+/**
+ * @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-repo-list';
+import {GrRepoList} from './gr-repo-list';
+import {page} from '../../../utils/page-wrapper-utils';
+import {
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  UrlEncodedRepoName,
+  ProjectInfoWithName,
+  RepoName,
+} from '../../../types/common';
+import {AppElementAdminParams} from '../../gr-app-types';
+import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {GerritView} from '../../../services/router/router-model';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrListView} from '../../shared/gr-list-view/gr-list-view';
+
+const basicFixture = fixtureFromElement('gr-repo-list');
+
+function createRepo(name: string, counter: number) {
+  return {
+    id: `${name}${counter}` as UrlEncodedRepoName,
+    name: `${name}` as RepoName,
+    state: 'ACTIVE' as ProjectState,
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://phabricator.example.org/r/project/${name}${counter}`,
+      },
+    ],
+  };
+}
+
+function createRepoList(name: string, n: number) {
+  const repos = [];
+  for (let i = 0; i < n; ++i) {
+    repos.push(createRepo(name, i));
+  }
+  return repos;
+}
+
+suite('gr-repo-list tests', () => {
+  let element: GrRepoList;
+  let repos: ProjectInfoWithName[];
+
+  setup(async () => {
+    sinon.stub(page, 'show');
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  suite('list with repos', () => {
+    setup(async () => {
+      repos = createRepoList('test', 26);
+      stubRestApi('getRepos').returns(Promise.resolve(repos));
+      await element._paramsChanged();
+      await element.updateComplete;
+    });
+
+    test('test for test repo in the list', async () => {
+      await element.updateComplete;
+      assert.equal(element.repos[0].id, 'test0');
+      assert.equal(element.repos[1].id, 'test1');
+      assert.equal(element.repos[2].id, 'test2');
+    });
+
+    test('shownRepos', () => {
+      assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+    });
+
+    test('maybeOpenCreateOverlay', () => {
+      const overlayOpen = sinon.stub(
+        queryAndAssert<GrOverlay>(element, '#createOverlay'),
+        'open'
+      );
+      element.maybeOpenCreateOverlay();
+      assert.isFalse(overlayOpen.called);
+      element.maybeOpenCreateOverlay(undefined);
+      assert.isFalse(overlayOpen.called);
+      const params: AppElementAdminParams = {
+        view: GerritView.ADMIN,
+        adminView: '',
+        openCreateModal: true,
+      };
+      element.maybeOpenCreateOverlay(params);
+      assert.isTrue(overlayOpen.called);
+    });
+  });
+
+  suite('list with less then 25 repos', () => {
+    setup(async () => {
+      repos = createRepoList('test', 25);
+      stubRestApi('getRepos').returns(Promise.resolve(repos));
+      await element._paramsChanged();
+      await element.updateComplete;
+    });
+
+    test('shownRepos', () => {
+      assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+    });
+  });
+
+  suite('filter', () => {
+    let reposFiltered: ProjectInfoWithName[];
+
+    setup(() => {
+      repos = createRepoList('test', 25);
+      reposFiltered = createRepoList('filter', 1);
+    });
+
+    test('_paramsChanged', async () => {
+      const repoStub = stubRestApi('getRepos');
+      repoStub.returns(Promise.resolve(repos));
+      element.params = {
+        view: GerritView.ADMIN,
+        adminView: '',
+        filter: 'test',
+        offset: 25,
+      } as AppElementAdminParams;
+      await element._paramsChanged();
+      assert.isTrue(repoStub.lastCall.calledWithExactly('test', 25, 25));
+    });
+
+    test('latest repos requested are always set', async () => {
+      const repoStub = stubRestApi('getRepos');
+      const promise = mockPromise<ProjectInfoWithName[]>();
+      repoStub.withArgs('filter', 25).returns(promise);
+
+      element.filter = 'test';
+      element.reposPerPage = 25;
+      element.offset = 0;
+
+      // Repos are not set because the element.filter differs.
+      const p = element.getRepos();
+      element.filter = 'filter';
+      promise.resolve(reposFiltered);
+      await p;
+      assert.deepEqual(element.repos, []);
+    });
+
+    test('filter is case insensitive', async () => {
+      const repoStub = stubRestApi('getRepos');
+      const repos = [createRepo('aSDf', 0)];
+      repoStub.withArgs('asdf', 25).returns(Promise.resolve(repos));
+
+      element.filter = 'asdf';
+      element.reposPerPage = 25;
+      element.offset = 0;
+
+      await element.getRepos();
+      assert.equal(element.repos.length, 1);
+    });
+
+    test('display repos by query search', async () => {
+      const repoStub = stubRestApi('getRepos');
+      const repos = [createRepo('test', 0)];
+      repoStub.withArgs('inname:test', 25).returns(Promise.resolve(repos));
+      element.filter = 'inname:test';
+      element.reposPerPage = 25;
+      element.offset = 0;
+      await element.getRepos();
+      assert.equal(element.repos.length, 1);
+    });
+  });
+
+  suite('loading', () => {
+    test('correct contents are displayed', async () => {
+      assert.isTrue(element.loading);
+      assert.equal(element.computeLoadingClass(element.loading), 'loading');
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<HTMLTableRowElement>(element, '#loading')
+        ).display,
+        'block'
+      );
+
+      element.loading = false;
+      element.repos = createRepoList('test', 25);
+
+      await element.updateComplete;
+      assert.equal(element.computeLoadingClass(element.loading), '');
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<HTMLTableRowElement>(element, '#loading')
+        ).display,
+        'none'
+      );
+    });
+  });
+
+  suite('create new', () => {
+    test('handleCreateClicked called when create-clicked fired', () => {
+      const handleCreateClickedStub = sinon.stub(
+        element,
+        'handleCreateClicked'
+      );
+      queryAndAssert<GrListView>(element, 'gr-list-view').dispatchEvent(
+        new CustomEvent('create-clicked', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleCreateClickedStub.called);
+    });
+
+    test('handleCreateClicked opens modal', () => {
+      const openStub = sinon
+        .stub(queryAndAssert<GrOverlay>(element, '#createOverlay'), 'open')
+        .returns(Promise.resolve());
+      element.handleCreateClicked();
+      assert.isTrue(openStub.called);
+    });
+
+    test('handleCreateRepo called when confirm fired', () => {
+      const handleCreateRepoStub = sinon.stub(element, 'handleCreateRepo');
+      queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
+        new CustomEvent('confirm', {
+          composed: true,
+          bubbles: false,
+        })
+      );
+      assert.isTrue(handleCreateRepoStub.called);
+    });
+
+    test('handleCloseCreate called when cancel fired', () => {
+      const handleCloseCreateStub = sinon.stub(element, 'handleCloseCreate');
+      queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
+        new CustomEvent('cancel', {
+          composed: true,
+          bubbles: false,
+        })
+      );
+      assert.isTrue(handleCloseCreateStub.called);
+    });
+  });
+});
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 bab7b15..e23db15 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
@@ -36,13 +36,13 @@
   PluginConfigOptionsChangedEventDetail,
   PluginOption,
 } from './gr-repo-plugin-config-types';
+import {paperStyles} from '../../../styles/gr-paper-styles';
 
 const PLUGIN_CONFIG_CHANGED_EVENT_NAME = 'plugin-config-changed';
 
 export interface ConfigChangeInfo {
   _key: string; // parameterName of PluginParameterToConfigParameterInfoMap
   info: ConfigParameterInfo;
-  notifyPath: string;
 }
 
 export interface PluginData {
@@ -53,7 +53,6 @@
 export interface PluginConfigChangeDetail {
   name: string; // parameterName of PluginParameterToConfigParameterInfoMap
   config: PluginParameterToConfigParameterInfoMap;
-  notifyPath: string;
 }
 
 @customElement('gr-repo-plugin-config')
@@ -74,6 +73,7 @@
     return [
       sharedStyles,
       formStyles,
+      paperStyles,
       subpageStyles,
       css`
         .inherited {
@@ -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,8 @@
       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>
       `;
     } else if (option.info.type === ConfigParameterInfoType.BOOLEAN) {
@@ -171,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 +186,13 @@
         <iron-input
           .bindValue=${option.info.value ?? ''}
           @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>
@@ -250,7 +251,6 @@
     return {
       _key,
       info,
-      notifyPath: `${_key}.value`,
     };
   }
 
@@ -258,7 +258,7 @@
     this._handleChange(e.detail);
   }
 
-  _handleChange({_key, info, notifyPath}: ConfigChangeInfo) {
+  _handleChange({_key, info}: ConfigChangeInfo) {
     // If pluginData is not set, editors are not created and this method
     // can't be called
     const {name, config} = this.pluginData!;
@@ -267,7 +267,6 @@
     const detail: PluginConfigChangeDetail = {
       name,
       config: {...config, [_key]: info},
-      notifyPath: `${name}.${notifyPath}`,
     };
 
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
deleted file mode 100644
index 8c2e6b3..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
+++ /dev/null
@@ -1,157 +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-repo-plugin-config.js';
-
-const basicFixture = fixtureFromElement('gr-repo-plugin-config');
-
-suite('gr-repo-plugin-config tests', () => {
-  let element;
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
-  });
-
-  test('_computePluginConfigOptions', () => {
-    assert.deepEqual(element._computePluginConfigOptions({config: {}}), []);
-    assert.deepEqual(element._computePluginConfigOptions(
-        {config: {testKey: 'testInfo'}}),
-    [{_key: 'testKey', info: 'testInfo'}]);
-  });
-
-  test('_handleChange', () => {
-    const eventStub = sinon.stub(element, 'dispatchEvent');
-    element.pluginData = {
-      name: 'testName',
-      config: {plugin: {value: 'test'}},
-    };
-    element._handleChange({
-      _key: 'plugin',
-      info: {value: 'newTest'},
-      notifyPath: 'plugin.value',
-    });
-
-    assert.isTrue(eventStub.called);
-
-    const {detail} = eventStub.lastCall.args[0];
-    assert.equal(detail.name, 'testName');
-    assert.deepEqual(detail.config, {plugin: {value: 'newTest'}});
-    assert.equal(detail.notifyPath, 'testName.plugin.value');
-  });
-
-  suite('option types', () => {
-    let changeStub;
-    let buildStub;
-
-    setup(() => {
-      changeStub = sinon.stub(element, '_handleChange');
-      buildStub = sinon.stub(element, '_buildConfigChangeInfo');
-    });
-
-    test('ARRAY type option', async () => {
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin: {value: 'test', type: 'ARRAY', editable: true}},
-      };
-      await flush();
-
-      const editor = element.shadowRoot
-          .querySelector('gr-plugin-config-array-editor');
-      assert.ok(editor);
-      element._handleArrayChange({detail: 'test'});
-      assert.isTrue(changeStub.called);
-      assert.equal(changeStub.lastCall.args[0], 'test');
-    });
-
-    test('BOOLEAN type option', async () => {
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin: {value: 'true', type: 'BOOLEAN', editable: true}},
-      };
-      await flush();
-
-      const toggle = element.shadowRoot
-          .querySelector('paper-toggle-button');
-      assert.ok(toggle);
-      toggle.click();
-      await flush();
-
-      assert.isTrue(buildStub.called);
-      assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
-
-      assert.isTrue(changeStub.called);
-    });
-
-    test('INT/LONG/STRING type option', async () => {
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin: {value: 'test', type: 'STRING', editable: true}},
-      };
-      await flush();
-
-      const input = element.shadowRoot
-          .querySelector('input');
-      assert.ok(input);
-      input.value = 'newTest';
-      input.dispatchEvent(new Event('input'));
-      await flush();
-
-      assert.isTrue(buildStub.called);
-      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
-
-      assert.isTrue(changeStub.called);
-    });
-
-    test('LIST type option', async () => {
-      const permitted_values = ['test', 'newTest'];
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin:
-          {value: 'test', type: 'LIST', editable: true, permitted_values},
-        },
-      };
-      await flush();
-
-      const select = element.shadowRoot
-          .querySelector('select');
-      assert.ok(select);
-      select.value = 'newTest';
-      select.dispatchEvent(new Event(
-          'change', {bubbles: true, composed: true}));
-      await flush();
-
-      assert.isTrue(buildStub.called);
-      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
-
-      assert.isTrue(changeStub.called);
-    });
-  });
-
-  test('_buildConfigChangeInfo', () => {
-    element.pluginData = {
-      name: 'testName',
-      config: {plugin: {value: 'test'}},
-    };
-    const detail = element._buildConfigChangeInfo('newTest', 'plugin');
-    assert.equal(detail._key, 'plugin');
-    assert.deepEqual(detail.info, {value: 'newTest'});
-    assert.equal(detail.notifyPath, 'plugin.value');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts
new file mode 100644
index 0000000..fa3a635
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts
@@ -0,0 +1,207 @@
+/**
+ * @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';
+import './gr-repo-plugin-config';
+import {GrRepoPluginConfig} from './gr-repo-plugin-config';
+import {PluginParameterToConfigParameterInfoMap} from '../../../types/common';
+import {ConfigParameterInfoType} from '../../../constants/constants';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrPluginConfigArrayEditor} from '../gr-plugin-config-array-editor/gr-plugin-config-array-editor';
+import {PaperToggleButtonElement} from '@polymer/paper-toggle-button/paper-toggle-button';
+
+const basicFixture = fixtureFromElement('gr-repo-plugin-config');
+
+suite('gr-repo-plugin-config tests', () => {
+  let element: GrRepoPluginConfig;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  test('_computePluginConfigOptions', () => {
+    assert.deepEqual(
+      element._computePluginConfigOptions({
+        name: 'testInfo',
+        config: {
+          testKey: {display_name: 'testInfo plugin', type: 'STRING'},
+        } as PluginParameterToConfigParameterInfoMap,
+      }),
+      [
+        {
+          _key: 'testKey',
+          info: {
+            display_name: 'testInfo plugin',
+            type: 'STRING' as ConfigParameterInfoType,
+          },
+        },
+      ]
+    );
+  });
+
+  test('_handleChange', () => {
+    const eventStub = sinon.stub(element, 'dispatchEvent');
+    element.pluginData = {
+      name: 'testName',
+      config: {
+        plugin: {type: 'STRING' as ConfigParameterInfoType, value: 'test'},
+      },
+    };
+    element._handleChange({
+      _key: 'plugin',
+      info: {type: 'STRING' as ConfigParameterInfoType, value: 'newTest'},
+    });
+
+    assert.isTrue(eventStub.called);
+
+    const {detail} = eventStub.lastCall.args[0] as CustomEvent;
+    assert.equal(detail.name, 'testName');
+    assert.deepEqual(detail.config, {
+      plugin: {type: 'STRING' as ConfigParameterInfoType, value: 'newTest'},
+    });
+  });
+
+  suite('option types', () => {
+    let changeStub: sinon.SinonStub;
+    let buildStub: sinon.SinonStub;
+
+    setup(() => {
+      changeStub = sinon.stub(element, '_handleChange');
+      buildStub = sinon.stub(element, '_buildConfigChangeInfo');
+    });
+
+    test('ARRAY type option', async () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {
+          plugin: {
+            value: 'test',
+            type: 'ARRAY' as ConfigParameterInfoType,
+            editable: true,
+          },
+        },
+      };
+      await element.updateComplete;
+
+      const editor = queryAndAssert<GrPluginConfigArrayEditor>(
+        element,
+        'gr-plugin-config-array-editor'
+      );
+      assert.ok(editor);
+      element._handleArrayChange({detail: 'test'} as CustomEvent);
+      assert.isTrue(changeStub.called);
+      assert.equal(changeStub.lastCall.args[0], 'test');
+    });
+
+    test('BOOLEAN type option', async () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {
+          plugin: {
+            value: 'true',
+            type: 'BOOLEAN' as ConfigParameterInfoType,
+            editable: true,
+          },
+        },
+      };
+      await element.updateComplete;
+
+      const toggle = queryAndAssert<PaperToggleButtonElement>(
+        element,
+        'paper-toggle-button'
+      );
+      assert.ok(toggle);
+      toggle.click();
+      await element.updateComplete;
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
+
+      assert.isTrue(changeStub.called);
+    });
+
+    test('INT/LONG/STRING type option', async () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {
+          plugin: {
+            value: 'test',
+            type: 'STRING' as ConfigParameterInfoType,
+            editable: true,
+          },
+        },
+      };
+      await element.updateComplete;
+
+      const input = queryAndAssert<HTMLInputElement>(element, 'input');
+      assert.ok(input);
+      input.value = 'newTest';
+      input.dispatchEvent(new Event('input'));
+      await element.updateComplete;
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+      assert.isTrue(changeStub.called);
+    });
+
+    test('LIST type option', async () => {
+      const permitted_values = ['test', 'newTest'];
+      element.pluginData = {
+        name: 'testName',
+        config: {
+          plugin: {
+            value: 'test',
+            type: 'LIST' as ConfigParameterInfoType,
+            editable: true,
+            permitted_values,
+          },
+        },
+      };
+      await element.updateComplete;
+
+      const select = queryAndAssert<HTMLSelectElement>(element, 'select');
+      assert.ok(select);
+      select.value = 'newTest';
+      select.dispatchEvent(
+        new Event('change', {bubbles: true, composed: true})
+      );
+      await element.updateComplete;
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+      assert.isTrue(changeStub.called);
+    });
+  });
+
+  test('_buildConfigChangeInfo', () => {
+    element.pluginData = {
+      name: 'testName',
+      config: {
+        plugin: {type: 'STRING' as ConfigParameterInfoType, value: 'test'},
+      },
+    };
+    const detail = element._buildConfigChangeInfo('newTest', 'plugin');
+    assert.equal(detail._key, 'plugin');
+    assert.deepEqual(detail.info, {
+      type: 'STRING' as ConfigParameterInfoType,
+      value: 'newTest',
+    });
+  });
+});
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 83c3d6a..06691d7 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -17,35 +17,40 @@
 import '@polymer/iron-input/iron-input';
 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-download-commands/gr-download-commands';
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-textarea/gr-textarea';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/shared-styles';
 import '../gr-repo-plugin-config/gr-repo-plugin-config';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, observe} from '@polymer/decorators';
 import {
   ConfigInfo,
   RepoName,
   InheritedBooleanInfo,
   SchemesInfoMap,
   ConfigInput,
+  MaxObjectSizeLimitInfo,
   PluginParameterToConfigParameterInfoMap,
-  PluginNameToPluginParametersMap,
 } from '../../../types/common';
-import {PluginData} from '../gr-repo-plugin-config/gr-repo-plugin-config';
-import {ProjectState} from '../../../constants/constants';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {
+  InheritedBooleanInfoConfiguredValue,
+  ProjectState,
+  SubmitType,
+} from '../../../constants/constants';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {WebLinkInfo} from '../../../types/diff';
 import {ErrorCallback} from '../../../api/rest';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {BindValueChangeEvent} from '../../../types/events';
+import {deepClone} from '../../../utils/deep-util';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {subscribe} from '../../lit/subscription-controller';
 
 const STATES = {
   active: {value: ProjectState.ACTIVE, label: 'Active'},
@@ -81,92 +86,687 @@
   },
 };
 
-@customElement('gr-repo')
-export class GrRepo extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo': GrRepo;
   }
+}
+
+@customElement('gr-repo')
+export class GrRepo extends LitElement {
+  private schemes: string[] = [];
 
   @property({type: String})
   repo?: RepoName;
 
-  @property({type: Boolean})
-  _configChanged = false;
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() repoConfig?: ConfigInfo;
 
-  @property({type: Boolean, observer: '_loggedInChanged'})
-  _loggedIn = false;
+  // private but used in test
+  @state() readOnly = true;
 
-  @property({type: Object})
-  _repoConfig?: ConfigInfo;
+  @state() private states = Object.values(STATES);
 
-  @property({
-    type: Array,
-    computed: '_computePluginData(_repoConfig.plugin_config.*)',
-  })
-  _pluginData?: PluginData[];
+  @state() private originalConfig?: ConfigInfo;
 
-  @property({type: Boolean})
-  _readOnly = true;
+  @state() private selectedScheme?: string;
 
-  @property({type: Array})
-  _states = Object.values(STATES);
+  // private but used in test
+  @state() schemesObj?: SchemesInfoMap;
 
-  @property({
-    type: Array,
-    computed: '_computeSchemes(_schemesDefault, _schemesObj)',
-    observer: '_schemesChanged',
-  })
-  _schemes: string[] = [];
+  @state() private weblinks: WebLinkInfo[] = [];
 
-  // This is workaround to have _schemes with default value [],
-  // because assignment doesn't work when property has a computed attribute.
-  @property({type: Array})
-  _schemesDefault: string[] = [];
+  @state() private pluginConfigChanged = false;
 
-  @property({type: String})
-  _selectedCommand = 'Clone';
+  private readonly userModel = getAppContext().userModel;
 
-  @property({type: String})
-  _selectedScheme?: string;
+  private readonly restApiService = getAppContext().restApiService;
 
-  @property({type: Object})
-  _schemesObj?: SchemesInfoMap;
-
-  @property({type: Array})
-  weblinks: WebLinkInfo[] = [];
-
-  private readonly restApiService = appContext.restApiService;
+  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();
+      }
+    });
+  }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadRepo();
 
     fireTitleChange(this, `${this.repo}`);
   }
 
-  _computePluginData(
-    configRecord: PolymerDeepPropertyChange<
-      PluginNameToPluginParametersMap,
-      PluginNameToPluginParametersMap
-    >
-  ) {
-    if (!configRecord || !configRecord.base) {
-      return [];
-    }
+  static override get styles() {
+    return [
+      fontStyles,
+      formStyles,
+      subpageStyles,
+      sharedStyles,
+      css`
+        .info {
+          margin-bottom: var(--spacing-xl);
+        }
+        h2.edited:after {
+          color: var(--deemphasized-text-color);
+          content: ' *';
+        }
+        .loading,
+        .hide {
+          display: none;
+        }
+        #loading.loading {
+          display: block;
+        }
+        #loading:not(.loading) {
+          display: none;
+        }
+        #options .repositorySettings {
+          display: none;
+        }
+        #options .repositorySettings.showConfig {
+          display: block;
+        }
+      `,
+    ];
+  }
 
-    const pluginConfig = configRecord.base;
+  override render() {
+    const configChanged = this.hasConfigChanged();
+    return html`
+      <div class="main gr-form-styles read-only">
+        <div class="info">
+          <h1 id="Title" class="heading-1">${this.repo}</h1>
+          <hr />
+          <div>
+            <a href=${this.weblinks?.[0]?.url}
+              ><gr-button link ?disabled=${!this.weblinks?.[0]?.url}
+                >Browse</gr-button
+              ></a
+            ><a href=${this.computeChangesUrl(this.repo)}
+              ><gr-button link>View Changes</gr-button></a
+            >
+          </div>
+        </div>
+        <div id="loading" class=${this.loading ? 'loading' : ''}>
+          Loading...
+        </div>
+        <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
+          ${this.renderDownloadCommands()}
+          <h2
+            id="configurations"
+            class="heading-2 ${configChanged ? 'edited' : ''}"
+          >
+            Configurations
+          </h2>
+          <div id="form">
+            <fieldset>
+              ${this.renderDescription()} ${this.renderRepoOptions()}
+              ${this.renderPluginConfig()}
+              <gr-button
+                ?disabled=${this.readOnly || !configChanged}
+                @click=${this.handleSaveRepoConfig}
+                >Save changes</gr-button
+              >
+            </fieldset>
+            <gr-endpoint-decorator name="repo-config">
+              <gr-endpoint-param
+                name="repoName"
+                .value=${this.repo}
+              ></gr-endpoint-param>
+              <gr-endpoint-param
+                name="readOnly"
+                .value=${this.readOnly}
+              ></gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderDownloadCommands() {
+    return html`
+      <div
+        id="downloadContent"
+        class=${!this.schemes || !this.schemes.length ? 'hide' : ''}
+      >
+        <h2 id="download" class="heading-2">Download</h2>
+        <fieldset>
+          <gr-download-commands
+            id="downloadCommands"
+            .commands=${this.computeCommands(
+              this.repo,
+              this.schemesObj,
+              this.selectedScheme
+            )}
+            .schemes=${this.schemes}
+            .selectedScheme=${this.selectedScheme}
+            @selected-scheme-changed=${(e: BindValueChangeEvent) => {
+              if (this.loading) return;
+              this.selectedScheme = e.detail.value;
+            }}
+          ></gr-download-commands>
+        </fieldset>
+      </div>
+    `;
+  }
+
+  private renderDescription() {
+    return html`
+      <h3 id="Description" class="heading-3">Description</h3>
+      <fieldset>
+        <gr-textarea
+          id="descriptionInput"
+          class="description"
+          autocomplete="on"
+          placeholder="&lt;Insert repo description here&gt;"
+          rows="4"
+          monospace
+          ?disabled=${this.readOnly}
+          .text=${this.repoConfig?.description}
+          @text-changed=${this.handleDescriptionTextChanged}
+        ></gr-textarea>
+      </fieldset>
+    `;
+  }
+
+  private renderRepoOptions() {
+    return html`
+      <h3 id="Options" class="heading-3">Repository Options</h3>
+      <fieldset id="options">
+        ${this.renderState()} ${this.renderSubmitType()}
+        ${this.renderContentMerges()} ${this.renderNewChange()}
+        ${this.renderChangeId()} ${this.renderEnableSignedPush()}
+        ${this.renderRequireSignedPush()} ${this.renderRejectImplicitMerges()}
+        ${this.renderUnRegisteredCc()} ${this.renderPrivateByDefault()}
+        ${this.renderWorkInProgressByDefault()} ${this.renderMaxGitObjectSize()}
+        ${this.renderMatchAuthoredDateWithCommitterDate()}
+        ${this.renderRejectEmptyCommit()}
+      </fieldset>
+      <h3 id="Options" class="heading-3">Contributor Agreements</h3>
+      <fieldset id="agreements">
+        ${this.renderContributorAgreement()} ${this.renderUseSignedOffBy()}
+      </fieldset>
+    `;
+  }
+
+  private renderState() {
+    return html`
+      <section>
+        <span class="title">State</span>
+        <span class="value">
+          <gr-select
+            id="stateSelect"
+            .bindValue=${this.repoConfig?.state}
+            @bind-value-changed=${this.handleStateSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.states.map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderSubmitType() {
+    return html`
+      <section>
+        <span class="title">Submit type</span>
+        <span class="value">
+          <gr-select
+            id="submitTypeSelect"
+            .bindValue=${this.repoConfig?.submit_type}
+            @bind-value-changed=${this.handleSubmitTypeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatSubmitTypeSelect(this.repoConfig).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderContentMerges() {
+    return html`
+      <section>
+        <span class="title">Allow content merges</span>
+        <span class="value">
+          <gr-select
+            id="contentMergeSelect"
+            .bindValue=${this.repoConfig?.use_content_merge?.configured_value}
+            @bind-value-changed=${this.handleContentMergeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.use_content_merge
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderNewChange() {
+    return html`
+      <section>
+        <span class="title">
+          Create a new change for every commit not in the target branch
+        </span>
+        <span class="value">
+          <gr-select
+            id="newChangeSelect"
+            .bindValue=${this.repoConfig
+              ?.create_new_change_for_all_not_in_target?.configured_value}
+            @bind-value-changed=${this.handleNewChangeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.create_new_change_for_all_not_in_target
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderChangeId() {
+    return html`
+      <section>
+        <span class="title">Require Change-Id in commit message</span>
+        <span class="value">
+          <gr-select
+            id="requireChangeIdSelect"
+            .bindValue=${this.repoConfig?.require_change_id?.configured_value}
+            @bind-value-changed=${this
+              .handleRequireChangeIdSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.require_change_id
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderEnableSignedPush() {
+    return html`
+      <section
+        id="enableSignedPushSettings"
+        class="repositorySettings ${this.repoConfig?.enable_signed_push
+          ? 'showConfig'
+          : ''}"
+      >
+        <span class="title">Enable signed push</span>
+        <span class="value">
+          <gr-select
+            id="enableSignedPush"
+            .bindValue=${this.repoConfig?.enable_signed_push?.configured_value}
+            @bind-value-changed=${this.handleEnableSignedPushBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.enable_signed_push
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRequireSignedPush() {
+    return html`
+      <section
+        id="requireSignedPushSettings"
+        class="repositorySettings ${this.repoConfig?.require_signed_push
+          ? 'showConfig'
+          : ''}"
+      >
+        <span class="title">Require signed push</span>
+        <span class="value">
+          <gr-select
+            id="requireSignedPush"
+            .bindValue=${this.repoConfig?.require_signed_push?.configured_value}
+            @bind-value-changed=${this.handleRequireSignedPushBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.require_signed_push
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRejectImplicitMerges() {
+    return html`
+      <section>
+        <span class="title">
+          Reject implicit merges when changes are pushed for review</span
+        >
+        <span class="value">
+          <gr-select
+            id="rejectImplicitMergesSelect"
+            .bindValue=${this.repoConfig?.reject_implicit_merges
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleRejectImplicitMergeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.reject_implicit_merges
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderUnRegisteredCc() {
+    return html`
+      <section>
+        <span class="title">
+          Enable adding unregistered users as reviewers and CCs on changes</span
+        >
+        <span class="value">
+          <gr-select
+            id="unRegisteredCcSelect"
+            .bindValue=${this.repoConfig?.enable_reviewer_by_email
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleUnRegisteredCcSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.enable_reviewer_by_email
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderPrivateByDefault() {
+    return html`
+      <section>
+        <span class="title"> Set all new changes private by default</span>
+        <span class="value">
+          <gr-select
+            id="setAllnewChangesPrivateByDefaultSelect"
+            .bindValue=${this.repoConfig?.private_by_default?.configured_value}
+            @bind-value-changed=${this
+              .handleSetAllNewChangesPrivateByDefaultSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.private_by_default
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderWorkInProgressByDefault() {
+    return html`
+      <section>
+        <span class="title">
+          Set new changes to "work in progress" by default</span
+        >
+        <span class="value">
+          <gr-select
+            id="setAllNewChangesWorkInProgressByDefaultSelect"
+            .bindValue=${this.repoConfig?.work_in_progress_by_default
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleSetAllNewChangesWorkInProgressByDefaultSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.work_in_progress_by_default
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderMaxGitObjectSize() {
+    return html`
+      <section>
+        <span class="title">Maximum Git object size limit</span>
+        <span class="value">
+          <iron-input
+            id="maxGitObjSizeIronInput"
+            .bindValue=${this.repoConfig?.max_object_size_limit
+              ?.configured_value}
+            @bind-value-changed=${this.handleMaxGitObjSizeBindValueChanged}
+          >
+            <input
+              id="maxGitObjSizeInput"
+              type="text"
+              ?disabled=${this.readOnly}
+            />
+          </iron-input>
+          ${this.repoConfig?.max_object_size_limit?.value
+            ? `effective: ${this.repoConfig.max_object_size_limit.value} bytes`
+            : ''}
+        </span>
+      </section>
+    `;
+  }
+
+  private renderMatchAuthoredDateWithCommitterDate() {
+    return html`
+      <section>
+        <span class="title"
+          >Match authored date with committer date upon submit</span
+        >
+        <span class="value">
+          <gr-select
+            id="matchAuthoredDateWithCommitterDateSelect"
+            .bindValue=${this.repoConfig?.match_author_to_committer_date
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleMatchAuthoredDateWithCommitterDateSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.match_author_to_committer_date
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRejectEmptyCommit() {
+    return html`
+      <section>
+        <span class="title">Reject empty commit upon submit</span>
+        <span class="value">
+          <gr-select
+            id="rejectEmptyCommitSelect"
+            .bindValue=${this.repoConfig?.reject_empty_commit?.configured_value}
+            @bind-value-changed=${this
+              .handleRejectEmptyCommitSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.reject_empty_commit
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderContributorAgreement() {
+    return html`
+      <section>
+        <span class="title">
+          Require a valid contributor agreement to upload</span
+        >
+        <span class="value">
+          <gr-select
+            id="contributorAgreementSelect"
+            .bindValue=${this.repoConfig?.use_contributor_agreements
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleUseContributorAgreementsBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.use_contributor_agreements
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderUseSignedOffBy() {
+    return html`
+      <section>
+        <span class="title">Require Signed-off-by in commit message</span>
+        <span class="value">
+          <gr-select
+            id="useSignedOffBySelect"
+            .bindValue=${this.repoConfig?.use_signed_off_by?.configured_value}
+            @bind-value-changed=${this
+              .handleUseSignedOffBySelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.use_signed_off_by
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderPluginConfig() {
+    const pluginData = this.computePluginData();
+    return html` <div
+      class="pluginConfig ${!pluginData || !pluginData.length ? 'hide' : ''}"
+      @plugin-config-changed=${this.handlePluginConfigChanged}
+    >
+      <h3 class="heading-3">Plugins</h3>
+      ${pluginData.map(
+        item => html`
+          <gr-repo-plugin-config
+            .pluginData=${item}
+            ?disabled=${this.readOnly}
+          ></gr-repo-plugin-config>
+        `
+      )}
+    </div>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('repo')) {
+      this.loadRepo();
+    }
+    if (changedProperties.has('schemesObj')) {
+      this.computeSchemesAndDefault();
+    }
+  }
+
+  // private but used in test
+  computePluginData() {
+    if (!this.repoConfig || !this.repoConfig.plugin_config) return [];
+    const pluginConfig = this.repoConfig.plugin_config;
     return Object.keys(pluginConfig).map(name => {
       return {name, config: pluginConfig[name]};
     });
   }
 
-  _loadRepo() {
-    if (!this.repo) {
-      return Promise.resolve();
-    }
+  // private but used in test
+  async loadRepo() {
+    if (!this.repo) return Promise.resolve();
 
     const promises = [];
 
@@ -175,8 +775,7 @@
     };
 
     promises.push(
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
+      this.restApiService.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           const repo = this.repo;
           if (!repo) throw new Error('undefined repo');
@@ -190,71 +789,60 @@
             }
 
             // If the user is not an owner, is_owner is not a property.
-            this._readOnly = !access[repo]?.is_owner;
+            this.readOnly = !access[repo]?.is_owner;
           });
         }
       })
     );
 
-    promises.push(
-      this.restApiService.getProjectConfig(this.repo, errFn).then(config => {
-        if (!config) {
-          return;
-        }
+    const repoConfigHelper = async () => {
+      const config = await this.restApiService.getProjectConfig(
+        this.repo as RepoName,
+        errFn
+      );
+      if (!config) return;
 
-        if (config.default_submit_type) {
-          // The gr-select is bound to submit_type, which needs to be the
-          // *configured* submit type. When default_submit_type is
-          // present, the server reports the *effective* submit type in
-          // submit_type, so we need to overwrite it before storing the
-          // config in this.
-          config.submit_type = config.default_submit_type.configured_value;
-        }
-        if (!config.state) {
-          config.state = STATES.active.value;
-        }
-        this._repoConfig = config;
-        this._loading = false;
-      })
-    );
-
-    promises.push(
-      this.restApiService.getConfig().then(config => {
-        if (!config) {
-          return;
-        }
-
-        this._schemesObj = config.download.schemes;
-      })
-    );
-
-    return Promise.all(promises);
-  }
-
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-
-  _computeHideClass(arr?: PluginData[] | string[]) {
-    return !arr || !arr.length ? 'hide' : '';
-  }
-
-  _loggedInChanged(_loggedIn?: boolean) {
-    if (!_loggedIn) {
-      return;
-    }
-    this.restApiService.getPreferences().then(prefs => {
-      if (prefs?.download_scheme) {
-        // Note (issue 5180): normalize the download scheme with lower-case.
-        this._selectedScheme = prefs.download_scheme.toLowerCase();
+      if (config.default_submit_type) {
+        // The gr-select is bound to submit_type, which needs to be the
+        // *configured* submit type. When default_submit_type is
+        // present, the server reports the *effective* submit type in
+        // submit_type, so we need to overwrite it before storing the
+        // config in this.
+        config.submit_type = config.default_submit_type.configured_value;
       }
-    });
+      if (!config.state) {
+        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.
+      if (config.description === undefined) {
+        config.description = '';
+      }
+      // 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.
+      if (config.max_object_size_limit.configured_value === undefined) {
+        config.max_object_size_limit.configured_value = '';
+      }
+      this.repoConfig = config;
+      this.originalConfig = deepClone(config) as ConfigInfo;
+      this.loading = false;
+    };
+    promises.push(repoConfigHelper());
+
+    const configHelper = async () => {
+      const config = await this.restApiService.getConfig();
+      if (!config) return;
+
+      this.schemesObj = config.download.schemes;
+    };
+    promises.push(configHelper());
+
+    await Promise.all(promises);
   }
 
-  _formatBooleanSelect(item: InheritedBooleanInfo) {
-    if (!item) {
-      return;
-    }
+  // private but used in test
+  formatBooleanSelect(item?: InheritedBooleanInfo) {
+    if (!item) return [];
     let inheritLabel = 'Inherit';
     if (!(item.inherited_value === undefined)) {
       inheritLabel = `Inherit (${item.inherited_value})`;
@@ -275,12 +863,10 @@
     ];
   }
 
-  _formatSubmitTypeSelect(projectConfig: ConfigInfo) {
-    if (!projectConfig) {
-      return;
-    }
+  private formatSubmitTypeSelect(repoConfig?: ConfigInfo) {
+    if (!repoConfig) return [];
     const allValues = Object.values(SUBMIT_TYPES);
-    const type = projectConfig.default_submit_type;
+    const type = repoConfig.default_submit_type;
     if (!type) {
       // Server is too old to report default_submit_type, so assume INHERIT
       // is not a valid value.
@@ -306,15 +892,9 @@
     ];
   }
 
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  _formatRepoConfigForSave(repoConfig: ConfigInfo): ConfigInput {
+  // private but used in test
+  formatRepoConfigForSave(repoConfig?: ConfigInfo): ConfigInput {
+    if (!repoConfig) return {};
     const configInputObj: ConfigInput = {};
     for (const configKey of Object.keys(repoConfig)) {
       const key = configKey as keyof ConfigInfo;
@@ -329,7 +909,7 @@
       } else if (typeof repoConfig[key] === 'object') {
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         const repoConfigObj: any = repoConfig[key];
-        if (repoConfigObj.configured_value) {
+        if (repoConfigObj.configured_value !== undefined) {
           configInputObj[key as keyof ConfigInput] =
             repoConfigObj.configured_value;
         }
@@ -341,56 +921,173 @@
     return configInputObj;
   }
 
-  _handleSaveRepoConfig() {
-    if (!this._repoConfig || !this.repo)
+  // private but used in test
+  async handleSaveRepoConfig() {
+    if (!this.repoConfig || !this.repo)
       return Promise.reject(new Error('undefined repoConfig or repo'));
-    return this.restApiService
-      .saveRepoConfig(
-        this.repo,
-        this._formatRepoConfigForSave(this._repoConfig)
+    await this.restApiService.saveRepoConfig(
+      this.repo,
+      this.formatRepoConfigForSave(this.repoConfig)
+    );
+    this.originalConfig = deepClone(this.repoConfig) as ConfigInfo;
+    this.pluginConfigChanged = false;
+    return;
+  }
+
+  private isEdited(
+    original?: InheritedBooleanInfo | MaxObjectSizeLimitInfo,
+    repo?: InheritedBooleanInfo | MaxObjectSizeLimitInfo
+  ) {
+    return original?.configured_value !== repo?.configured_value;
+  }
+
+  private hasConfigChanged() {
+    const {repoConfig, originalConfig} = this;
+
+    if (!repoConfig || !originalConfig) return false;
+
+    if (originalConfig.description !== repoConfig.description) {
+      return true;
+    }
+    if (originalConfig.state !== repoConfig.state) {
+      return true;
+    }
+    if (originalConfig.submit_type !== repoConfig.submit_type) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.use_content_merge,
+        repoConfig.use_content_merge
       )
-      .then(() => {
-        this._configChanged = false;
-      });
-  }
-
-  @observe('_repoConfig.*')
-  _handleConfigChanged() {
-    if (this._isLoading()) {
-      return;
+    ) {
+      return true;
     }
-    this._configChanged = true;
-  }
-
-  _computeButtonDisabled(readOnly: boolean, configChanged: boolean) {
-    return readOnly || !configChanged;
-  }
-
-  _computeHeaderClass(configChanged: boolean) {
-    return configChanged ? 'edited' : '';
-  }
-
-  _computeSchemes(schemesDefault: string[], schemesObj?: SchemesInfoMap) {
-    return !schemesObj ? schemesDefault : Object.keys(schemesObj);
-  }
-
-  _schemesChanged(schemes: string[]) {
-    if (schemes.length === 0) {
-      return;
+    if (
+      this.isEdited(
+        originalConfig.create_new_change_for_all_not_in_target,
+        repoConfig.create_new_change_for_all_not_in_target
+      )
+    ) {
+      return true;
     }
-    if (!this._selectedScheme || !schemes.includes(this._selectedScheme)) {
-      this._selectedScheme = schemes.sort()[0];
+    if (
+      this.isEdited(
+        originalConfig.require_change_id,
+        repoConfig.require_change_id
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.enable_signed_push,
+        repoConfig.enable_signed_push
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.require_signed_push,
+        repoConfig.require_signed_push
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.reject_implicit_merges,
+        repoConfig.reject_implicit_merges
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.enable_reviewer_by_email,
+        repoConfig.enable_reviewer_by_email
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.private_by_default,
+        repoConfig.private_by_default
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.work_in_progress_by_default,
+        repoConfig.work_in_progress_by_default
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.max_object_size_limit,
+        repoConfig.max_object_size_limit
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.match_author_to_committer_date,
+        repoConfig.match_author_to_committer_date
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.reject_empty_commit,
+        repoConfig.reject_empty_commit
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.use_contributor_agreements,
+        repoConfig.use_contributor_agreements
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.use_signed_off_by,
+        repoConfig.use_signed_off_by
+      )
+    ) {
+      return true;
+    }
+
+    return this.pluginConfigChanged;
+  }
+
+  private computeSchemesAndDefault() {
+    this.schemes = !this.schemesObj ? [] : Object.keys(this.schemesObj).sort();
+    if (this.schemes.length > 0) {
+      if (!this.selectedScheme || !this.schemes.includes(this.selectedScheme)) {
+        this.selectedScheme = this.schemes.sort()[0];
+      }
     }
   }
 
-  _computeCommands(
+  private computeCommands(
     repo?: RepoName,
     schemesObj?: SchemesInfoMap,
-    _selectedScheme?: string
+    selectedScheme?: string
   ) {
-    if (!schemesObj || !repo || !_selectedScheme) return [];
-    if (!hasOwnProperty(schemesObj, _selectedScheme)) return [];
-    const commandObj = schemesObj[_selectedScheme].clone_commands;
+    if (!schemesObj || !repo || !selectedScheme) return [];
+    if (!hasOwnProperty(schemesObj, selectedScheme)) return [];
+    const commandObj = schemesObj[selectedScheme].clone_commands;
     const commands = [];
     for (const [title, command] of Object.entries(commandObj)) {
       commands.push({
@@ -406,36 +1103,166 @@
     return commands;
   }
 
-  _computeRepositoriesClass(config: InheritedBooleanInfo) {
-    return config ? 'showConfig' : '';
-  }
-
-  _computeChangesUrl(name: RepoName) {
+  private computeChangesUrl(name?: RepoName) {
+    if (!name) return '';
     return GerritNav.getUrlForProjectChanges(name);
   }
 
-  _computeBrowseUrl(weblinks: WebLinkInfo[]) {
-    return weblinks?.[0]?.url;
-  }
-
-  _handlePluginConfigChanged({
-    detail: {name, config, notifyPath},
+  // private but used in test
+  handlePluginConfigChanged({
+    detail: {name, config},
   }: {
     detail: {
       name: string;
       config: PluginParameterToConfigParameterInfoMap;
-      notifyPath: string;
     };
   }) {
-    if (this._repoConfig?.plugin_config) {
-      this._repoConfig.plugin_config[name] = config;
-      this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
+    if (this.repoConfig?.plugin_config) {
+      this.repoConfig.plugin_config[name] = config;
+      this.pluginConfigChanged = true;
+      this.requestUpdate();
     }
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-repo': GrRepo;
+  private handleDescriptionTextChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig || this.loading) return;
+    this.repoConfig = {
+      ...this.repoConfig,
+      description: e.detail.value,
+    };
+    this.requestUpdate();
+  }
+
+  private handleStateSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig || this.loading) return;
+    this.repoConfig = {
+      ...this.repoConfig,
+      state: e.detail.value as ProjectState,
+    };
+    this.requestUpdate();
+  }
+
+  private handleSubmitTypeSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig || this.loading) return;
+    this.repoConfig = {
+      ...this.repoConfig,
+      submit_type: e.detail.value as SubmitType,
+    };
+    this.requestUpdate();
+  }
+
+  private handleContentMergeSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.use_content_merge || this.loading) return;
+    this.repoConfig.use_content_merge.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleNewChangeSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (
+      !this.repoConfig?.create_new_change_for_all_not_in_target ||
+      this.loading
+    )
+      return;
+    this.repoConfig.create_new_change_for_all_not_in_target.configured_value = e
+      .detail.value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRequireChangeIdSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.require_change_id || this.loading) return;
+    this.repoConfig.require_change_id.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleEnableSignedPushBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.enable_signed_push || this.loading) return;
+    this.repoConfig.enable_signed_push.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRequireSignedPushBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.require_signed_push || this.loading) return;
+    this.repoConfig.require_signed_push.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRejectImplicitMergeSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.reject_implicit_merges || this.loading) return;
+    this.repoConfig.reject_implicit_merges.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleUnRegisteredCcSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.enable_reviewer_by_email || this.loading) return;
+    this.repoConfig.enable_reviewer_by_email.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleSetAllNewChangesPrivateByDefaultSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.private_by_default || this.loading) return;
+    this.repoConfig.private_by_default.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleSetAllNewChangesWorkInProgressByDefaultSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.work_in_progress_by_default || this.loading) return;
+    this.repoConfig.work_in_progress_by_default.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleMaxGitObjSizeBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.max_object_size_limit || this.loading) return;
+    this.repoConfig.max_object_size_limit.value = e.detail.value;
+    this.repoConfig.max_object_size_limit.configured_value = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handleMatchAuthoredDateWithCommitterDateSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.match_author_to_committer_date || this.loading)
+      return;
+    this.repoConfig.match_author_to_committer_date.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRejectEmptyCommitSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.reject_empty_commit || this.loading) return;
+    this.repoConfig.reject_empty_commit.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleUseContributorAgreementsBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.use_contributor_agreements || this.loading) return;
+    this.repoConfig.use_contributor_agreements.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleUseSignedOffBySelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.use_signed_off_by || this.loading) return;
+    this.repoConfig.use_signed_off_by.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
deleted file mode 100644
index 2bfad1f..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
+++ /dev/null
@@ -1,451 +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">
-    .info {
-      margin-bottom: var(--spacing-xl);
-    }
-    h2.edited:after {
-      color: var(--deemphasized-text-color);
-      content: ' *';
-    }
-    .loading,
-    .hide {
-      display: none;
-    }
-    #loading.loading {
-      display: block;
-    }
-    #loading:not(.loading) {
-      display: none;
-    }
-    #options .repositorySettings {
-      display: none;
-    }
-    #options .repositorySettings.showConfig {
-      display: block;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="main gr-form-styles read-only">
-    <style include="shared-styles">
-      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-    </style>
-    <div class="info">
-      <h1 id="Title" class="heading-1">[[repo]]</h1>
-      <hr />
-      <div>
-        <a href$="[[_computeBrowseUrl(weblinks)]]"
-          ><gr-button link disabled="[[!_computeBrowseUrl(weblinks)]]"
-            >Browse</gr-button
-          ></a
-        ><a href$="[[_computeChangesUrl(repo)]]"
-          ><gr-button link>View Changes</gr-button></a
-        >
-      </div>
-    </div>
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <div id="downloadContent" class$="[[_computeHideClass(_schemes)]]">
-        <h2 id="download" class="heading-2">Download</h2>
-        <fieldset>
-          <gr-download-commands
-            id="downloadCommands"
-            commands="[[_computeCommands(repo, _schemesObj, _selectedScheme)]]"
-            schemes="[[_schemes]]"
-            selected-scheme="{{_selectedScheme}}"
-          ></gr-download-commands>
-        </fieldset>
-      </div>
-      <h2
-        id="configurations"
-        class$="heading-2 [[_computeHeaderClass(_configChanged)]]"
-      >
-        Configurations
-      </h2>
-      <div id="form">
-        <fieldset>
-          <h3 id="Description" class="heading-3">Description</h3>
-          <fieldset>
-            <gr-textarea
-              id="descriptionInput"
-              class="description"
-              autocomplete="on"
-              placeholder="<Insert repo description here>"
-              rows="4"
-              text="{{_repoConfig.description}}"
-              disabled$="[[_readOnly]]"
-            ></gr-textarea>
-          </fieldset>
-          <h3 id="Options" class="heading-3">Repository Options</h3>
-          <fieldset id="options">
-            <section>
-              <span class="title">State</span>
-              <span class="value">
-                <gr-select id="stateSelect" bind-value="{{_repoConfig.state}}">
-                  <select disabled$="[[_readOnly]]">
-                    <template is="dom-repeat" items="[[_states]]">
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Submit type</span>
-              <span class="value">
-                <gr-select
-                  id="submitTypeSelect"
-                  bind-value="{{_repoConfig.submit_type}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatSubmitTypeSelect(_repoConfig)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Allow content merges</span>
-              <span class="value">
-                <gr-select
-                  id="contentMergeSelect"
-                  bind-value="{{_repoConfig.use_content_merge.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_content_merge)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Create a new change for every commit not in the target branch
-              </span>
-              <span class="value">
-                <gr-select
-                  id="newChangeSelect"
-                  bind-value="{{_repoConfig.create_new_change_for_all_not_in_target.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.create_new_change_for_all_not_in_target)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Require Change-Id in commit message</span>
-              <span class="value">
-                <gr-select
-                  id="requireChangeIdSelect"
-                  bind-value="{{_repoConfig.require_change_id.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.require_change_id)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section
-              id="enableSignedPushSettings"
-              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.enable_signed_push)]]"
-            >
-              <span class="title">Enable signed push</span>
-              <span class="value">
-                <gr-select
-                  id="enableSignedPush"
-                  bind-value="{{_repoConfig.enable_signed_push.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.enable_signed_push)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section
-              id="requireSignedPushSettings"
-              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.require_signed_push)]]"
-            >
-              <span class="title">Require signed push</span>
-              <span class="value">
-                <gr-select
-                  id="requireSignedPush"
-                  bind-value="{{_repoConfig.require_signed_push.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.require_signed_push)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Reject implicit merges when changes are pushed for review</span
-              >
-              <span class="value">
-                <gr-select
-                  id="rejectImplicitMergesSelect"
-                  bind-value="{{_repoConfig.reject_implicit_merges.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.reject_implicit_merges)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Enable adding unregistered users as reviewers and CCs on
-                changes</span
-              >
-              <span class="value">
-                <gr-select
-                  id="unRegisteredCcSelect"
-                  bind-value="{{_repoConfig.enable_reviewer_by_email.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.enable_reviewer_by_email)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title"> Set all new changes private by default</span>
-              <span class="value">
-                <gr-select
-                  id="setAllnewChangesPrivateByDefaultSelect"
-                  bind-value="{{_repoConfig.private_by_default.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.private_by_default)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Set new changes to "work in progress" by default</span
-              >
-              <span class="value">
-                <gr-select
-                  id="setAllNewChangesWorkInProgressByDefaultSelect"
-                  bind-value="{{_repoConfig.work_in_progress_by_default.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.work_in_progress_by_default)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Maximum Git object size limit</span>
-              <span class="value">
-                <iron-input
-                  id="maxGitObjSizeIronInput"
-                  bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                  type="text"
-                  disabled$="[[_readOnly]]"
-                >
-                  <input
-                    id="maxGitObjSizeInput"
-                    bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                    is="iron-input"
-                    type="text"
-                    disabled$="[[_readOnly]]"
-                  />
-                </iron-input>
-                <template
-                  is="dom-if"
-                  if="[[_repoConfig.max_object_size_limit.value]]"
-                >
-                  effective: [[_repoConfig.max_object_size_limit.value]] bytes
-                </template>
-              </span>
-            </section>
-            <section>
-              <span class="title"
-                >Match authored date with committer date upon submit</span
-              >
-              <span class="value">
-                <gr-select
-                  id="matchAuthoredDateWithCommitterDateSelect"
-                  bind-value="{{_repoConfig.match_author_to_committer_date.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.match_author_to_committer_date)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Reject empty commit upon submit</span>
-              <span class="value">
-                <gr-select
-                  id="rejectEmptyCommitSelect"
-                  bind-value="{{_repoConfig.reject_empty_commit.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.reject_empty_commit)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-          </fieldset>
-          <h3 id="Options" class="heading-3">Contributor Agreements</h3>
-          <fieldset id="agreements">
-            <section>
-              <span class="title">
-                Require a valid contributor agreement to upload</span
-              >
-              <span class="value">
-                <gr-select
-                  id="contributorAgreementSelect"
-                  bind-value="{{_repoConfig.use_contributor_agreements.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_contributor_agreements)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Require Signed-off-by in commit message</span>
-              <span class="value">
-                <gr-select
-                  id="useSignedOffBySelect"
-                  bind-value="{{_repoConfig.use_signed_off_by.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_signed_off_by)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-          </fieldset>
-          <div
-            class$="pluginConfig [[_computeHideClass(_pluginData)]]"
-            on-plugin-config-changed="_handlePluginConfigChanged"
-          >
-            <h3 class="heading-3">Plugins</h3>
-            <template is="dom-repeat" items="[[_pluginData]]" as="data">
-              <gr-repo-plugin-config
-                plugin-data="[[data]]"
-                disabled$="[[_readOnly]]"
-              ></gr-repo-plugin-config>
-            </template>
-          </div>
-          <gr-button
-            on-click="_handleSaveRepoConfig"
-            disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]"
-            >Save changes</gr-button
-          >
-        </fieldset>
-        <gr-endpoint-decorator name="repo-config">
-          <gr-endpoint-param
-            name="repoName"
-            value="[[repo]]"
-          ></gr-endpoint-param>
-          <gr-endpoint-param
-            name="readOnly"
-            value="[[_readOnly]]"
-          ></gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </div>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
deleted file mode 100644
index c780a62..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
+++ /dev/null
@@ -1,358 +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-repo.js';
-import {mockPromise} from '../../../test/test-utils.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-repo');
-
-suite('gr-repo tests', () => {
-  let element;
-  let loggedInStub;
-  let repoStub;
-  const repoConf = {
-    description: 'Access inherited by all other projects.',
-    use_contributor_agreements: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    use_content_merge: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    use_signed_off_by: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    create_new_change_for_all_not_in_target: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    require_change_id: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    enable_signed_push: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    require_signed_push: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    reject_implicit_merges: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    private_by_default: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    match_author_to_committer_date: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    reject_empty_commit: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    enable_reviewer_by_email: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    max_object_size_limit: {},
-    submit_type: 'MERGE_IF_NECESSARY',
-    default_submit_type: {
-      value: 'MERGE_IF_NECESSARY',
-      configured_value: 'INHERIT',
-      inherited_value: 'MERGE_IF_NECESSARY',
-    },
-  };
-
-  const REPO = 'test-repo';
-  const SCHEMES = {http: {}, repo: {}, ssh: {}};
-
-  function getFormFields() {
-    const selects = Array.from(
-        element.root.querySelectorAll('select'));
-    const textareas = Array.from(
-        element.root.querySelectorAll('iron-autogrow-textarea'));
-    const inputs = Array.from(
-        element.root.querySelectorAll('input'));
-    return inputs.concat(textareas).concat(selects);
-  }
-
-  setup(() => {
-    loggedInStub = stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getConfig').returns(Promise.resolve({download: {}}));
-    repoStub =
-        stubRestApi('getProjectConfig').returns(Promise.resolve(repoConf));
-    element = basicFixture.instantiate();
-  });
-
-  test('_computePluginData', () => {
-    assert.deepEqual(element._computePluginData(), []);
-    assert.deepEqual(element._computePluginData({}), []);
-    assert.deepEqual(element._computePluginData({base: {}}), []);
-    assert.deepEqual(element._computePluginData({base: {plugin: 'data'}}),
-        [{name: 'plugin', config: 'data'}]);
-  });
-
-  test('_handlePluginConfigChanged', () => {
-    const notifyStub = sinon.stub(element, 'notifyPath');
-    element._repoConfig = {plugin_config: {}};
-    element._handlePluginConfigChanged({detail: {
-      name: 'test',
-      config: 'data',
-      notifyPath: 'path',
-    }});
-    flush();
-
-    assert.equal(element._repoConfig.plugin_config.test, 'data');
-    assert.equal(notifyStub.lastCall.args[0],
-        '_repoConfig.plugin_config.path');
-  });
-
-  test('loading displays before repo config is loaded', () => {
-    assert.isTrue(element.$.loading.classList.contains('loading'));
-    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
-    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
-    assert.isTrue(getComputedStyle(element.$.loadedContent)
-        .display === 'none');
-  });
-
-  test('download commands visibility', () => {
-    element._loading = false;
-    flush();
-    assert.isTrue(element.$.downloadContent.classList.contains('hide'));
-    assert.isTrue(getComputedStyle(element.$.downloadContent)
-        .display == 'none');
-    element._schemesObj = SCHEMES;
-    flush();
-    assert.isFalse(element.$.downloadContent.classList.contains('hide'));
-    assert.isFalse(getComputedStyle(element.$.downloadContent)
-        .display == 'none');
-  });
-
-  test('form defaults to read only', () => {
-    assert.isTrue(element._readOnly);
-  });
-
-  test('form defaults to read only when not logged in', async () => {
-    element.repo = REPO;
-    await element._loadRepo();
-    assert.isTrue(element._readOnly);
-  });
-
-  test('form defaults to read only when logged in and not admin', async () => {
-    element.repo = REPO;
-    stubRestApi('getRepoAccess')
-        .callsFake(() => Promise.resolve({'test-repo': {}}));
-    await element._loadRepo();
-    assert.isTrue(element._readOnly);
-  });
-
-  test('all form elements are disabled when not admin', async () => {
-    element.repo = REPO;
-    await element._loadRepo();
-    flush();
-    const formFields = getFormFields();
-    for (const field of formFields) {
-      assert.isTrue(field.hasAttribute('disabled'));
-    }
-  });
-
-  test('_formatBooleanSelect', () => {
-    let item = {inherited_value: true};
-    assert.deepEqual(element._formatBooleanSelect(item), [
-      {
-        label: 'Inherit (true)',
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ]);
-
-    item = {inherited_value: false};
-    assert.deepEqual(element._formatBooleanSelect(item), [
-      {
-        label: 'Inherit (false)',
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ]);
-
-    // For items without inherited values
-    item = {};
-    assert.deepEqual(element._formatBooleanSelect(item), [
-      {
-        label: 'Inherit',
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ]);
-  });
-
-  test('fires page-error', async () => {
-    repoStub.restore();
-
-    element.repo = 'test';
-
-    const pageErrorFired = mockPromise();
-    const response = {status: 404};
-    stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
-      errFn(response);
-      return Promise.resolve(undefined);
-    });
-    addListenerForTest(document, 'page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      pageErrorFired.resolve();
-    });
-
-    element._loadRepo();
-    await pageErrorFired;
-  });
-
-  suite('admin', () => {
-    setup(() => {
-      element.repo = REPO;
-      loggedInStub.returns(Promise.resolve(true));
-      stubRestApi('getRepoAccess')
-          .returns(Promise.resolve({'test-repo': {is_owner: true}}));
-    });
-
-    test('all form elements are enabled', async () => {
-      await element._loadRepo();
-      await flush();
-      const formFields = getFormFields();
-      for (const field of formFields) {
-        assert.isFalse(field.hasAttribute('disabled'));
-      }
-      assert.isFalse(element._loading);
-    });
-
-    test('state gets set correctly', async () => {
-      await element._loadRepo();
-      assert.equal(element._repoConfig.state, 'ACTIVE');
-      assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
-    });
-
-    test('inherited submit type value is calculated correctly', async () => {
-      await element._loadRepo();
-      const sel = element.$.submitTypeSelect;
-      assert.equal(sel.bindValue, 'INHERIT');
-      assert.equal(
-          sel.nativeSelect.options[0].text,
-          'Inherit (Merge if necessary)'
-      );
-    });
-
-    test('fields update and save correctly', async () => {
-      const configInputObj = {
-        description: 'new description',
-        use_contributor_agreements: 'TRUE',
-        use_content_merge: 'TRUE',
-        use_signed_off_by: 'TRUE',
-        create_new_change_for_all_not_in_target: 'TRUE',
-        require_change_id: 'TRUE',
-        enable_signed_push: 'TRUE',
-        require_signed_push: 'TRUE',
-        reject_implicit_merges: 'TRUE',
-        private_by_default: 'TRUE',
-        match_author_to_committer_date: 'TRUE',
-        reject_empty_commit: 'TRUE',
-        max_object_size_limit: 10,
-        submit_type: 'FAST_FORWARD_ONLY',
-        state: 'READ_ONLY',
-        enable_reviewer_by_email: 'TRUE',
-      };
-
-      const saveStub = stubRestApi('saveRepoConfig')
-          .callsFake(() => Promise.resolve({}));
-
-      const button = element.root.querySelectorAll('gr-button')[2];
-
-      await element._loadRepo();
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
-      element.$.descriptionInput.text = configInputObj.description;
-      element.$.stateSelect.bindValue = configInputObj.state;
-      element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
-      element.$.contentMergeSelect.bindValue =
-          configInputObj.use_content_merge;
-      element.$.newChangeSelect.bindValue =
-          configInputObj.create_new_change_for_all_not_in_target;
-      element.$.requireChangeIdSelect.bindValue =
-          configInputObj.require_change_id;
-      element.$.enableSignedPush.bindValue =
-          configInputObj.enable_signed_push;
-      element.$.requireSignedPush.bindValue =
-          configInputObj.require_signed_push;
-      element.$.rejectImplicitMergesSelect.bindValue =
-          configInputObj.reject_implicit_merges;
-      element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
-          configInputObj.private_by_default;
-      element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
-          configInputObj.match_author_to_committer_date;
-      const inputElement = PolymerElement ?
-        element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
-      inputElement.bindValue = configInputObj.max_object_size_limit;
-      element.$.contributorAgreementSelect.bindValue =
-          configInputObj.use_contributor_agreements;
-      element.$.useSignedOffBySelect.bindValue =
-          configInputObj.use_signed_off_by;
-      element.$.rejectEmptyCommitSelect.bindValue =
-          configInputObj.reject_empty_commit;
-      element.$.unRegisteredCcSelect.bindValue =
-          configInputObj.enable_reviewer_by_email;
-
-      assert.isFalse(button.hasAttribute('disabled'));
-      assert.isTrue(element.$.configurations.classList.contains('edited'));
-
-      const formattedObj =
-          element._formatRepoConfigForSave(element._repoConfig);
-      assert.deepEqual(formattedObj, configInputObj);
-
-      await element._handleSaveRepoConfig();
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
-      assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
-          configInputObj));
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..65eee70
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -0,0 +1,570 @@
+/**
+ * @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-repo';
+import {GrRepo} from './gr-repo';
+import {mockPromise} from '../../../test/test-utils';
+import {
+  addListenerForTest,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  createInheritedBoolean,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {
+  ConfigInfo,
+  GitRef,
+  GroupId,
+  GroupName,
+  InheritedBooleanInfo,
+  MaxObjectSizeLimitInfo,
+  PluginParameterToConfigParameterInfoMap,
+  ProjectAccessGroups,
+  ProjectAccessInfoMap,
+  RepoName,
+} from '../../../types/common';
+import {
+  ConfigParameterInfoType,
+  InheritedBooleanInfoConfiguredValue,
+  ProjectState,
+  SubmitType,
+} from '../../../constants/constants';
+import {
+  createConfig,
+  createDownloadSchemes,
+} from '../../../test/test-data-generators';
+import {PageErrorEvent} from '../../../types/events.js';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
+import {IronInputElement} from '@polymer/iron-input/iron-input';
+
+const basicFixture = fixtureFromElement('gr-repo');
+
+suite('gr-repo tests', () => {
+  let element: GrRepo;
+  let loggedInStub: sinon.SinonStub;
+  let repoStub: sinon.SinonStub;
+
+  const repoConf: ConfigInfo = {
+    description: 'Access inherited by all other projects.',
+    use_contributor_agreements: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    use_content_merge: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    use_signed_off_by: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    create_new_change_for_all_not_in_target: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    require_change_id: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    enable_signed_push: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    require_signed_push: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    reject_implicit_merges: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    match_author_to_committer_date: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    reject_empty_commit: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    enable_reviewer_by_email: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    private_by_default: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    work_in_progress_by_default: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    max_object_size_limit: {},
+    commentlinks: {},
+    submit_type: SubmitType.MERGE_IF_NECESSARY,
+    default_submit_type: {
+      value: SubmitType.MERGE_IF_NECESSARY,
+      configured_value: SubmitType.INHERIT,
+      inherited_value: SubmitType.MERGE_IF_NECESSARY,
+    },
+  };
+
+  const REPO = 'test-repo';
+  const SCHEMES = {
+    ...createDownloadSchemes(),
+    http: {
+      url: 'test',
+      is_auth_required: false,
+      is_auth_supported: false,
+      commands: 'test',
+      clone_commands: {clone: 'test'},
+    },
+    repo: {
+      url: 'test',
+      is_auth_required: false,
+      is_auth_supported: false,
+      commands: 'test',
+      clone_commands: {clone: 'test'},
+    },
+    ssh: {
+      url: 'test',
+      is_auth_required: false,
+      is_auth_supported: false,
+      commands: 'test',
+      clone_commands: {clone: 'test'},
+    },
+  };
+
+  function getFormFields() {
+    const selects = Array.from(queryAll(element, 'select'));
+    const textareas = Array.from(queryAll(element, 'iron-autogrow-textarea'));
+    const inputs = Array.from(queryAll(element, 'input'));
+    return inputs.concat(textareas).concat(selects);
+  }
+
+  setup(async () => {
+    loggedInStub = stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
+    repoStub = stubRestApi('getProjectConfig').returns(
+      Promise.resolve(repoConf)
+    );
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  test('_computePluginData', async () => {
+    element.repoConfig = {
+      ...createConfig(),
+      plugin_config: {},
+    };
+    await element.updateComplete;
+    assert.deepEqual(element.computePluginData(), []);
+
+    element.repoConfig.plugin_config = {
+      'test-plugin': {
+        test: {display_name: 'test plugin', type: 'STRING'},
+      } as PluginParameterToConfigParameterInfoMap,
+    };
+    await element.updateComplete;
+    assert.deepEqual(element.computePluginData(), [
+      {
+        name: 'test-plugin',
+        config: {
+          test: {
+            display_name: 'test plugin',
+            type: 'STRING' as ConfigParameterInfoType,
+          },
+        },
+      },
+    ]);
+  });
+
+  test('handlePluginConfigChanged', async () => {
+    const requestUpdateStub = sinon.stub(element, 'requestUpdate');
+    element.repoConfig = {
+      ...createConfig(),
+      plugin_config: {},
+    };
+    element.handlePluginConfigChanged({
+      detail: {
+        name: 'test',
+        config: {
+          test: {display_name: 'test plugin', type: 'STRING'},
+        } as PluginParameterToConfigParameterInfoMap,
+      },
+    });
+    await element.updateComplete;
+
+    assert.deepEqual(element.repoConfig.plugin_config!.test, {
+      test: {display_name: 'test plugin', type: 'STRING'},
+    } as PluginParameterToConfigParameterInfoMap);
+    assert.isTrue(requestUpdateStub.called);
+  });
+
+  test('loading displays before repo config is loaded', () => {
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
+        'loading'
+      )
+    );
+    assert.isFalse(
+      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '#loading'))
+        .display === 'none'
+    );
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#loadedContent'
+      ).classList.contains('loading')
+    );
+    assert.isTrue(
+      getComputedStyle(
+        queryAndAssert<HTMLDivElement>(element, '#loadedContent')
+      ).display === 'none'
+    );
+  });
+
+  test('download commands visibility', async () => {
+    element.loading = false;
+    await element.updateComplete;
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#downloadContent'
+      ).classList.contains('hide')
+    );
+    assert.isTrue(
+      getComputedStyle(
+        queryAndAssert<HTMLDivElement>(element, '#downloadContent')
+      ).display === 'none'
+    );
+    element.schemesObj = SCHEMES;
+    await element.updateComplete;
+    assert.isFalse(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#downloadContent'
+      ).classList.contains('hide')
+    );
+    assert.isFalse(
+      getComputedStyle(
+        queryAndAssert<HTMLDivElement>(element, '#downloadContent')
+      ).display === 'none'
+    );
+  });
+
+  test('form defaults to read only', () => {
+    assert.isTrue(element.readOnly);
+  });
+
+  test('form defaults to read only when not logged in', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
+    assert.isTrue(element.readOnly);
+  });
+
+  test('form defaults to read only when logged in and not admin', async () => {
+    element.repo = REPO as RepoName;
+
+    stubRestApi('getRepoAccess').callsFake(() =>
+      Promise.resolve({
+        'test-repo': {
+          revision: 'xxxx',
+          local: {
+            'refs/*': {
+              permissions: {
+                owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
+              },
+            },
+          },
+          owner_of: ['refs/*'] as GitRef[],
+          groups: {
+            xxxx: {
+              id: 'xxxx' as GroupId,
+              url: 'test',
+              name: 'test' as GroupName,
+            },
+          } as ProjectAccessGroups,
+          config_web_links: [{name: 'gitiles', url: 'test'}],
+        },
+      } as ProjectAccessInfoMap)
+    );
+    await element.loadRepo();
+    assert.isTrue(element.readOnly);
+  });
+
+  test('all form elements are disabled when not admin', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
+    await element.updateComplete;
+    const formFields = getFormFields();
+    for (const field of formFields) {
+      assert.isTrue(field.hasAttribute('disabled'));
+    }
+  });
+
+  test('formatBooleanSelect', () => {
+    let item: InheritedBooleanInfo = {
+      ...createInheritedBoolean(true),
+      inherited_value: true,
+    };
+    assert.deepEqual(element.formatBooleanSelect(item), [
+      {
+        label: 'Inherit (true)',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      },
+      {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+
+    item = {...createInheritedBoolean(false), inherited_value: false};
+    assert.deepEqual(element.formatBooleanSelect(item), [
+      {
+        label: 'Inherit (false)',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      },
+      {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+
+    // For items without inherited values
+    item = createInheritedBoolean(false);
+    assert.deepEqual(element.formatBooleanSelect(item), [
+      {
+        label: 'Inherit',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      },
+      {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+  });
+
+  test('fires page-error', async () => {
+    repoStub.restore();
+
+    element.repo = 'test' as RepoName;
+
+    const pageErrorFired = mockPromise();
+    const response = {...new Response(), status: 404};
+    stubRestApi('getProjectConfig').callsFake((_, errFn) => {
+      if (errFn !== undefined) {
+        errFn(response);
+      }
+      return Promise.resolve(undefined);
+    });
+    addListenerForTest(document, 'page-error', e => {
+      assert.deepEqual((e as PageErrorEvent).detail.response, response);
+      pageErrorFired.resolve();
+    });
+
+    element.loadRepo();
+    await pageErrorFired;
+  });
+
+  suite('admin', () => {
+    setup(() => {
+      element.repo = REPO as RepoName;
+      loggedInStub.returns(Promise.resolve(true));
+      stubRestApi('getRepoAccess').callsFake(() =>
+        Promise.resolve({
+          'test-repo': {
+            revision: 'xxxx',
+            local: {
+              'refs/*': {
+                permissions: {
+                  owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
+                },
+              },
+            },
+            is_owner: true,
+            owner_of: ['refs/*'] as GitRef[],
+            groups: {
+              xxxx: {
+                id: 'xxxx' as GroupId,
+                url: 'test',
+                name: 'test' as GroupName,
+              },
+            } as ProjectAccessGroups,
+            config_web_links: [{name: 'gitiles', url: 'test'}],
+          },
+        } as ProjectAccessInfoMap)
+      );
+    });
+
+    test('all form elements are enabled', async () => {
+      await element.loadRepo();
+      await element.updateComplete;
+      const formFields = getFormFields();
+      for (const field of formFields) {
+        assert.isFalse(field.hasAttribute('disabled'));
+      }
+      assert.isFalse(element.loading);
+    });
+
+    test('state gets set correctly', async () => {
+      await element.loadRepo();
+      assert.equal(element.repoConfig!.state, ProjectState.ACTIVE);
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#stateSelect').bindValue,
+        ProjectState.ACTIVE
+      );
+    });
+
+    test('inherited submit type value is calculated correctly', async () => {
+      await element.loadRepo();
+      const sel = queryAndAssert<GrSelect>(element, '#submitTypeSelect');
+      assert.equal(sel.bindValue, 'INHERIT');
+      assert.equal(
+        sel.nativeSelect.options[0].text,
+        'Inherit (Merge if necessary)'
+      );
+    });
+
+    test('fields update and save correctly', async () => {
+      const configInputObj = {
+        description: 'new description',
+        use_contributor_agreements: InheritedBooleanInfoConfiguredValue.TRUE,
+        use_content_merge: InheritedBooleanInfoConfiguredValue.TRUE,
+        use_signed_off_by: InheritedBooleanInfoConfiguredValue.TRUE,
+        create_new_change_for_all_not_in_target:
+          InheritedBooleanInfoConfiguredValue.TRUE,
+        require_change_id: InheritedBooleanInfoConfiguredValue.TRUE,
+        enable_signed_push: InheritedBooleanInfoConfiguredValue.TRUE,
+        require_signed_push: InheritedBooleanInfoConfiguredValue.TRUE,
+        reject_implicit_merges: InheritedBooleanInfoConfiguredValue.TRUE,
+        private_by_default: InheritedBooleanInfoConfiguredValue.TRUE,
+        work_in_progress_by_default: InheritedBooleanInfoConfiguredValue.TRUE,
+        match_author_to_committer_date:
+          InheritedBooleanInfoConfiguredValue.TRUE,
+        reject_empty_commit: InheritedBooleanInfoConfiguredValue.TRUE,
+        max_object_size_limit: '10' as MaxObjectSizeLimitInfo,
+        submit_type: SubmitType.FAST_FORWARD_ONLY,
+        state: ProjectState.READ_ONLY,
+        enable_reviewer_by_email: InheritedBooleanInfoConfiguredValue.TRUE,
+      };
+
+      const saveStub = stubRestApi('saveRepoConfig').callsFake(() =>
+        Promise.resolve(new Response())
+      );
+
+      const button = queryAll<GrButton>(element, 'gr-button')[2];
+
+      await element.loadRepo();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(
+        queryAndAssert<HTMLHeadingElement>(
+          element,
+          '#Title'
+        ).classList.contains('edited')
+      );
+      queryAndAssert<GrTextarea>(element, '#descriptionInput').text =
+        configInputObj.description;
+      queryAndAssert<GrSelect>(element, '#stateSelect').bindValue =
+        configInputObj.state;
+      queryAndAssert<GrSelect>(element, '#submitTypeSelect').bindValue =
+        configInputObj.submit_type;
+      queryAndAssert<GrSelect>(element, '#contentMergeSelect').bindValue =
+        configInputObj.use_content_merge;
+      queryAndAssert<GrSelect>(element, '#newChangeSelect').bindValue =
+        configInputObj.create_new_change_for_all_not_in_target;
+      queryAndAssert<GrSelect>(element, '#requireChangeIdSelect').bindValue =
+        configInputObj.require_change_id;
+      queryAndAssert<GrSelect>(element, '#enableSignedPush').bindValue =
+        configInputObj.enable_signed_push;
+      queryAndAssert<GrSelect>(element, '#requireSignedPush').bindValue =
+        configInputObj.require_signed_push;
+      queryAndAssert<GrSelect>(
+        element,
+        '#rejectImplicitMergesSelect'
+      ).bindValue = configInputObj.reject_implicit_merges;
+      queryAndAssert<GrSelect>(
+        element,
+        '#setAllnewChangesPrivateByDefaultSelect'
+      ).bindValue = configInputObj.private_by_default;
+      queryAndAssert<GrSelect>(
+        element,
+        '#setAllNewChangesWorkInProgressByDefaultSelect'
+      ).bindValue = configInputObj.work_in_progress_by_default;
+      queryAndAssert<GrSelect>(
+        element,
+        '#matchAuthoredDateWithCommitterDateSelect'
+      ).bindValue = configInputObj.match_author_to_committer_date;
+      queryAndAssert<IronInputElement>(
+        element,
+        '#maxGitObjSizeIronInput'
+      ).bindValue = String(configInputObj.max_object_size_limit);
+      queryAndAssert<GrSelect>(
+        element,
+        '#contributorAgreementSelect'
+      ).bindValue = configInputObj.use_contributor_agreements;
+      queryAndAssert<GrSelect>(element, '#useSignedOffBySelect').bindValue =
+        configInputObj.use_signed_off_by;
+      queryAndAssert<GrSelect>(element, '#rejectEmptyCommitSelect').bindValue =
+        configInputObj.reject_empty_commit;
+      queryAndAssert<GrSelect>(element, '#unRegisteredCcSelect').bindValue =
+        configInputObj.enable_reviewer_by_email;
+
+      await element.updateComplete;
+
+      assert.isFalse(button.hasAttribute('disabled'));
+      assert.isTrue(
+        queryAndAssert<HTMLHeadingElement>(
+          element,
+          '#configurations'
+        ).classList.contains('edited')
+      );
+
+      const formattedObj = element.formatRepoConfigForSave(element.repoConfig);
+      assert.deepEqual(formattedObj, configInputObj);
+
+      await element.handleSaveRepoConfig();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(
+        queryAndAssert<HTMLHeadingElement>(
+          element,
+          '#Title'
+        ).classList.contains('edited')
+      );
+      assert.isTrue(
+        saveStub.lastCall.calledWithExactly(REPO as RepoName, configInputObj)
+      );
+    });
+  });
+});
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 172f807..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
@@ -15,16 +15,19 @@
  * limitations under the License.
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-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-rule-editor_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {AccessPermissionId} from '../../../utils/access-util';
-import {property, customElement, observe} from '@polymer/decorators';
 import {fireEvent} from '../../../utils/event-util';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
+import {ifDefined} from 'lit/directives/if-defined';
+import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
+import {PermissionAction} from '../../../constants/constants';
 
 /**
  * Fired when the rule has been modified or removed.
@@ -38,15 +41,19 @@
  * @event added-rule-removed
  */
 
-const PRIORITY_OPTIONS = ['BATCH', 'INTERACTIVE'];
+const PRIORITY_OPTIONS = [PermissionAction.BATCH, PermissionAction.INTERACTIVE];
 
 const Action = {
-  ALLOW: 'ALLOW',
-  DENY: 'DENY',
-  BLOCK: 'BLOCK',
+  ALLOW: PermissionAction.ALLOW,
+  DENY: PermissionAction.DENY,
+  BLOCK: PermissionAction.BLOCK,
 };
 
-const DROPDOWN_OPTIONS = [Action.ALLOW, Action.DENY, Action.BLOCK];
+const DROPDOWN_OPTIONS = [
+  PermissionAction.ALLOW,
+  PermissionAction.DENY,
+  PermissionAction.BLOCK,
+];
 
 const ForcePushOptions = {
   ALLOW: [
@@ -70,19 +77,7 @@
   },
 ];
 
-interface Rule {
-  value: RuleValue;
-}
-
-interface RuleValue {
-  min?: number;
-  max?: number;
-  force?: boolean;
-  action?: string;
-  added?: boolean;
-  modified?: boolean;
-  deleted?: boolean;
-}
+type Rule = {value?: EditablePermissionRuleInfo};
 
 interface RuleLabel {
   values: RuleLabelValue[];
@@ -100,18 +95,14 @@
 }
 
 @customElement('gr-rule-editor')
-export class GrRuleEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrRuleEditor extends LitElement {
   @property({type: Boolean})
   hasRange?: boolean;
 
   @property({type: Object})
   label?: RuleLabel;
 
-  @property({type: Boolean, observer: '_handleEditingChanged'})
+  @property({type: Boolean})
   editing = false;
 
   @property({type: String})
@@ -124,97 +115,281 @@
   @property({type: String})
   permission!: AccessPermissionId;
 
-  @property({type: Object, notify: true})
+  @property({type: Object})
   rule?: Rule;
 
   @property({type: String})
   section?: string;
 
-  @property({type: Boolean})
-  _deleted = false;
+  // private but used in test
+  @state() deleted = false;
 
-  @property({type: Object})
-  _originalRuleValues?: RuleValue;
+  // private but used in test
+  @state() originalRuleValues?: EditablePermissionRuleInfo;
 
   constructor() {
     super();
-    this.addEventListener('access-saved', () => this._handleAccessSaved());
-  }
-
-  override ready() {
-    super.ready();
-    // Called on ready rather than the observer because when new rules are
-    // added, the observer is triggered prior to being ready.
-    if (!this.rule) {
-      return;
-    } // Check needed for test purposes.
-    this._setupValues(this.rule);
+    this.addEventListener('access-saved', () => this.handleAccessSaved());
   }
 
   override connectedCallback() {
     super.connectedCallback();
+    if (this.rule) {
+      this.setupValues();
+    }
     // Check needed for test purposes.
-    if (!this._originalRuleValues && this.rule) {
-      // Observer _handleValueChange is called after the ready()
-      // method finishes. Original values must be set later to
-      // avoid set .modified flag to true
-      this._setOriginalRuleValues(this.rule.value);
+    if (!this.originalRuleValues && this.rule) {
+      this.setOriginalRuleValues();
     }
   }
 
-  _setupValues(rule: Rule) {
-    if (!rule.value) {
-      this._setDefaultRuleValues();
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      css`
+        :host {
+          border-bottom: 1px solid var(--border-color);
+          padding: var(--spacing-m);
+          display: block;
+        }
+        #removeBtn {
+          display: none;
+        }
+        .editing #removeBtn {
+          display: flex;
+        }
+        #options {
+          align-items: baseline;
+          display: flex;
+        }
+        #options > * {
+          margin-right: var(--spacing-m);
+        }
+        #mainContainer {
+          align-items: baseline;
+          display: flex;
+          flex-wrap: nowrap;
+          justify-content: space-between;
+        }
+        #deletedContainer.deleted {
+          align-items: baseline;
+          display: flex;
+          justify-content: space-between;
+        }
+        #undoBtn,
+        #force,
+        #deletedContainer,
+        #mainContainer.deleted {
+          display: none;
+        }
+        #undoBtn.modified,
+        #force.force {
+          display: block;
+        }
+        .groupPath {
+          color: var(--deemphasized-text-color);
+        }
+        iron-autogrow-textarea {
+          width: 14em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div
+        id="mainContainer"
+        class="gr-form-styles ${this.computeSectionClass()}"
+      >
+        <div id="options">
+          <gr-select
+            id="action"
+            .bindValue=${this.rule?.value?.action}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.handleActionBindValueChanged(e);
+            }}
+          >
+            <select ?disabled=${!this.editing}>
+              ${this.computeOptions().map(
+                item => html` <option value=${item}>${item}</option> `
+              )}
+            </select>
+          </gr-select>
+          ${this.renderMinAndMaxLabel()} ${this.renderMinAndMaxInput()}
+          <a
+            class="groupPath"
+            href=${ifDefined(this.computeGroupPath(this.groupId))}
+          >
+            ${this.groupName}
+          </a>
+          <gr-select
+            id="force"
+            class=${this.computeForce(this.rule?.value?.action) ? 'force' : ''}
+            .bindValue=${this.rule?.value?.force}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.handleForceBindValueChanged(e);
+            }}
+          >
+            <select ?disabled=${!this.editing}>
+              ${this.computeForceOptions(this.rule?.value?.action).map(
+                item => html`
+                  <option value=${item.value}>${item.name}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </div>
+        <gr-button
+          link
+          id="removeBtn"
+          @click=${() => {
+            this.handleRemoveRule();
+          }}
+          >Remove</gr-button
+        >
+      </div>
+      <div
+        id="deletedContainer"
+        class="gr-form-styles ${this.computeSectionClass()}"
+      >
+        ${this.groupName} was deleted
+        <gr-button
+          link
+          id="undoRemoveBtn"
+          @click=${() => {
+            this.handleUndoRemove();
+          }}
+          >Undo</gr-button
+        >
+      </div>
+    `;
+  }
+
+  private renderMinAndMaxLabel() {
+    if (!this.label) return;
+
+    return html`
+      <gr-select
+        id="labelMin"
+        .bindValue=${this.rule?.value?.min}
+        @bind-value-changed=${(e: BindValueChangeEvent) => {
+          this.handleMinBindValueChanged(e);
+        }}
+      >
+        <select ?disabled=${!this.editing}>
+          ${this.label.values.map(
+            item => html` <option value=${item.value}>${item.value}</option> `
+          )}
+        </select>
+      </gr-select>
+      <gr-select
+        id="labelMax"
+        .bindValue=${this.rule?.value?.max}
+        @bind-value-changed=${(e: BindValueChangeEvent) => {
+          this.handleMaxBindValueChanged(e);
+        }}
+      >
+        <select ?disabled=${!this.editing}>
+          ${this.label.values.map(
+            item => html` <option value=${item.value}>${item.value}</option> `
+          )}
+        </select>
+      </gr-select>
+    `;
+  }
+
+  private renderMinAndMaxInput() {
+    if (!this.hasRange) return;
+
+    return html`
+      <iron-autogrow-textarea
+        id="minInput"
+        class="min"
+        autocomplete="on"
+        placeholder="Min value"
+        .bindValue=${this.rule?.value?.min}
+        ?disabled=${!this.editing}
+        @bind-value-changed=${(e: BindValueChangeEvent) => {
+          this.handleMinBindValueChanged(e);
+        }}
+      ></iron-autogrow-textarea>
+      <iron-autogrow-textarea
+        id="maxInput"
+        class="max"
+        autocomplete="on"
+        placeholder="Max value"
+        .bindValue=${this.rule?.value?.max}
+        ?disabled=${!this.editing}
+        @bind-value-changed=${(e: BindValueChangeEvent) => {
+          this.handleMaxBindValueChanged(e);
+        }}
+      ></iron-autogrow-textarea>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('editing')) {
+      this.handleEditingChanged(changedProperties.get('editing') as boolean);
     }
   }
 
-  _computeForce(permission: AccessPermissionId, action: string) {
-    if (AccessPermissionId.PUSH === permission && action !== Action.DENY) {
+  // private but used in test
+  setupValues() {
+    if (!this.rule?.value) {
+      this.setDefaultRuleValues();
+    }
+  }
+
+  // private but used in test
+  computeForce(action?: string) {
+    if (AccessPermissionId.PUSH === this.permission && action !== Action.DENY) {
       return true;
     }
 
-    return AccessPermissionId.EDIT_TOPIC_NAME === permission;
+    return AccessPermissionId.EDIT_TOPIC_NAME === this.permission;
   }
 
-  _computeForceClass(permission: AccessPermissionId, action: string) {
-    return this._computeForce(permission, action) ? 'force' : '';
+  // private but used in test
+  computeGroupPath(groupId?: string) {
+    if (!groupId) return;
+    return `${getBaseUrl()}/admin/groups/${encodeURL(groupId, true)}`;
   }
 
-  _computeGroupPath(group: string) {
-    return `${getBaseUrl()}/admin/groups/${encodeURL(group, true)}`;
-  }
-
-  _handleAccessSaved() {
-    if (!this.rule) return;
+  // private but used in test
+  handleAccessSaved() {
     // Set a new 'original' value to keep track of after the value has been
     // saved.
-    this._setOriginalRuleValues(this.rule.value);
+    this.setOriginalRuleValues();
   }
 
-  _handleEditingChanged(editing: boolean, editingOld: boolean) {
+  private handleEditingChanged(editingOld: boolean) {
     // Ignore when editing gets set initially.
     if (!editingOld) {
       return;
     }
     // Restore original values if no longer editing.
-    if (!editing) {
-      this._handleUndoChange();
+    if (!this.editing) {
+      this.handleUndoChange();
     }
   }
 
-  _computeSectionClass(editing: boolean, deleted: boolean) {
+  // private but used in test
+  computeSectionClass() {
     const classList = [];
-    if (editing) {
+    if (this.editing) {
       classList.push('editing');
     }
-    if (deleted) {
+    if (this.deleted) {
       classList.push('deleted');
     }
     return classList.join(' ');
   }
 
-  _computeForceOptions(permission: string, action: string) {
-    if (permission === AccessPermissionId.PUSH) {
+  // private but used in test
+  computeForceOptions(action?: string) {
+    if (this.permission === AccessPermissionId.PUSH) {
       if (action === Action.ALLOW) {
         return ForcePushOptions.ALLOW;
       } else if (action === Action.BLOCK) {
@@ -222,82 +397,164 @@
       } else {
         return [];
       }
-    } else if (permission === AccessPermissionId.EDIT_TOPIC_NAME) {
+    } else if (this.permission === AccessPermissionId.EDIT_TOPIC_NAME) {
       return FORCE_EDIT_OPTIONS;
     }
     return [];
   }
 
-  _getDefaultRuleValues(permission: AccessPermissionId, label?: RuleLabel) {
-    const ruleAction = Action.ALLOW;
-    const value: RuleValue = {};
-    if (permission === AccessPermissionId.PRIORITY) {
-      value.action = PRIORITY_OPTIONS[0];
-      return value;
-    } else if (label) {
-      value.min = label.values[0].value;
-      value.max = label.values[label.values.length - 1].value;
-    } else if (this._computeForce(permission, ruleAction)) {
-      value.force = this._computeForceOptions(permission, ruleAction)[0].value;
+  // private but used in test
+  getDefaultRuleValues(): EditablePermissionRuleInfo {
+    if (this.permission === AccessPermissionId.PRIORITY) {
+      return {action: PRIORITY_OPTIONS[0]};
     }
-    value.action = DROPDOWN_OPTIONS[0];
-    return value;
+    if (this.label) {
+      return {
+        action: DROPDOWN_OPTIONS[0],
+        min: this.label.values[0].value,
+        max: this.label.values[this.label.values.length - 1].value,
+      };
+    }
+    if (this.computeForce(Action.ALLOW)) {
+      return {
+        action: DROPDOWN_OPTIONS[0],
+        force: this.computeForceOptions(Action.ALLOW)[0].value,
+      };
+    }
+    return {action: DROPDOWN_OPTIONS[0]};
   }
 
-  _setDefaultRuleValues() {
-    this.set(
-      'rule.value',
-      this._getDefaultRuleValues(this.permission, this.label)
-    );
+  // private but used in test
+  setDefaultRuleValues() {
+    this.rule!.value = this.getDefaultRuleValues();
+
+    this.handleRuleChange();
   }
 
-  _computeOptions(permission: string) {
-    if (permission === 'priority') {
+  // private but used in test
+  computeOptions() {
+    if (this.permission === 'priority') {
       return PRIORITY_OPTIONS;
     }
     return DROPDOWN_OPTIONS;
   }
 
-  _handleRemoveRule() {
-    if (!this.rule) return;
+  private handleRemoveRule() {
+    if (!this.rule?.value) return;
     if (this.rule.value.added) {
       fireEvent(this, 'added-rule-removed');
     }
-    this._deleted = true;
+    this.deleted = true;
     this.rule.value.deleted = true;
+
+    this.handleRuleChange();
+
     fireEvent(this, 'access-modified');
   }
 
-  _handleUndoRemove() {
-    if (!this.rule) return;
-    this._deleted = false;
+  private handleUndoRemove() {
+    if (!this.rule?.value) return;
+    this.deleted = false;
     delete this.rule.value.deleted;
+
+    this.handleRuleChange();
   }
 
-  _handleUndoChange() {
-    if (!this.rule) return;
+  private handleUndoChange() {
+    if (!this.originalRuleValues || !this.rule?.value) {
+      return;
+    }
     // gr-permission will take care of removing rules that were added but
     // unsaved. We need to keep the added bit for the filter.
     if (this.rule.value.added) {
       return;
     }
-    this.set('rule.value', {...this._originalRuleValues});
-    this._deleted = false;
+    this.rule.value = {...this.originalRuleValues};
+    this.deleted = false;
     delete this.rule.value.deleted;
     delete this.rule.value.modified;
+
+    this.handleRuleChange();
   }
 
-  @observe('rule.value.*')
-  _handleValueChange() {
-    if (!this._originalRuleValues || !this.rule) {
+  // private but used in test
+  handleValueChange() {
+    if (!this.originalRuleValues || !this.rule?.value) {
       return;
     }
     this.rule.value.modified = true;
+
+    this.handleRuleChange();
+
     // Allows overall access page to know a change has been made.
     fireEvent(this, 'access-modified');
   }
 
-  _setOriginalRuleValues(value: RuleValue) {
-    this._originalRuleValues = {...value};
+  // private but used in test
+  setOriginalRuleValues() {
+    if (!this.rule?.value) return;
+    this.originalRuleValues = {...this.rule.value};
+  }
+
+  private handleActionBindValueChanged(e: BindValueChangeEvent) {
+    if (
+      !this.rule?.value ||
+      e.detail.value === undefined ||
+      this.rule.value.action === String(e.detail.value)
+    )
+      return;
+
+    this.rule.value.action = String(e.detail.value) as PermissionAction;
+
+    this.handleValueChange();
+  }
+
+  private handleMinBindValueChanged(e: BindValueChangeEvent) {
+    if (
+      !this.rule?.value ||
+      e.detail.value === undefined ||
+      this.rule.value.min === Number(e.detail.value)
+    )
+      return;
+    this.rule.value.min = Number(e.detail.value);
+
+    this.handleValueChange();
+  }
+
+  private handleMaxBindValueChanged(e: BindValueChangeEvent) {
+    if (
+      !this.rule?.value ||
+      e.detail.value === undefined ||
+      this.rule.value.max === Number(e.detail.value)
+    )
+      return;
+    this.rule.value.max = Number(e.detail.value);
+
+    this.handleValueChange();
+  }
+
+  private handleForceBindValueChanged(e: BindValueChangeEvent) {
+    const forceValue = String(e.detail.value) === 'true' ? true : false;
+    if (
+      !this.rule?.value ||
+      e.detail.value === undefined ||
+      this.rule.value.force === forceValue
+    )
+      return;
+    this.rule.value.force = forceValue;
+
+    this.handleValueChange();
+  }
+
+  private handleRuleChange() {
+    this.requestUpdate('rule');
+
+    this.dispatchEvent(
+      new CustomEvent('rule-changed', {
+        detail: {value: this.rule},
+        composed: true,
+        bubbles: true,
+      })
+    );
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
deleted file mode 100644
index c4d7688..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
+++ /dev/null
@@ -1,159 +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 {
-      border-bottom: 1px solid var(--border-color);
-      padding: var(--spacing-m);
-      display: block;
-    }
-    #removeBtn {
-      display: none;
-    }
-    .editing #removeBtn {
-      display: flex;
-    }
-    #options {
-      align-items: baseline;
-      display: flex;
-    }
-    #options > * {
-      margin-right: var(--spacing-m);
-    }
-    #mainContainer {
-      align-items: baseline;
-      display: flex;
-      flex-wrap: nowrap;
-      justify-content: space-between;
-    }
-    #deletedContainer.deleted {
-      align-items: baseline;
-      display: flex;
-      justify-content: space-between;
-    }
-    #undoBtn,
-    #force,
-    #deletedContainer,
-    #mainContainer.deleted {
-      display: none;
-    }
-    #undoBtn.modified,
-    #force.force {
-      display: block;
-    }
-    .groupPath {
-      color: var(--deemphasized-text-color);
-    }
-  </style>
-  <style include="gr-form-styles">
-    iron-autogrow-textarea {
-      width: 14em;
-    }
-  </style>
-  <div
-    id="mainContainer"
-    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
-  >
-    <div id="options">
-      <gr-select
-        id="action"
-        bind-value="{{rule.value.action}}"
-        on-change="_handleValueChange"
-      >
-        <select disabled$="[[!editing]]">
-          <template is="dom-repeat" items="[[_computeOptions(permission)]]">
-            <option value="[[item]]">[[item]]</option>
-          </template>
-        </select>
-      </gr-select>
-      <template is="dom-if" if="[[label]]">
-        <gr-select
-          id="labelMin"
-          bind-value="{{rule.value.min}}"
-          on-change="_handleValueChange"
-        >
-          <select disabled$="[[!editing]]">
-            <template is="dom-repeat" items="[[label.values]]">
-              <option value="[[item.value]]">[[item.value]]</option>
-            </template>
-          </select>
-        </gr-select>
-        <gr-select
-          id="labelMax"
-          bind-value="{{rule.value.max}}"
-          on-change="_handleValueChange"
-        >
-          <select disabled$="[[!editing]]">
-            <template is="dom-repeat" items="[[label.values]]">
-              <option value="[[item.value]]">[[item.value]]</option>
-            </template>
-          </select>
-        </gr-select>
-      </template>
-      <template is="dom-if" if="[[hasRange]]">
-        <iron-autogrow-textarea
-          id="minInput"
-          class="min"
-          autocomplete="on"
-          placeholder="Min value"
-          bind-value="{{rule.value.min}}"
-          disabled$="[[!editing]]"
-        ></iron-autogrow-textarea>
-        <iron-autogrow-textarea
-          id="maxInput"
-          class="max"
-          autocomplete="on"
-          placeholder="Max value"
-          bind-value="{{rule.value.max}}"
-          disabled$="[[!editing]]"
-        ></iron-autogrow-textarea>
-      </template>
-      <a class="groupPath" href$="[[_computeGroupPath(groupId)]]">
-        [[groupName]]
-      </a>
-      <gr-select
-        id="force"
-        class$="[[_computeForceClass(permission, rule.value.action)]]"
-        bind-value="{{rule.value.force}}"
-        on-change="_handleValueChange"
-      >
-        <select disabled$="[[!editing]]">
-          <template
-            is="dom-repeat"
-            items="[[_computeForceOptions(permission, rule.value.action)]]"
-          >
-            <option value="[[item.value]]">[[item.name]]</option>
-          </template>
-        </select>
-      </gr-select>
-    </div>
-    <gr-button link="" id="removeBtn" on-click="_handleRemoveRule"
-      >Remove</gr-button
-    >
-  </div>
-  <div
-    id="deletedContainer"
-    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
-  >
-    [[groupName]] was deleted
-    <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
-      >Undo</gr-button
-    >
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
deleted file mode 100644
index f3df132..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
+++ /dev/null
@@ -1,586 +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-rule-editor.js';
-
-const basicFixture = fixtureFromElement('gr-rule-editor');
-
-suite('gr-rule-editor tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('unit tests', () => {
-    test('_computeForce, _computeForceClass, and _computeForceOptions',
-        () => {
-          const ForcePushOptions = {
-            ALLOW: [
-              {name: 'Allow pushing (but not force pushing)', value: false},
-              {name: 'Allow pushing with or without force', value: true},
-            ],
-            BLOCK: [
-              {name: 'Block pushing with or without force', value: false},
-              {name: 'Block force pushing', value: true},
-            ],
-          };
-
-          const FORCE_EDIT_OPTIONS = [
-            {
-              name: 'No Force Edit',
-              value: false,
-            },
-            {
-              name: 'Force Edit',
-              value: true,
-            },
-          ];
-          let permission = 'push';
-          let action = 'ALLOW';
-          assert.isTrue(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action),
-              'force');
-          assert.deepEqual(element._computeForceOptions(permission, action),
-              ForcePushOptions.ALLOW);
-
-          action = 'BLOCK';
-          assert.isTrue(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action),
-              'force');
-          assert.deepEqual(element._computeForceOptions(permission, action),
-              ForcePushOptions.BLOCK);
-
-          action = 'DENY';
-          assert.isFalse(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action), '');
-          assert.equal(
-              element._computeForceOptions(permission, action).length, 0);
-
-          permission = 'editTopicName';
-          assert.isTrue(element._computeForce(permission));
-          assert.equal(element._computeForceClass(permission), 'force');
-          assert.deepEqual(element._computeForceOptions(permission),
-              FORCE_EDIT_OPTIONS);
-          permission = 'submit';
-          assert.isFalse(element._computeForce(permission));
-          assert.equal(element._computeForceClass(permission), '');
-          assert.deepEqual(element._computeForceOptions(permission), []);
-        });
-
-    test('_computeSectionClass', () => {
-      let deleted = true;
-      let editing = false;
-      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
-      deleted = false;
-      assert.equal(element._computeSectionClass(editing, deleted), '');
-
-      editing = true;
-      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
-      deleted = true;
-      assert.equal(element._computeSectionClass(editing, deleted),
-          'editing deleted');
-    });
-
-    test('_getDefaultRuleValues', () => {
-      let permission = 'priority';
-      let label;
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'BATCH'});
-      permission = 'label-Code-Review';
-      label = {values: [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged 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'},
-      ]};
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW', max: 2, min: -2});
-      permission = 'push';
-      label = undefined;
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW', force: false});
-      permission = 'submit';
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW'});
-    });
-
-    test('_setDefaultRuleValues', () => {
-      element.rule = {id: 123};
-      const defaultValue = {action: 'ALLOW'};
-      sinon.stub(element, '_getDefaultRuleValues').returns(defaultValue);
-      element._setDefaultRuleValues();
-      assert.isTrue(element._getDefaultRuleValues.called);
-      assert.equal(element.rule.value, defaultValue);
-    });
-
-    test('_computeOptions', () => {
-      const PRIORITY_OPTIONS = [
-        'BATCH',
-        'INTERACTIVE',
-      ];
-      const DROPDOWN_OPTIONS = [
-        'ALLOW',
-        'DENY',
-        'BLOCK',
-      ];
-      let permission = 'priority';
-      assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
-      permission = 'submit';
-      assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
-    });
-
-    test('_handleValueChange', () => {
-      const modifiedHandler = sinon.stub();
-      element.rule = {value: {}};
-      element.addEventListener('access-modified', modifiedHandler);
-      element._handleValueChange();
-      assert.isNotOk(element.rule.value.modified);
-      element._originalRuleValues = {};
-      element._handleValueChange();
-      assert.isTrue(element.rule.value.modified);
-      assert.isTrue(modifiedHandler.called);
-    });
-
-    test('_handleAccessSaved', () => {
-      const originalValue = {action: 'DENY'};
-      const newValue = {action: 'ALLOW'};
-      element._originalRuleValues = originalValue;
-      element.rule = {value: newValue};
-      element._handleAccessSaved();
-      assert.deepEqual(element._originalRuleValues, newValue);
-    });
-
-    test('_setOriginalRuleValues', () => {
-      const value = {
-        action: 'ALLOW',
-        force: false,
-      };
-      element._setOriginalRuleValues(value);
-      assert.deepEqual(element._originalRuleValues, value);
-    });
-  });
-
-  suite('already existing generic rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'submit';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: false,
-        },
-      };
-      element.section = 'refs/*';
-
-      // Typically called on ready since elements will have properties defined
-      // by the parent element.
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.isNotOk(element.root.querySelector('#labelMin'));
-      assert.isNotOk(element.root.querySelector('#labelMax'));
-      assert.isFalse(element.$.force.classList.contains('force'));
-    });
-
-    test('modify and cancel restores original values', () => {
-      element.editing = true;
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = 'DENY';
-      assert.isTrue(element.rule.value.modified);
-      element.editing = false;
-      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-      assert.equal(element.$.action.bindValue, 'ALLOW');
-      assert.isNotOk(element.rule.value.modified);
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = 'DENY';
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('all selects are disabled when not in edit mode', () => {
-      const selects = element.root.querySelectorAll('select');
-      for (const select of selects) {
-        assert.isTrue(select.disabled);
-      }
-      element.editing = true;
-      for (const select of selects) {
-        assert.isFalse(select.disabled);
-      }
-    });
-
-    test('remove rule and undo remove', () => {
-      element.editing = true;
-      element.rule = {id: 123, value: {action: 'ALLOW'}};
-      assert.isFalse(
-          element.$.deletedContainer.classList.contains('deleted'));
-      MockInteractions.tap(element.$.removeBtn);
-      assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
-      assert.isTrue(element._deleted);
-      assert.isTrue(element.rule.value.deleted);
-
-      MockInteractions.tap(element.$.undoRemoveBtn);
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.rule.value.deleted);
-    });
-
-    test('remove rule and cancel', () => {
-      element.editing = true;
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-
-      element.rule = {id: 123, value: {action: 'ALLOW'}};
-      MockInteractions.tap(element.$.removeBtn);
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.notEqual(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-      assert.isTrue(element._deleted);
-      assert.isTrue(element.rule.value.deleted);
-
-      element.editing = false;
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.rule.value.deleted);
-      assert.isNotOk(element.rule.value.modified);
-
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-    });
-
-    test('_computeGroupPath', () => {
-      const group = '123';
-      assert.equal(element._computeGroupPath(group),
-          `/admin/groups/123`);
-    });
-  });
-
-  suite('new edit rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'editTopicName';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.rule.value.added = true;
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      const expectedRuleValue = {
-        action: 'ALLOW',
-        force: false,
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.force.bindValue = true;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('remove value', () => {
-      element.editing = true;
-      const removeStub = sinon.stub();
-      element.addEventListener('added-rule-removed', removeStub);
-      MockInteractions.tap(element.$.removeBtn);
-      flush();
-      assert.isTrue(removeStub.called);
-    });
-  });
-
-  suite('already existing rule with labels', () => {
-    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: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      element.group = 'Group Name';
-      element.permission = 'label-Code-Review';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: false,
-          max: 2,
-          min: -2,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          element.root.querySelector('#labelMin').bindValue,
-          element.rule.value.min);
-      assert.equal(
-          element.root.querySelector('#labelMax').bindValue,
-          element.rule.value.max);
-      assert.isFalse(element.$.force.classList.contains('force'));
-    });
-
-    test('modify value', () => {
-      const removeStub = sinon.stub();
-      element.addEventListener('added-rule-removed', removeStub);
-      assert.isNotOk(element.rule.value.modified);
-      element.root.querySelector('#labelMin').bindValue = 1;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-      assert.isFalse(removeStub.called);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('new rule with labels', () => {
-    setup(async () => {
-      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: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      element.group = 'Group Name';
-      element.permission = 'label-Code-Review';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.rule.value.added = true;
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      assert.isTrue(element._setDefaultRuleValues.called);
-
-      const expectedRuleValue = {
-        max: element.label.values[element.label.values.length - 1].value,
-        min: element.label.values[0].value,
-        action: 'ALLOW',
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(
-            element.$.action.bindValue,
-            expectedRuleValue.action);
-        assert.equal(
-            element.root.querySelector('#labelMin').bindValue,
-            expectedRuleValue.min);
-        assert.equal(
-            element.root.querySelector('#labelMax').bindValue,
-            expectedRuleValue.max);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.root.querySelector('#labelMin').bindValue = 1;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('already existing push rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'push';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: true,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.isTrue(element.$.force.classList.contains('force'));
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          element.root.querySelector('#force').bindValue,
-          element.rule.value.force);
-      assert.isNotOk(element.root.querySelector('#labelMin'));
-      assert.isNotOk(element.root.querySelector('#labelMax'));
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = false;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('new push rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'push';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.rule.value.added = true;
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      const expectedRuleValue = {
-        action: 'ALLOW',
-        force: false,
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.force.bindValue = true;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('already existing edit rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'editTopicName';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: true,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.isTrue(element.$.force.classList.contains('force'));
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          element.root.querySelector('#force').bindValue,
-          element.rule.value.force);
-      assert.isNotOk(element.root.querySelector('#labelMin'));
-      assert.isNotOk(element.root.querySelector('#labelMax'));
-    });
-
-    test('modify value', async () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = false;
-      await flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..047bbb6
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
@@ -0,0 +1,774 @@
+/**
+ * @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-rule-editor';
+import {GrRuleEditor} from './gr-rule-editor';
+import {AccessPermissionId} from '../../../utils/access-util';
+import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
+import {PermissionAction} from '../../../constants/constants';
+
+suite('gr-rule-editor tests', () => {
+  let element: GrRuleEditor;
+
+  setup(async () => {
+    element = await fixture<GrRuleEditor>(html`
+      <gr-rule-editor></gr-rule-editor>
+    `);
+    await element.updateComplete;
+  });
+
+  suite('dom tests', () => {
+    test('default', () => {
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <div class="gr-form-styles" id="mainContainer">
+          <div id="options">
+            <gr-select id="action">
+              <select disabled="">
+                <option value="ALLOW">ALLOW</option>
+                <option value="DENY">DENY</option>
+                <option value="BLOCK">BLOCK</option>
+              </select>
+            </gr-select>
+            <a class="groupPath"> </a>
+            <gr-select id="force">
+              <select disabled=""></select>
+            </gr-select>
+          </div>
+          <gr-button
+            aria-disabled="false"
+            id="removeBtn"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            Remove
+          </gr-button>
+        </div>
+        <div class="gr-form-styles" id="deletedContainer">
+          was deleted
+          <gr-button
+            aria-disabled="false"
+            id="undoRemoveBtn"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            Undo
+          </gr-button>
+        </div>
+      `);
+    });
+
+    test('push options', async () => {
+      const rule: {value: EditablePermissionRuleInfo} = {
+        value: {
+          action: PermissionAction.ALLOW,
+        },
+      };
+      element = await fixture<GrRuleEditor>(html`
+        <gr-rule-editor
+          .editing=${true}
+          .rule=${rule}
+          .permission=${AccessPermissionId.PUSH}
+        ></gr-rule-editor>
+      `);
+      expect(queryAndAssert(element, '#options')).dom.to.equal(/* HTML */ `
+        <div id="options">
+          <gr-select id="action">
+            <select>
+              <option value="ALLOW">ALLOW</option>
+              <option value="DENY">DENY</option>
+              <option value="BLOCK">BLOCK</option>
+            </select>
+          </gr-select>
+          <a class="groupPath"> </a>
+          <gr-select class="force" id="force">
+            <select>
+              <option value="false">
+                Allow pushing (but not force pushing)
+              </option>
+              <option value="true">Allow pushing with or without force</option>
+            </select>
+          </gr-select>
+        </div>
+      `);
+    });
+  });
+
+  suite('unit tests', () => {
+    test('computeForce and computeForceOptions', () => {
+      const ForcePushOptions = {
+        ALLOW: [
+          {name: 'Allow pushing (but not force pushing)', value: false},
+          {name: 'Allow pushing with or without force', value: true},
+        ],
+        BLOCK: [
+          {name: 'Block pushing with or without force', value: false},
+          {name: 'Block force pushing', value: true},
+        ],
+      };
+
+      const FORCE_EDIT_OPTIONS = [
+        {
+          name: 'No Force Edit',
+          value: false,
+        },
+        {
+          name: 'Force Edit',
+          value: true,
+        },
+      ];
+      element.permission = 'push' as AccessPermissionId;
+      let action = PermissionAction.ALLOW;
+      assert.isTrue(element.computeForce(action));
+      assert.deepEqual(
+        element.computeForceOptions(action),
+        ForcePushOptions.ALLOW
+      );
+
+      action = PermissionAction.BLOCK;
+      assert.isTrue(element.computeForce(action));
+      assert.deepEqual(
+        element.computeForceOptions(action),
+        ForcePushOptions.BLOCK
+      );
+
+      action = PermissionAction.DENY;
+      assert.isFalse(element.computeForce(action));
+      assert.equal(element.computeForceOptions(action).length, 0);
+
+      element.permission = 'editTopicName' as AccessPermissionId;
+      assert.isTrue(element.computeForce());
+      assert.deepEqual(element.computeForceOptions(), FORCE_EDIT_OPTIONS);
+      element.permission = 'submit' as AccessPermissionId;
+      assert.isFalse(element.computeForce());
+      assert.deepEqual(element.computeForceOptions(), []);
+    });
+
+    test('computeSectionClass', () => {
+      element.deleted = true;
+      element.editing = false;
+      assert.equal(element.computeSectionClass(), 'deleted');
+
+      element.deleted = false;
+      assert.equal(element.computeSectionClass(), '');
+
+      element.editing = true;
+      assert.equal(element.computeSectionClass(), 'editing');
+
+      element.deleted = true;
+      assert.equal(element.computeSectionClass(), 'editing deleted');
+    });
+
+    test('getDefaultRuleValues', () => {
+      element.permission = 'priority' as AccessPermissionId;
+      assert.deepEqual(element.getDefaultRuleValues(), {
+        action: PermissionAction.BATCH,
+      });
+      element.permission = 'label-Code-Review' as AccessPermissionId;
+      element.label = {
+        values: [
+          {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'},
+        ],
+      };
+      assert.deepEqual(element.getDefaultRuleValues(), {
+        action: PermissionAction.ALLOW,
+        max: 2,
+        min: -2,
+      });
+      element.permission = 'push' as AccessPermissionId;
+      element.label = undefined;
+      assert.deepEqual(element.getDefaultRuleValues(), {
+        action: PermissionAction.ALLOW,
+        force: false,
+      });
+      element.permission = 'submit' as AccessPermissionId;
+      assert.deepEqual(element.getDefaultRuleValues(), {
+        action: PermissionAction.ALLOW,
+      });
+    });
+
+    test('setDefaultRuleValues', async () => {
+      element.rule = {};
+      const defaultValue = {action: PermissionAction.ALLOW};
+      const getDefaultRuleValuesStub = sinon
+        .stub(element, 'getDefaultRuleValues')
+        .returns(defaultValue);
+      element.setDefaultRuleValues();
+      assert.isTrue(getDefaultRuleValuesStub.called);
+      assert.equal(element.rule.value, defaultValue);
+    });
+
+    test('computeOptions', () => {
+      const PRIORITY_OPTIONS = ['BATCH', 'INTERACTIVE'];
+      const DROPDOWN_OPTIONS = [
+        PermissionAction.ALLOW,
+        PermissionAction.DENY,
+        PermissionAction.BLOCK,
+      ];
+      element.permission = 'priority' as AccessPermissionId;
+      assert.deepEqual(element.computeOptions(), PRIORITY_OPTIONS);
+      element.permission = 'submit' as AccessPermissionId;
+      assert.deepEqual(element.computeOptions(), DROPDOWN_OPTIONS);
+    });
+
+    test('handleValueChange', () => {
+      const modifiedHandler = sinon.stub();
+      element.rule = {
+        value: {action: PermissionAction.ALLOW},
+      };
+      element.addEventListener('access-modified', modifiedHandler);
+      element.handleValueChange();
+      assert.isNotOk(element.rule.value!.modified);
+      element.originalRuleValues = {action: PermissionAction.ALLOW};
+      element.handleValueChange();
+      assert.isTrue(element.rule.value!.modified);
+      assert.isTrue(modifiedHandler.called);
+    });
+
+    test('handleAccessSaved', () => {
+      const originalValue = {action: PermissionAction.DENY};
+      const newValue = {action: PermissionAction.ALLOW};
+      element.originalRuleValues = originalValue;
+      element.rule = {value: newValue};
+      element.handleAccessSaved();
+      assert.deepEqual(element.originalRuleValues, newValue);
+    });
+
+    test('setOriginalRuleValues', () => {
+      element.rule = {
+        value: {
+          action: PermissionAction.ALLOW,
+          force: false,
+        },
+      };
+      element.setOriginalRuleValues();
+      assert.deepEqual(element.originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('already existing generic rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'submit' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: PermissionAction.ALLOW,
+          force: false,
+        },
+      };
+      element.section = 'refs/*';
+      element.setupValues();
+      element.setOriginalRuleValues();
+      await element.updateComplete;
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+      assert.isFalse(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+    });
+
+    test('modify and cancel restores original values', async () => {
+      element.rule = {value: {action: PermissionAction.ALLOW}};
+      element.setOriginalRuleValues();
+      element.editing = true;
+      await element.updateComplete;
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      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);
+      element.editing = false;
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      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,
+        PermissionAction.ALLOW
+      );
+    });
+
+    test('modify value', async () => {
+      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);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+
+    test('all selects are disabled when not in edit mode', async () => {
+      const selects = queryAll<HTMLSelectElement>(element, 'select');
+      for (const select of selects) {
+        assert.isTrue(select.disabled);
+      }
+      element.editing = true;
+      await element.updateComplete;
+      for (const select of selects) {
+        assert.isFalse(select.disabled);
+      }
+    });
+
+    test('remove rule and undo remove', async () => {
+      element.editing = true;
+      element.rule = {value: {action: PermissionAction.ALLOW}};
+      await element.updateComplete;
+      assert.isFalse(
+        queryAndAssert<HTMLDivElement>(
+          element,
+          '#deletedContainer'
+        ).classList.contains('deleted')
+      );
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      await element.updateComplete;
+      assert.isTrue(
+        queryAndAssert<HTMLDivElement>(
+          element,
+          '#deletedContainer'
+        ).classList.contains('deleted')
+      );
+      assert.isTrue(element.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);
+    });
+
+    test('remove rule and cancel', async () => {
+      element.editing = true;
+      await element.updateComplete;
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+        ).display,
+        'none'
+      );
+
+      element.rule = {value: {action: PermissionAction.ALLOW}};
+      await element.updateComplete;
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      await element.updateComplete;
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.notEqual(
+        getComputedStyle(
+          queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+        ).display,
+        'none'
+      );
+      assert.isTrue(element.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.deepEqual(element.originalRuleValues, element.rule.value);
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+        ).display,
+        'none'
+      );
+    });
+
+    test('computeGroupPath', () => {
+      const group = '123';
+      assert.equal(element.computeGroupPath(group), '/admin/groups/123');
+    });
+  });
+
+  suite('new edit rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'editTopicName' as AccessPermissionId;
+      element.rule = {};
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.rule.value!.added = true;
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule!.value!.modified);
+      const expectedRuleValue = {
+        action: PermissionAction.ALLOW,
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule!.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#action').bindValue,
+          expectedRuleValue.action
+        );
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#force').bindValue,
+          expectedRuleValue.action
+        );
+      });
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
+      forceBindValue.bindValue = 'true';
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+
+    test('remove value', async () => {
+      element.editing = true;
+      const removeStub = sinon.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      MockInteractions.tap(queryAndAssert<GrButton>(element, '#removeBtn'));
+      await element.updateComplete;
+      assert.isTrue(removeStub.called);
+    });
+  });
+
+  suite('already existing rule with labels', () => {
+    setup(async () => {
+      element.label = {
+        values: [
+          {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'},
+        ],
+      };
+      element.groupName = 'Group Name';
+      element.permission = 'label-Code-Review' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: PermissionAction.ALLOW,
+          force: false,
+          max: 2,
+          min: -2,
+        },
+      };
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
+        element.rule!.value!.min
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
+        element.rule!.value!.max
+      );
+      assert.isFalse(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+    });
+
+    test('modify value', async () => {
+      const removeStub = sinon.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      assert.isNotOk(element.rule!.value!.modified);
+      const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
+      labelMinBindValue.bindValue = 1;
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+      assert.isFalse(removeStub.called);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('new rule with labels', () => {
+    let setDefaultRuleValuesSpy: sinon.SinonSpy;
+
+    setup(async () => {
+      setDefaultRuleValuesSpy = sinon.spy(element, 'setDefaultRuleValues');
+      element.label = {
+        values: [
+          {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'},
+        ],
+      };
+      element.groupName = 'Group Name';
+      element.permission = 'label-Code-Review' as AccessPermissionId;
+      element.rule = {};
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.rule.value!.added = true;
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule!.value!.modified);
+      assert.isTrue(setDefaultRuleValuesSpy.called);
+
+      const expectedRuleValue = {
+        max: element.label!.values[element.label!.values.length - 1].value,
+        min: element.label!.values[0].value,
+        action: PermissionAction.ALLOW,
+        added: true,
+      };
+      assert.deepEqual(element.rule!.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#action').bindValue,
+          expectedRuleValue.action
+        );
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
+          expectedRuleValue.min
+        );
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
+          expectedRuleValue.max
+        );
+      });
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
+      labelMinBindValue.bindValue = 1;
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('already existing push rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'push' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: PermissionAction.ALLOW,
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        element.rule!.value!.force
+      );
+      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = false;
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('new push rule', async () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'push' as AccessPermissionId;
+      element.rule = {};
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.rule.value!.added = true;
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule!.value!.modified);
+      const expectedRuleValue = {
+        action: PermissionAction.ALLOW,
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule!.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#action').bindValue,
+          expectedRuleValue.action
+        );
+        assert.equal(
+          queryAndAssert<GrSelect>(element, '#force').bindValue,
+          expectedRuleValue.action
+        );
+      });
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
+      forceBindValue.bindValue = true;
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('already existing edit rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'editTopicName' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: PermissionAction.ALLOW,
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        element.rule!.value!.force
+      );
+      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = false;
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+  });
+});
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..fdd7502
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
@@ -0,0 +1,127 @@
+/**
+ * @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-bulk-abandon-flow/gr-change-list-bulk-abandon-flow';
+import '../gr-change-list-reviewer-flow/gr-change-list-reviewer-flow';
+import '../gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-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);
+
+  override connectedCallback(): void {
+    super.connectedCallback();
+    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-reviewer-flow></gr-change-list-reviewer-flow>
+            <gr-change-list-bulk-abandon-flow>
+            </gr-change-list-bulk-abandon-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..5804b99
--- /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-reviewer-flow></gr-change-list-reviewer-flow>
+            <gr-change-list-bulk-abandon-flow>
+            </gr-change-list-bulk-abandon-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..4fd65df
--- /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);
+        }
+      `,
+    ];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    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..6790b15
--- /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,307 @@
+/**
+ * @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 {
+  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';
+
+@customElement('gr-change-list-bulk-vote-flow')
+export class GrChangeListBulkVoteFlow extends LitElement {
+  private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private readonly userModel = getAppContext().userModel;
+
+  @state() selectedChanges: ChangeInfo[] = [];
+
+  @state() progressByChange: Map<NumericChangeId, ProgressStatus> = new Map();
+
+  @query('#actionOverlay') actionOverlay!: GrOverlay;
+
+  @state() account?: AccountInfo;
+
+  static override get styles() {
+    return [
+      fontStyles,
+      css`
+        .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;
+        }
+        .heading-3 {
+          padding-left: var(--spacing-xl);
+          margin-bottom: var(--spacing-m);
+          margin-top: var(--spacing-l);
+          display: table-caption;
+        }
+        .heading-3:first-of-type {
+          margin-top: 0;
+        }
+      `,
+    ];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    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()}
+          @confirm=${() => this.handleConfirm()}
+          @cancel=${() => this.handleClose()}
+          .cancelLabel=${'Close'}
+        >
+          <div slot="main">
+            ${this.renderLabels(
+              nonTriggerLabels,
+              'Submit requirements votes',
+              permittedLabels
+            )}
+            ${this.renderLabels(
+              triggerLabels,
+              'Trigger Votes',
+              permittedLabels
+            )}
+          </div>
+          <!-- TODO: Add error handling status if something fails -->
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private renderLabels(
+    labels: Label[],
+    heading: string,
+    permittedLabels?: LabelNameToValuesMap
+  ) {
+    return html` <div class="scoresTable newSubmitRequirements">
+      <h3 class="heading-3">${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 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;
+    fireAlert(this, 'Reloading page..');
+    fireReload(this, true);
+  }
+
+  private handleConfirm() {
+    this.progressByChange.clear();
+    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);
+    for (let index = 0; index < promises.length; index++) {
+      const changeNum = this.selectedChanges[index]._number;
+      promises[index]
+        .then(() => {
+          this.progressByChange.set(changeNum, ProgressStatus.SUCCESSFUL);
+        })
+        .catch(() => {
+          this.progressByChange.set(changeNum, ProgressStatus.FAILED);
+        })
+        .finally(() => {
+          this.requestUpdate();
+        });
+    }
+  }
+
+  // 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..5a965d9
--- /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,501 @@
+/**
+ * @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,
+} 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';
+
+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 getChangesStub: SinonStubbedMember<
+    RestApiService['getDetailedChangesWithActions']
+  >;
+
+  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-vote-flow></gr-change-list-bulk-vote-flow>`,
+          bulkActionsModelToken,
+          model
+        )
+      )
+    ).querySelector('gr-change-list-bulk-vote-flow')!;
+    await element.updateComplete;
+  });
+
+  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="main">
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-3">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-3">Trigger Votes</h3>
+              <gr-label-score-row name="change1OnlyTriggerLabelE">
+              </gr-label-score-row>
+            </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.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
+    );
+  });
+
+  suite('closing dialog triggers reloads', () => {
+    test('closing dialog triggers a reload', async () => {
+      const changes: ChangeInfo[] = [change1, change2];
+      getChangesStub.returns(Promise.resolve(changes));
+
+      const fireStub = sinon.stub(element, 'dispatchEvent');
+
+      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
+      );
+
+      assert.isFalse(fireStub.called);
+
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
+
+      await waitUntil(() => fireStub.called);
+      assert.equal(fireStub.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));
+
+      const fireStub = sinon.stub(element, 'dispatchEvent');
+
+      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(fireStub.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
new file mode 100644
index 0000000..09b5cc9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
@@ -0,0 +1,240 @@
+/**
+ * @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 '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
+import '../../shared/gr-change-status/gr-change-status';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {
+  ApprovalInfo,
+  ChangeInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+  LabelInfo,
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../../../api/rest-api';
+import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
+import {
+  extractAssociatedLabels,
+  getAllUniqueApprovals,
+  getRequirements,
+  getTriggerVotes,
+  hasNeutralStatus,
+  hasVotes,
+  iconForStatus,
+} from '../../../utils/label-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ifDefined} from 'lit/directives/if-defined';
+import {capitalizeFirstLetter} from '../../../utils/string-util';
+
+@customElement('gr-change-list-column-requirement')
+export class GrChangeListColumnRequirement extends LitElement {
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property()
+  labelName?: string;
+
+  static override get styles() {
+    return [
+      submitRequirementsStyles,
+      sharedStyles,
+      css`
+        iron-icon {
+          vertical-align: top;
+        }
+        .container {
+          display: flex;
+          align-items: center;
+          justify-content: center;
+        }
+        .container.not-applicable {
+          background-color: var(--table-header-background-color);
+          height: calc(var(--line-height-normal) + var(--spacing-m));
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`<div
+      class="container ${this.computeClass()}"
+      title=${ifDefined(this.computeLabelTitle())}
+    >
+      ${this.renderContent()}
+    </div>`;
+  }
+
+  private renderContent() {
+    if (!this.labelName) return;
+    const requirements = this.getRequirement(this.labelName);
+    if (requirements.length === 0) {
+      return this.renderTriggerVote();
+    }
+
+    const requirement = requirements[0];
+    if (requirement.status === SubmitRequirementStatus.UNSATISFIED) {
+      return this.renderUnsatisfiedState(requirement);
+    } else {
+      return this.renderStatusIcon(requirement.status);
+    }
+  }
+
+  private renderTriggerVote() {
+    if (!this.labelName || !this.isTriggerVote(this.labelName)) return;
+    const allLabels = this.change?.labels ?? {};
+    const labelInfo = allLabels[this.labelName];
+    if (isDetailedLabelInfo(labelInfo)) {
+      // votes sorted from best e.g +2 to worst e.g -2
+      const votes = this.getSortedVotes(this.labelName);
+      if (votes.length > 0) {
+        const bestVote = votes[0];
+        return html`<gr-vote-chip
+          .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;
+  }
+
+  private renderUnsatisfiedState(requirement: SubmitRequirementResultInfo) {
+    const requirementLabels = extractAssociatedLabels(
+      requirement,
+      'onlySubmittability'
+    );
+    const allLabels = this.change?.labels ?? {};
+    const associatedLabels = Object.keys(allLabels).filter(label =>
+      requirementLabels.includes(label)
+    );
+
+    let worstVote: ApprovalInfo | undefined;
+    let labelInfo: LabelInfo | undefined;
+    for (const label of associatedLabels) {
+      // votes sorted from worst e.g -2 to best e.g +2
+      const votes = this.getSortedVotes(label).sort(
+        (a, b) => (a.value ?? 0) - (b.value ?? 0)
+      );
+      if (votes.length === 0) break;
+      if (!worstVote || (worstVote.value ?? 0) > (votes[0].value ?? 0)) {
+        worstVote = votes[0];
+        labelInfo = allLabels[label];
+      }
+    }
+    if (worstVote === undefined) {
+      return this.renderStatusIcon(requirement.status);
+    } else {
+      return html`<gr-vote-chip
+        .vote=${worstVote}
+        .label=${labelInfo}
+        tooltip-with-who-voted
+      ></gr-vote-chip>`;
+    }
+  }
+
+  private renderStatusIcon(status: SubmitRequirementStatus) {
+    const icon = iconForStatus(status ?? SubmitRequirementStatus.ERROR);
+    return html`<iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>`;
+  }
+
+  private computeClass(): string {
+    if (!this.labelName) return '';
+    const requirements = this.getRequirement(this.labelName);
+    if (requirements.length === 0 && !this.isTriggerVote(this.labelName)) {
+      return 'not-applicable';
+    }
+    return '';
+  }
+
+  private computeLabelTitle() {
+    if (!this.labelName) return;
+    const requirements = this.getRequirement(this.labelName);
+    if (requirements.length === 0) {
+      if (this.isTriggerVote(this.labelName)) {
+        return;
+      } else {
+        return 'Requirement not applicable';
+      }
+    }
+    const requirement = requirements[0];
+    if (requirement.status === SubmitRequirementStatus.UNSATISFIED) {
+      const requirementLabels = extractAssociatedLabels(
+        requirement,
+        'onlySubmittability'
+      );
+      const allLabels = this.change?.labels ?? {};
+      const associatedLabels = Object.keys(allLabels).filter(label =>
+        requirementLabels.includes(label)
+      );
+      const requirementWithoutLabelToVoteOn = associatedLabels.length === 0;
+      if (requirementWithoutLabelToVoteOn) {
+        const status = capitalizeFirstLetter(requirement.status.toLowerCase());
+        return status;
+      }
+
+      const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
+        label => !hasVotes(allLabels[label])
+      );
+      if (everyAssociatedLabelsIsWithoutVotes) {
+        return 'No votes';
+      } else {
+        return; // there is a vote with tooltip, so undefined label title
+      }
+    } else {
+      return capitalizeFirstLetter(requirement.status.toLowerCase());
+    }
+  }
+
+  private getRequirement(labelName: string) {
+    const requirements = getRequirements(this.change).filter(
+      sr => sr.name === labelName
+    );
+    // TODO(milutin): Remove this after migration from legacy requirements.
+    if (requirements.length > 1) {
+      return requirements.filter(sr => !sr.is_legacy);
+    } else {
+      return requirements;
+    }
+  }
+
+  private getSortedVotes(label: string) {
+    const allLabels = this.change?.labels ?? {};
+    const labelInfo = allLabels[label];
+    if (isDetailedLabelInfo(labelInfo)) {
+      return getAllUniqueApprovals(labelInfo).filter(
+        approval => !hasNeutralStatus(labelInfo, approval)
+      );
+    }
+    return [];
+  }
+
+  private isTriggerVote(labelName: string) {
+    const triggerVotes = getTriggerVotes(this.change);
+    return triggerVotes.includes(labelName);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-column-requirement': GrChangeListColumnRequirement;
+  }
+}
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
new file mode 100644
index 0000000..96240de
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
@@ -0,0 +1,181 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import './gr-change-list-column-requirement';
+import {GrChangeListColumnRequirement} from './gr-change-list-column-requirement';
+import {
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
+  createChange,
+} from '../../../test/test-data-generators';
+import {
+  AccountId,
+  ChangeInfo,
+  DetailedLabelInfo,
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../../../api/rest-api';
+import {StandardLabels} from '../../../utils/label-util';
+import {queryAndAssert, stubFlags} from '../../../test/test-utils';
+
+suite('gr-change-list-column-requirement tests', () => {
+  let element: GrChangeListColumnRequirement;
+  let change: ChangeInfo;
+  setup(() => {
+    stubFlags('isEnabled').returns(true);
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      name: StandardLabels.CODE_REVIEW,
+      submittability_expression_result: {
+        ...createSubmitRequirementExpressionInfo(),
+      },
+    };
+    change = {
+      ...createChange(),
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
+      unresolved_comment_count: 1,
+    };
+  });
+
+  test('renders', async () => {
+    element = await fixture<GrChangeListColumnRequirement>(
+      html`<gr-change-list-column-requirement
+        .change=${change}
+        .labelName=${StandardLabels.CODE_REVIEW}
+      >
+      </gr-change-list-column-requirement>`
+    );
+    expect(element).shadowDom.to.equal(
+      /* HTML */
+      ` <div class="container" title="Satisfied">
+        <iron-icon
+          class="check-circle-filled"
+          icon="gr-icons:check-circle-filled"
+        >
+        </iron-icon>
+      </div>`
+    );
+  });
+
+  test('show worst vote when state is not satisfied', async () => {
+    const VALUES_2 = {
+      '-2': 'blocking',
+      '-1': 'bad',
+      '0': 'neutral',
+      '+1': 'good',
+      '+2': 'perfect',
+    };
+    const label: DetailedLabelInfo = {
+      values: VALUES_2,
+      all: [
+        {value: -1, _account_id: 777 as AccountId, name: 'Reviewer'},
+        {value: 1, _account_id: 324 as AccountId, name: 'Reviewer 2'},
+      ],
+    };
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      name: StandardLabels.CODE_REVIEW,
+      status: SubmitRequirementStatus.UNSATISFIED,
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    change = {
+      ...change,
+      submit_requirements: [submitRequirement],
+      labels: {
+        Verified: label,
+      },
+    };
+    element = await fixture<GrChangeListColumnRequirement>(
+      html`<gr-change-list-column-requirement
+        .change=${change}
+        .labelName=${StandardLabels.CODE_REVIEW}
+      >
+      </gr-change-list-column-requirement>`
+    );
+    expect(element).shadowDom.to.equal(
+      /* HTML */
+      ` <div class="container">
+        <gr-vote-chip tooltip-with-who-voted=""></gr-vote-chip>
+      </div>`
+    );
+    const voteChip = queryAndAssert(element, 'gr-vote-chip');
+    expect(voteChip).shadowDom.to.equal(
+      /* HTML */
+      ` <gr-tooltip-content
+        class="container"
+        has-tooltip=""
+        title="Reviewer: bad"
+      >
+        <div class="negative vote-chip">-1</div>
+      </gr-tooltip-content>`
+    );
+  });
+
+  test('show trigger vote', async () => {
+    const VALUES_2 = {
+      '-2': 'blocking',
+      '-1': 'bad',
+      '0': 'neutral',
+      '+1': 'good',
+      '+2': 'perfect',
+    };
+    change = {
+      ...change,
+      submit_requirements: [],
+      labels: {
+        'Commit-Queue': {
+          values: VALUES_2,
+          all: [
+            {value: -1, _account_id: 777 as AccountId, name: 'Reviewer'},
+            {value: 1, _account_id: 324 as AccountId, name: 'Reviewer 2'},
+          ],
+        },
+      },
+    };
+    element = await fixture<GrChangeListColumnRequirement>(
+      html`<gr-change-list-column-requirement
+        .change=${change}
+        .labelName=${'Commit-Queue'}
+      >
+      </gr-change-list-column-requirement>`
+    );
+    expect(element).shadowDom.to.equal(
+      /* HTML */
+      ` <div class="container">
+        <gr-vote-chip tooltip-with-who-voted=""></gr-vote-chip>
+      </div>`
+    );
+    const voteChip = queryAndAssert(element, 'gr-vote-chip');
+    expect(voteChip).shadowDom.to.equal(
+      /* HTML */
+      ` <gr-tooltip-content
+        class="container"
+        has-tooltip=""
+        title="Reviewer 2: good"
+      >
+        <div class="positive vote-chip">+1</div>
+      </gr-tooltip-content>`
+    );
+  });
+});
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
new file mode 100644
index 0000000..1f8bf51
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
@@ -0,0 +1,157 @@
+/**
+ * @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 '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
+import '../../shared/gr-change-status/gr-change-status';
+import {LitElement, css, html, TemplateResult} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {ChangeInfo, SubmitRequirementStatus} from '../../../api/rest-api';
+import {changeStatuses} from '../../../utils/change-util';
+import {getRequirements, iconForStatus} from '../../../utils/label-util';
+import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
+import {pluralize} from '../../../utils/string-util';
+
+@customElement('gr-change-list-column-requirements-summary')
+export class GrChangeListColumnRequirementsSummary extends LitElement {
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  static override get styles() {
+    return [
+      submitRequirementsStyles,
+      css`
+        iron-icon {
+          width: var(--line-height-normal, 20px);
+          height: var(--line-height-normal, 20px);
+          vertical-align: top;
+        }
+        iron-icon.block,
+        iron-icon.check-circle-filled {
+          margin-right: var(--spacing-xs);
+        }
+        iron-icon.commentIcon {
+          color: var(--deemphasized-text-color);
+          margin-left: var(--spacing-s);
+        }
+        span {
+          line-height: var(--line-height-normal);
+        }
+        span.check-circle-filled {
+          color: var(--success-foreground);
+        }
+        .unsatisfied {
+          color: var(--primary-text-color);
+        }
+        .total {
+          margin-left: var(--spacing-xs);
+          color: var(--deemphasized-text-color);
+        }
+        :host {
+          align-items: center;
+          display: inline-flex;
+        }
+        .comma {
+          padding-right: var(--spacing-xs);
+        }
+        /* Used to hide the leading separator comma for statuses. */
+        .comma:first-of-type {
+          display: none;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const commentIcon = this.renderCommentIcon();
+    return html`${this.renderChangeStatus()} ${commentIcon}`;
+  }
+
+  renderChangeStatus() {
+    if (!this.change) return;
+    const statuses = changeStatuses(this.change);
+    if (statuses.length > 0) {
+      return statuses.map(
+        status => html`
+          <div class="comma">,</div>
+          <gr-change-status flat .status=${status}></gr-change-status>
+        `
+      );
+    }
+    return this.renderActiveStatus();
+  }
+
+  renderActiveStatus() {
+    const submitRequirements = getRequirements(this.change);
+    if (!submitRequirements.length) return html`n/a`;
+    const numRequirements = submitRequirements.length;
+    const numSatisfied = submitRequirements.filter(
+      req =>
+        req.status === SubmitRequirementStatus.SATISFIED ||
+        req.status === SubmitRequirementStatus.OVERRIDDEN
+    ).length;
+
+    if (numSatisfied === numRequirements) {
+      return this.renderState(
+        iconForStatus(SubmitRequirementStatus.SATISFIED),
+        'Ready'
+      );
+    }
+
+    const numUnsatisfied = submitRequirements.filter(
+      req => req.status === SubmitRequirementStatus.UNSATISFIED
+    ).length;
+
+    return this.renderState(
+      iconForStatus(SubmitRequirementStatus.UNSATISFIED),
+      this.renderSummary(numUnsatisfied, numRequirements)
+    );
+  }
+
+  renderState(icon: string, aggregation: string | TemplateResult) {
+    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
+      >${aggregation}</span
+    >`;
+  }
+
+  renderSummary(numUnsatisfied: number, numRequirements: number) {
+    return html`<span
+      ><span class="unsatisfied">${numUnsatisfied}</span
+      ><span class="total">(of ${numRequirements})</span></span
+    >`;
+  }
+
+  renderCommentIcon() {
+    if (!this.change?.unresolved_comment_count) return;
+    return html`<iron-icon
+      icon="gr-icons:comment"
+      class="commentIcon"
+      .title=${pluralize(
+        this.change?.unresolved_comment_count,
+        'unresolved comment'
+      )}
+    ></iron-icon>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-column-requirements-summary': GrChangeListColumnRequirementsSummary;
+  }
+}
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
new file mode 100644
index 0000000..5cc4e6d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
@@ -0,0 +1,115 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import './gr-change-list-column-requirements-summary';
+import {GrChangeListColumnRequirementsSummary} from './gr-change-list-column-requirements-summary';
+import {
+  createApproval,
+  createDetailedLabelInfo,
+  createParsedChange,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import {
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../../../api/rest-api';
+import {ParsedChangeInfo} from '../../../types/types';
+
+suite('gr-change-list-column-requirements-summary tests', () => {
+  let element: GrChangeListColumnRequirementsSummary;
+  let change: ParsedChangeInfo;
+  setup(() => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      status: SubmitRequirementStatus.UNSATISFIED,
+      description: 'Test Description',
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    change = {
+      ...createParsedChange(),
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
+      labels: {
+        Verified: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      },
+    };
+  });
+
+  test('renders', async () => {
+    element = await fixture<GrChangeListColumnRequirementsSummary>(
+      html`<gr-change-list-column-requirements-summary .change=${change}>
+      </gr-change-list-column-requirements-summary>`
+    );
+    expect(element).shadowDom.to.equal(/* HTML */ ` <span
+      class="block"
+      role="button"
+      tabindex="0"
+    >
+      <gr-submit-requirement-dashboard-hovercard>
+      </gr-submit-requirement-dashboard-hovercard>
+      <iron-icon class="block" icon="gr-icons:block" role="img"></iron-icon>
+      <span>
+        <span class="unsatisfied">1</span>
+        <span class="total">(of 1)</span>
+      </span>
+    </span>`);
+  });
+
+  test('renders comment count', async () => {
+    change = {
+      ...change,
+      unresolved_comment_count: 5,
+    };
+    element = await fixture<GrChangeListColumnRequirementsSummary>(
+      html`<gr-change-list-column-requirements-summary .change=${change}>
+      </gr-change-list-column-requirements-summary>`
+    );
+    expect(element).shadowDom.to.equal(/* HTML */ ` <span
+        class="block"
+        role="button"
+        tabindex="0"
+      >
+        <gr-submit-requirement-dashboard-hovercard>
+        </gr-submit-requirement-dashboard-hovercard>
+        <iron-icon class="block" icon="gr-icons:block" role="img"></iron-icon>
+        <span>
+          <span class="unsatisfied">1</span>
+          <span class="total">(of 1)</span>
+        </span>
+      </span>
+      <iron-icon
+        class="commentIcon"
+        icon="gr-icons:comment"
+        title="5 unresolved comments"
+      ></iron-icon>`);
+  });
+});
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 c476d2d..15532d2 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,28 +15,26 @@
  * limitations under the License.
  */
 
-import '../../../styles/gr-change-list-styles';
-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';
 import '../../shared/gr-icons/gr-icons';
 import '../../shared/gr-limited-text/gr-limited-text';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
-import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-list-item_html';
+import '../gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary';
+import '../gr-change-list-column-requirement/gr-change-list-column-requirement';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {truncatePath} from '../../../utils/path-list-util';
 import {changeStatuses} from '../../../utils/change-util';
 import {isSelf, isServiceUser} from '../../../utils/account-util';
-import {customElement, property} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {
   ChangeInfo,
@@ -45,9 +43,20 @@
   QuickLabelInfo,
   Timestamp,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {hasOwnProperty, assertIsDefined} from '../../../utils/common-util';
 import {pluralize} from '../../../utils/string-util';
-import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
+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';
+import {customElement, property, state} from 'lit/decorators';
+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 {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
 
 enum ChangeSize {
   XS = 10,
@@ -67,20 +76,19 @@
   REJECTED = 'REJECTED',
 }
 
-export interface ChangeListToggleReviewedDetail {
-  change: ChangeInfo;
-  reviewed: boolean;
-}
-
 // How many reviewers should be shown with an account-label?
 const PRIMARY_REVIEWERS_COUNT = 2;
 
-@customElement('gr-change-list-item')
-export class GrChangeListItem extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-item': GrChangeListItem;
   }
-
+}
+/**
+ * @attr {Boolean} selected - change list item is selected by cursor
+ */
+@customElement('gr-change-list-item')
+export class GrChangeListItem extends LitElement {
   /** The logged-in user's account, or null if no user is logged in. */
   @property({type: Object})
   account: AccountInfo | null = null;
@@ -101,56 +109,544 @@
   @property({type: String})
   sectionName?: string;
 
-  @property({type: String, computed: '_computeChangeURL(change)'})
-  changeURL?: string;
-
-  @property({type: Array, computed: '_changeStatuses(change)'})
-  statuses?: ChangeStates[];
-
   @property({type: Boolean})
   showStar = false;
 
   @property({type: Boolean})
   showNumber = false;
 
-  @property({type: String, computed: '_computeChangeSize(change)'})
-  _changeSize?: string;
+  @state() private dynamicCellEndpoints?: string[];
 
-  @property({type: Array})
-  _dynamicCellEndpoints?: string[];
+  reporting: ReportingService = getAppContext().reportingService;
 
-  reporting: ReportingService = appContext.reportingService;
+  private readonly flagsService = getAppContext().flagsService;
+
+  @state() private checked = false;
+
+  private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
   override connectedCallback() {
     super.connectedCallback();
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
+        this.dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
           'change-list-item-cell'
         );
       });
+    subscribe(
+      this,
+      this.getBulkActionsModel().selectedChangeNums$,
+      selectedChangeNums => {
+        if (!this.change) return;
+        this.checked = selectedChangeNums.includes(this.change._number);
+      }
+    );
   }
 
-  _changeStatuses(change?: ChangeInfo) {
-    if (!change) return [];
-    return changeStatuses(change);
+  static override get styles() {
+    return [
+      changeListStyles,
+      sharedStyles,
+      submitRequirementsStyles,
+      css`
+        :host {
+          display: table-row;
+          color: var(--primary-text-color);
+        }
+        :host(:focus) {
+          outline: none;
+        }
+        :host(:hover) {
+          background-color: var(--hover-background-color);
+        }
+        .container {
+          position: relative;
+        }
+        .content {
+          overflow: hidden;
+          position: absolute;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+          width: 100%;
+        }
+        .content a {
+          display: block;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+          width: 100%;
+        }
+        .comments,
+        .reviewers,
+        .requirements {
+          white-space: nowrap;
+        }
+        .reviewers {
+          --account-max-length: 70px;
+        }
+        .spacer {
+          height: 0;
+          overflow: hidden;
+        }
+        .status {
+          align-items: center;
+          display: inline-flex;
+        }
+        .status .comma {
+          padding-right: var(--spacing-xs);
+        }
+        /* Used to hide the leading separator comma for statuses. */
+        .status .comma:first-of-type {
+          display: none;
+        }
+        .size gr-tooltip-content {
+          margin: -0.4rem -0.6rem;
+          max-width: 2.5rem;
+          padding: var(--spacing-m) var(--spacing-l);
+        }
+        .size span {
+          border-radius: var(--border-radius);
+          color: var(--dashboard-size-text);
+          font-size: var(--font-size-small);
+          /* To set height and width of span, it has to be inline block */
+          display: inline-block;
+          height: 20px;
+          width: 20px;
+          text-align: center;
+          vertical-align: top;
+        }
+        .size span.size-xs {
+          background-color: var(--dashboard-size-xs);
+          color: var(--dashboard-size-xs-text);
+        }
+        .size span.size-s {
+          background-color: var(--dashboard-size-s);
+        }
+        .size span.size-m {
+          background-color: var(--dashboard-size-m);
+        }
+        .size span.size-l {
+          background-color: var(--dashboard-size-l);
+        }
+        .size span.size-xl {
+          background-color: var(--dashboard-size-xl);
+          color: var(--dashboard-size-xl-text);
+        }
+        a {
+          color: inherit;
+          cursor: pointer;
+          text-decoration: none;
+        }
+        a:hover {
+          text-decoration: underline;
+        }
+        .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);
+        }
+        .cell.selection input {
+          vertical-align: middle;
+        }
+        .cell.label {
+          font-weight: var(--font-weight-normal);
+        }
+        .cell.label iron-icon {
+          vertical-align: top;
+        }
+        /* Requirement child needs whole area */
+        .cell.requirement {
+          padding: 0;
+          margin: 0;
+        }
+        @media only screen and (max-width: 50em) {
+          :host {
+            display: flex;
+          }
+        }
+      `,
+    ];
   }
 
-  _computeChangeURL(change?: ChangeInfo) {
-    if (!change) return '';
-    return GerritNav.getUrlForChange(change);
+  override render() {
+    const changeUrl = this.computeChangeURL();
+    return html`
+      <td aria-hidden="true" class="cell leftPadding"></td>
+      ${this.renderCellSelectionBox()} ${this.renderCellStar()}
+      ${this.renderCellNumber(changeUrl)} ${this.renderCellSubject(changeUrl)}
+      ${this.renderCellStatus()} ${this.renderCellOwner()}
+      ${this.renderCellReviewers()} ${this.renderCellComments()}
+      ${this.renderCellRepo()} ${this.renderCellBranch()}
+      ${this.renderCellUpdated()} ${this.renderCellSubmitted()}
+      ${this.renderCellWaiting()} ${this.renderCellSize()}
+      ${this.renderCellRequirements()}
+      ${this.labelNames?.map(labelNames => this.renderChangeLabels(labelNames))}
+      ${this.dynamicCellEndpoints?.map(pluginEndpointName =>
+        this.renderChangePluginEndpoint(pluginEndpointName)
+      )}
+    `;
   }
 
-  _computeLabelTitle(change: ChangeInfo | undefined, labelName: string) {
-    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
-    const category = this._computeLabelCategory(change, labelName);
+  private renderCellSelectionBox() {
+    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}
+          @click=${() => this.handleChangeSelectionClick()}
+        />
+      </td>
+    `;
+  }
+
+  private renderCellStar() {
+    if (!this.showStar) return;
+
+    return html`
+      <td class="cell star">
+        <gr-change-star .change=${this.change}></gr-change-star>
+      </td>
+    `;
+  }
+
+  private renderCellNumber(changeUrl: string) {
+    if (!this.showNumber) return;
+
+    return html`
+      <td class="cell number">
+        <a href=${changeUrl}>${this.change?._number}</a>
+      </td>
+    `;
+  }
+
+  private renderCellSubject(changeUrl: string) {
+    if (this.computeIsColumnHidden('Subject', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell subject">
+        <a
+          title=${ifDefined(this.change?.subject)}
+          href=${changeUrl}
+          @click=${() => this.handleChangeClick()}
+        >
+          <div class="container">
+            <div class="content">${this.change?.subject}</div>
+            <div class="spacer">${this.change?.subject}</div>
+            <span>&nbsp;</span>
+          </div>
+        </a>
+      </td>
+    `;
+  }
+
+  private renderCellStatus() {
+    if (this.computeIsColumnHidden('Status', this.visibleChangeTableColumns))
+      return;
+
+    return html` <td class="cell status">${this.renderChangeStatus()}</td> `;
+  }
+
+  private renderChangeStatus() {
+    if (!this.changeStatuses().length) {
+      return html`<span class="placeholder">--</span>`;
+    }
+
+    return this.changeStatuses().map(
+      status => html`
+        <div class="comma">,</div>
+        <gr-change-status flat .status=${status}></gr-change-status>
+      `
+    );
+  }
+
+  private renderCellOwner() {
+    if (this.computeIsColumnHidden('Owner', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell owner">
+        <gr-account-label
+          highlightAttention
+          clickable
+          .change=${this.change}
+          .account=${this.change?.owner}
+        ></gr-account-label>
+      </td>
+    `;
+  }
+
+  private renderCellReviewers() {
+    if (this.computeIsColumnHidden('Reviewers', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell reviewers">
+        <div>
+          ${this.computePrimaryReviewers().map((reviewer, index) =>
+            this.renderChangeReviewers(reviewer, index)
+          )}
+          ${this.computeAdditionalReviewersCount()
+            ? html`<span title=${this.computeAdditionalReviewersTitle()}
+                >+${this.computeAdditionalReviewersCount()}</span
+              >`
+            : ''}
+        </div>
+      </td>
+    `;
+  }
+
+  private renderChangeReviewers(reviewer: AccountInfo, index: number) {
+    return html`
+      <gr-account-label
+        clickable
+        hideAvatar
+        firstName
+        highlightAttention
+        .change=${this.change}
+        .account=${reviewer}
+      ></gr-account-label
+      ><span ?hidden=${this.computeCommaHidden(index)} aria-hidden="true"
+        >,
+      </span>
+    `;
+  }
+
+  private renderCellComments() {
+    if (this.computeIsColumnHidden('Comments', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell comments">
+        ${this.change?.unresolved_comment_count
+          ? html`<iron-icon icon="gr-icons:comment"></iron-icon>`
+          : ''}
+        <span
+          >${this.computeComments(this.change?.unresolved_comment_count)}</span
+        >
+      </td>
+    `;
+  }
+
+  private renderCellRepo() {
+    if (this.computeIsColumnHidden('Repo', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell repo">
+        <a class="fullRepo" href=${this.computeRepoUrl()}>
+          ${this.computeRepoDisplay()}
+        </a>
+        <a
+          class="truncatedRepo"
+          href=${this.computeRepoUrl()}
+          title=${this.computeRepoDisplay()}
+        >
+          ${this.computeTruncatedRepoDisplay()}
+        </a>
+      </td>
+    `;
+  }
+
+  private renderCellBranch() {
+    if (this.computeIsColumnHidden('Branch', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell branch">
+        <a href=${this.computeRepoBranchURL()}> ${this.change?.branch} </a>
+        ${this.renderChangeBranch()}
+      </td>
+    `;
+  }
+
+  private renderChangeBranch() {
+    if (!this.change?.topic) return;
+
+    return html`
+      (<a href=${this.computeTopicURL()}
+        ><!--
+      --><gr-limited-text .limit=${50} .text=${this.change.topic}>
+        </gr-limited-text
+        ><!--
+    --></a
+      >)
+    `;
+  }
+
+  private renderCellUpdated() {
+    if (this.computeIsColumnHidden('Updated', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell updated">
+        <gr-date-formatter
+          withTooltip
+          .dateStr=${this.formatDate(this.change?.updated)}
+        ></gr-date-formatter>
+      </td>
+    `;
+  }
+
+  private renderCellSubmitted() {
+    if (this.computeIsColumnHidden('Submitted', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell submitted">
+        <gr-date-formatter
+          withTooltip
+          .dateStr=${this.formatDate(this.change?.submitted)}
+        ></gr-date-formatter>
+      </td>
+    `;
+  }
+
+  private renderCellWaiting() {
+    if (this.computeIsColumnHidden(WAITING, this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell waiting">
+        <gr-date-formatter
+          withTooltip
+          forceRelative
+          relativeOptionNoAgo
+          .dateStr=${this.computeWaiting()}
+        ></gr-date-formatter>
+      </td>
+    `;
+  }
+
+  private renderCellSize() {
+    if (this.computeIsColumnHidden('Size', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell size">
+        <gr-tooltip-content has-tooltip title=${this.computeSizeTooltip()}>
+          ${this.renderChangeSize()}
+        </gr-tooltip-content>
+      </td>
+    `;
+  }
+
+  private renderChangeSize() {
+    const changeSize = this.computeChangeSize();
+    if (!changeSize) return html`<span class="placeholder">--</span>`;
+
+    return html`
+      <span class="size-${changeSize.toLowerCase()}">${changeSize}</span>
+    `;
+  }
+
+  private renderCellRequirements() {
+    if (this.computeIsColumnHidden(' Status ', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell requirements">
+        <gr-change-list-column-requirements-summary .change=${this.change}>
+        </gr-change-list-column-requirements-summary>
+      </td>
+    `;
+  }
+
+  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)}
+      >
+        ${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>
+    `;
+  }
+
+  private renderChangePluginEndpoint(pluginEndpointName: string) {
+    return html`
+      <td class="cell endpoint">
+        <gr-endpoint-decorator name=${pluginEndpointName}>
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </td>
+    `;
+  }
+
+  private handleChangeSelectionClick() {
+    assertIsDefined(this.change, 'change');
+    this.checked = !this.checked;
+    if (this.checked)
+      this.getBulkActionsModel().addSelectedChangeNum(this.change._number);
+    else
+      this.getBulkActionsModel().removeSelectedChangeNum(this.change._number);
+  }
+
+  private changeStatuses() {
+    if (!this.change) return [];
+    return changeStatuses(this.change);
+  }
+
+  private computeChangeURL() {
+    if (!this.change) return '';
+    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 = change?.unresolved_comment_count ?? 0;
+      const num = this.change?.unresolved_comment_count ?? 0;
       titleParts.push(pluralize(num, 'unresolved comment'));
     }
     const significantLabel =
@@ -164,9 +660,10 @@
     return labelName;
   }
 
-  _computeLabelClass(change: ChangeInfo | undefined, labelName: string) {
-    const category = this._computeLabelCategory(change, 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');
@@ -189,12 +686,9 @@
     return classes.sort().join(' ');
   }
 
-  _computeHasLabelIcon(change: ChangeInfo | undefined, labelName: string) {
-    return this._computeLabelIcon(change, labelName) !== '';
-  }
-
-  _computeLabelIcon(change: ChangeInfo | undefined, labelName: string): string {
-    const category = this._computeLabelCategory(change, labelName);
+  // private but used in test
+  computeLabelIcon(labelName: string): string {
+    const category = this.computeLabelCategory(labelName);
     switch (category) {
       case LabelCategory.APPROVED:
         return 'gr-icons:check';
@@ -207,8 +701,9 @@
     }
   }
 
-  _computeLabelCategory(change: ChangeInfo | undefined, labelName: string) {
-    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+  // private but used in test
+  computeLabelCategory(labelName: string) {
+    const label: QuickLabelInfo | undefined = this.change?.labels?.[labelName];
     if (!label) {
       return LabelCategory.NOT_APPLICABLE;
     }
@@ -218,7 +713,7 @@
     if (label.value && label.value < 0) {
       return LabelCategory.NEGATIVE;
     }
-    if (change?.unresolved_comment_count && labelName === 'Code-Review') {
+    if (this.change?.unresolved_comment_count && labelName === 'Code-Review') {
       return LabelCategory.UNRESOLVED_COMMENTS;
     }
     if (label.approved) {
@@ -230,9 +725,10 @@
     return LabelCategory.NEUTRAL;
   }
 
-  _computeLabelValue(change: ChangeInfo | undefined, labelName: string) {
-    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
-    const category = this._computeLabelCategory(change, labelName);
+  // 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 '';
@@ -248,33 +744,36 @@
         return `${label?.value}`;
       case LabelCategory.REJECTED:
         return '\u2715'; // ✕
+      default:
+        return '';
     }
   }
 
-  _computeRepoUrl(change?: ChangeInfo) {
-    if (!change) return '';
+  private computeRepoUrl() {
+    if (!this.change) return '';
     return GerritNav.getUrlForProjectChanges(
-      change.project,
+      this.change.project,
       true,
-      change.internalHost
+      this.change.internalHost
     );
   }
 
-  _computeRepoBranchURL(change?: ChangeInfo) {
-    if (!change) return '';
+  private computeRepoBranchURL() {
+    if (!this.change) return '';
     return GerritNav.getUrlForBranch(
-      change.branch,
-      change.project,
+      this.change.branch,
+      this.change.project,
       undefined,
-      change.internalHost
+      this.change.internalHost
     );
   }
 
-  _computeTopicURL(change?: ChangeInfo) {
-    if (!change?.topic) {
-      return '';
-    }
-    return GerritNav.getUrlForTopic(change.topic, change.internalHost);
+  private computeTopicURL() {
+    if (!this.change?.topic) return '';
+    return GerritNav.getUrlForTopic(
+      this.change.topic,
+      this.change.internalHost
+    );
   }
 
   /**
@@ -283,44 +782,46 @@
    *
    * @param truncate whether or not the project name should be
    * truncated. If this value is truthy, the name will be truncated.
+   *
+   * private but used in test
    */
-  _computeRepoDisplay(change?: ChangeInfo) {
-    if (!change?.project) {
-      return '';
-    }
+  computeRepoDisplay() {
+    if (!this.change?.project) return '';
     let str = '';
-    if (change.internalHost) {
-      str += change.internalHost + '/';
+    if (this.change.internalHost) {
+      str += this.change.internalHost + '/';
     }
-    str += change.project;
+    str += this.change.project;
     return str;
   }
 
-  _computeTruncatedRepoDisplay(change?: ChangeInfo) {
-    if (!change?.project) {
+  // private but used in test
+  computeTruncatedRepoDisplay() {
+    if (!this.change?.project) {
       return '';
     }
     let str = '';
-    if (change.internalHost) {
-      str += change.internalHost + '/';
+    if (this.change.internalHost) {
+      str += this.change.internalHost + '/';
     }
-    str += truncatePath(change.project, 2);
+    str += truncatePath(this.change.project, 2);
     return str;
   }
 
-  _computeSizeTooltip(change?: ChangeInfo) {
+  // private but used in test
+  computeSizeTooltip() {
     if (
-      !change ||
-      change.insertions + change.deletions === 0 ||
-      isNaN(change.insertions + change.deletions)
+      !this.change ||
+      this.change.insertions + this.change.deletions === 0 ||
+      isNaN(this.change.insertions + this.change.deletions)
     ) {
       return 'Size unknown';
     } else {
-      return `added ${change.insertions}, removed ${change.deletions} lines`;
+      return `added ${this.change.insertions}, removed ${this.change.deletions} lines`;
     }
   }
 
-  _hasAttention(account: AccountInfo) {
+  private hasAttention(account: AccountInfo) {
     if (!this.change || !this.change.attention_set || !account._account_id) {
       return false;
     }
@@ -330,12 +831,15 @@
   /**
    * Computes the array of all reviewers with sorting the reviewers in the
    * attention set before others, and the current user first.
+   *
+   * private but used in test
    */
-  _computeReviewers(change?: ChangeInfo) {
-    if (!change?.reviewers || !change?.reviewers.REVIEWER) return [];
-    const reviewers = [...change.reviewers.REVIEWER].filter(
+  computeReviewers() {
+    if (!this.change?.reviewers || !this.change?.reviewers.REVIEWER) return [];
+    const reviewers = [...this.change.reviewers.REVIEWER].filter(
       r =>
-        (!change.owner || change.owner._account_id !== r._account_id) &&
+        (!this.change?.owner ||
+          this.change?.owner._account_id !== r._account_id) &&
         !isServiceUser(r)
     );
     reviewers.sort((r1, r2) => {
@@ -343,33 +847,33 @@
         if (isSelf(r1, this.account)) return -1;
         if (isSelf(r2, this.account)) return 1;
       }
-      if (this._hasAttention(r1) && !this._hasAttention(r2)) return -1;
-      if (this._hasAttention(r2) && !this._hasAttention(r1)) return 1;
+      if (this.hasAttention(r1) && !this.hasAttention(r2)) return -1;
+      if (this.hasAttention(r2) && !this.hasAttention(r1)) return 1;
       return (r1.name || '').localeCompare(r2.name || '');
     });
     return reviewers;
   }
 
-  _computePrimaryReviewers(change?: ChangeInfo) {
-    return this._computeReviewers(change).slice(0, PRIMARY_REVIEWERS_COUNT);
+  private computePrimaryReviewers() {
+    return this.computeReviewers().slice(0, PRIMARY_REVIEWERS_COUNT);
   }
 
-  _computeAdditionalReviewers(change?: ChangeInfo) {
-    return this._computeReviewers(change).slice(PRIMARY_REVIEWERS_COUNT);
+  private computeAdditionalReviewers() {
+    return this.computeReviewers().slice(PRIMARY_REVIEWERS_COUNT);
   }
 
-  _computeAdditionalReviewersCount(change?: ChangeInfo) {
-    return this._computeAdditionalReviewers(change).length;
+  private computeAdditionalReviewersCount() {
+    return this.computeAdditionalReviewers().length;
   }
 
-  _computeAdditionalReviewersTitle(change?: ChangeInfo, config?: ServerInfo) {
-    if (!change || !config) return '';
-    return this._computeAdditionalReviewers(change)
-      .map(user => getDisplayName(config, user, true))
+  private computeAdditionalReviewersTitle() {
+    if (!this.change || !this.config) return '';
+    return this.computeAdditionalReviewers()
+      .map(user => getDisplayName(this.config, user, true))
       .join(', ');
   }
 
-  _computeComments(unresolved_comment_count?: number) {
+  private computeComments(unresolved_comment_count?: number) {
     if (!unresolved_comment_count || unresolved_comment_count < 1) return '';
     return `${unresolved_comment_count} unresolved`;
   }
@@ -377,10 +881,12 @@
   /**
    * TShirt sizing is based on the following paper:
    * http://dirkriehle.com/wp-content/uploads/2008/09/hicss-42-csdistr-final-web.pdf
+   *
+   * private but used in test
    */
-  _computeChangeSize(change?: ChangeInfo) {
-    if (!change) return null;
-    const delta = change.insertions + change.deletions;
+  computeChangeSize() {
+    if (!this.change) return null;
+    const delta = this.change.insertions + this.change.deletions;
     if (isNaN(delta) || delta === 0) {
       return null; // Unknown
     }
@@ -397,44 +903,28 @@
     }
   }
 
-  _computeWaiting(
-    account?: AccountInfo | null,
-    change?: ChangeInfo | null
-  ): Timestamp | undefined {
-    if (!account?._account_id || !change?.attention_set) return undefined;
-    return change?.attention_set[account._account_id]?.last_update;
+  private computeWaiting(): Timestamp | undefined {
+    if (!this.account?._account_id || !this.change?.attention_set)
+      return undefined;
+    return this.change?.attention_set[this.account._account_id]?.last_update;
   }
 
-  _computeIsColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
+  private computeIsColumnHidden(
+    columnToCheck?: string,
+    columnsToDisplay?: string[]
+  ) {
     if (!columnsToDisplay || !columnToCheck) {
       return false;
     }
     return !columnsToDisplay.includes(columnToCheck);
   }
 
-  toggleReviewed() {
-    if (!this.change) return;
-    const newVal = !this.change?.reviewed;
-    this.set('change.reviewed', newVal);
-    const detail: ChangeListToggleReviewedDetail = {
-      change: this.change,
-      reviewed: newVal,
-    };
-    this.dispatchEvent(
-      new CustomEvent('toggle-reviewed', {
-        bubbles: true,
-        composed: true,
-        detail,
-      })
-    );
-  }
-
-  _formatDate(date: Timestamp | undefined): string | undefined {
+  private formatDate(date: Timestamp | undefined): string | undefined {
     if (!date) return undefined;
     return date.toString();
   }
 
-  _handleChangeClick() {
+  private handleChangeClick() {
     // Don't prevent the default and neither stop bubbling. We just want to
     // report the click, but then let the browser handle the click on the link.
 
@@ -448,19 +938,10 @@
     });
   }
 
-  _computeCommaHidden(index?: number, change?: ChangeInfo) {
-    if (index === undefined) return false;
-    if (change === undefined) return false;
-
-    const additionalCount = this._computeAdditionalReviewersCount(change);
-    const primaryCount = this._computePrimaryReviewers(change).length;
+  private computeCommaHidden(index: number) {
+    const additionalCount = this.computeAdditionalReviewersCount();
+    const primaryCount = this.computePrimaryReviewers().length;
     const isLast = index === primaryCount - 1;
     return isLast && additionalCount === 0;
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-change-list-item': GrChangeListItem;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
deleted file mode 100644
index a0aa962..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ /dev/null
@@ -1,318 +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: table-row;
-      color: var(--primary-text-color);
-    }
-    :host(:focus) {
-      outline: none;
-    }
-    :host(:hover) {
-      background-color: var(--hover-background-color);
-    }
-    .container {
-      position: relative;
-    }
-    .content {
-      overflow: hidden;
-      position: absolute;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-      width: 100%;
-    }
-    .content a {
-      display: block;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-      width: 100%;
-    }
-    .comments,
-    .reviewers {
-      white-space: nowrap;
-    }
-    .reviewers {
-      --account-max-length: 70px;
-    }
-    .spacer {
-      height: 0;
-      overflow: hidden;
-    }
-    .status {
-      align-items: center;
-      display: inline-flex;
-    }
-    .status .comma {
-      padding-right: var(--spacing-xs);
-    }
-    /* Used to hide the leading separator comma for statuses. */
-    .status .comma:first-of-type {
-      display: none;
-    }
-    .size gr-tooltip-content {
-      margin: -0.4rem -0.6rem;
-      max-width: 2.5rem;
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    a {
-      color: inherit;
-      cursor: pointer;
-      text-decoration: none;
-    }
-    a:hover {
-      text-decoration: underline;
-    }
-    .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);
-    }
-    .cell.label {
-      font-weight: var(--font-weight-normal);
-    }
-    .cell.label iron-icon {
-      vertical-align: top;
-    }
-    @media only screen and (max-width: 50em) {
-      :host {
-        display: flex;
-      }
-    }
-  </style>
-  <style include="gr-change-list-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <td aria-hidden="true" class="cell leftPadding"></td>
-  <td class="cell star" hidden$="[[!showStar]]" hidden="">
-    <gr-change-star change="{{change}}"></gr-change-star>
-  </td>
-  <td class="cell number" hidden$="[[!showNumber]]" hidden="">
-    <a href$="[[changeURL]]">[[change._number]]</a>
-  </td>
-  <td
-    class="cell subject"
-    hidden$="[[_computeIsColumnHidden('Subject', visibleChangeTableColumns)]]"
-  >
-    <a
-      title$="[[change.subject]]"
-      href$="[[changeURL]]"
-      on-click="_handleChangeClick"
-    >
-      <div class="container">
-        <div class="content">[[change.subject]]</div>
-        <div class="spacer">[[change.subject]]</div>
-        <span>&nbsp;</span>
-      </div>
-    </a>
-  </td>
-  <td
-    class="cell status"
-    hidden$="[[_computeIsColumnHidden('Status', visibleChangeTableColumns)]]"
-  >
-    <template is="dom-repeat" items="[[statuses]]" as="status">
-      <div class="comma">,</div>
-      <gr-change-status flat="" status="[[status]]"></gr-change-status>
-    </template>
-    <template is="dom-if" if="[[!statuses.length]]">
-      <span class="placeholder">--</span>
-    </template>
-  </td>
-  <td
-    class="cell owner"
-    hidden$="[[_computeIsColumnHidden('Owner', visibleChangeTableColumns)]]"
-  >
-    <gr-account-link
-      highlightAttention
-      change="[[change]]"
-      account="[[change.owner]]"
-    ></gr-account-link>
-  </td>
-  <td
-    class="cell assignee"
-    hidden$="[[_computeIsColumnHidden('Assignee', visibleChangeTableColumns)]]"
-  >
-    <template is="dom-if" if="[[change.assignee]]">
-      <gr-account-link
-        id="assigneeAccountLink"
-        account="[[change.assignee]]"
-      ></gr-account-link>
-    </template>
-    <template is="dom-if" if="[[!change.assignee]]">
-      <span class="placeholder">--</span>
-    </template>
-  </td>
-  <td
-    class="cell reviewers"
-    hidden$="[[_computeIsColumnHidden('Reviewers', visibleChangeTableColumns)]]"
-  >
-    <div>
-      <template
-        is="dom-repeat"
-        items="[[_computePrimaryReviewers(change)]]"
-        as="reviewer"
-        indexAs="index"
-      >
-        <gr-account-link
-          hideAvatar=""
-          hideStatus=""
-          firstName
-          highlightAttention
-          change="[[change]]"
-          account="[[reviewer]]"
-        ></gr-account-link
-        ><span
-          hidden$="[[_computeCommaHidden(index, change)]]"
-          aria-hidden="true"
-          >,
-        </span>
-      </template>
-      <template is="dom-if" if="[[_computeAdditionalReviewersCount(change)]]">
-        <span title="[[_computeAdditionalReviewersTitle(change, config)]]">
-          +[[_computeAdditionalReviewersCount(change)]]
-        </span>
-      </template>
-    </div>
-  </td>
-  <td
-    class="cell comments"
-    hidden$="[[_computeIsColumnHidden('Comments', visibleChangeTableColumns)]]"
-  >
-    <iron-icon
-      hidden$="[[!change.unresolved_comment_count]]"
-      icon="gr-icons:comment"
-    ></iron-icon>
-    <span>[[_computeComments(change.unresolved_comment_count)]]</span>
-  </td>
-  <td
-    class="cell repo"
-    hidden$="[[_computeIsColumnHidden('Repo', visibleChangeTableColumns)]]"
-  >
-    <a class="fullRepo" href$="[[_computeRepoUrl(change)]]">
-      [[_computeRepoDisplay(change)]]
-    </a>
-    <a
-      class="truncatedRepo"
-      href$="[[_computeRepoUrl(change)]]"
-      title$="[[_computeRepoDisplay(change)]]"
-    >
-      [[_computeTruncatedRepoDisplay(change)]]
-    </a>
-  </td>
-  <td
-    class="cell branch"
-    hidden$="[[_computeIsColumnHidden('Branch', visibleChangeTableColumns)]]"
-  >
-    <a href$="[[_computeRepoBranchURL(change)]]"> [[change.branch]] </a>
-    <template is="dom-if" if="[[change.topic]]">
-      (<a href$="[[_computeTopicURL(change)]]"
-        ><!--
-       --><gr-limited-text limit="50" text="[[change.topic]]"> </gr-limited-text
-        ><!--
-     --></a
-      >)
-    </template>
-  </td>
-  <td
-    class="cell updated"
-    hidden$="[[_computeIsColumnHidden('Updated', visibleChangeTableColumns)]]"
-  >
-    <gr-date-formatter
-      withTooltip
-      date-str="[[_formatDate(change.updated)]]"
-    ></gr-date-formatter>
-  </td>
-  <td
-    class="cell submitted"
-    hidden$="[[_computeIsColumnHidden('Submitted', visibleChangeTableColumns)]]"
-  >
-    <gr-date-formatter
-      withTooltip
-      date-str="[[_formatDate(change.submitted)]]"
-    ></gr-date-formatter>
-  </td>
-  <td
-    class="cell waiting"
-    hidden$="[[_computeIsColumnHidden('Waiting', visibleChangeTableColumns)]]"
-  >
-    <gr-date-formatter
-      withTooltip
-      forceRelative
-      relativeOptionNoAgo
-      date-str="[[_computeWaiting(account, change)]]"
-    ></gr-date-formatter>
-  </td>
-  <td
-    class="cell size"
-    hidden$="[[_computeIsColumnHidden('Size', visibleChangeTableColumns)]]"
-  >
-    <gr-tooltip-content has-tooltip title="[[_computeSizeTooltip(change)]]">
-      <template is="dom-if" if="[[_changeSize]]">
-        <span>[[_changeSize]]</span>
-      </template>
-      <template is="dom-if" if="[[!_changeSize]]">
-        <span class="placeholder">--</span>
-      </template>
-    </gr-tooltip-content>
-  </td>
-  <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-    <td
-      title$="[[_computeLabelTitle(change, labelName)]]"
-      class$="[[_computeLabelClass(change, labelName)]]"
-    >
-      <template is="dom-if" if="[[_computeHasLabelIcon(change, labelName)]]">
-        <iron-icon icon="[[_computeLabelIcon(change, labelName)]]"></iron-icon>
-      </template>
-      <template is="dom-if" if="[[!_computeHasLabelIcon(change, labelName)]]">
-        <span>[[_computeLabelValue(change, labelName)]]</span>
-      </template>
-    </td>
-  </template>
-  <template
-    is="dom-repeat"
-    items="[[_dynamicCellEndpoints]]"
-    as="pluginEndpointName"
-  >
-    <td class="cell endpoint">
-      <gr-endpoint-decorator name$="[[pluginEndpointName]]">
-        <gr-endpoint-param name="change" value="[[change]]">
-        </gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </td>
-  </template>
-`;
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 34cb6eb..95a851e 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
@@ -15,12 +15,29 @@
  * limitations under the License.
  */
 
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import {
+  SubmitRequirementResultInfo,
+  NumericChangeId,
+} from '../../../api/rest-api';
+import {getAppContext} from '../../../services/app-context';
 import '../../../test/common-test-setup-karma';
 import {
   createAccountWithId,
   createChange,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
+  createServerInfo,
 } from '../../../test/test-data-generators';
-import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  query,
+  queryAndAssert,
+  stubRestApi,
+  stubFlags,
+  waitUntilObserved,
+} from '../../../test/test-utils';
 import {
   AccountId,
   BranchName,
@@ -28,12 +45,21 @@
   RepoName,
   TopicName,
 } 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';
-
-const basicFixture = fixtureFromElement('gr-change-list-item');
+import {
+  DIProviderElement,
+  wrapInProvider,
+} from '../../../models/di-provider-element';
+import {
+  bulkActionsModelToken,
+  BulkActionsModel,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {createTestAppContext} from '../../../test/test-app-context-init';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 
 suite('gr-change-list-item tests', () => {
   const account = createAccountWithId();
@@ -46,315 +72,227 @@
   };
 
   let element: GrChangeListItem;
+  let bulkActionsModel: BulkActionsModel;
 
-  setup(() => {
+  setup(async () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    element = basicFixture.instantiate();
+
+    bulkActionsModel = new BulkActionsModel(
+      createTestAppContext().restApiService
+    );
+    element = (
+      await fixture<DIProviderElement>(
+        wrapInProvider(
+          html`<gr-change-list-item></gr-change-list-item>`,
+          bulkActionsModelToken,
+          bulkActionsModel
+        )
+      )
+    ).element as GrChangeListItem;
+    await element.updateComplete;
   });
 
-  test('_computeLabelCategory', () => {
+  test('computeLabelCategory', () => {
+    element.change = {
+      ...change,
+      labels: {},
+    };
     assert.equal(
-      element._computeLabelCategory({...change, labels: {}}, 'Verified'),
+      element.computeLabelCategory('Verified'),
       LabelCategory.NOT_APPLICABLE
     );
+    element.change.labels = {Verified: {approved: account, value: 1}};
     assert.equal(
-      element._computeLabelCategory(
-        {...change, labels: {Verified: {approved: account, value: 1}}},
-        'Verified'
-      ),
+      element.computeLabelCategory('Verified'),
       LabelCategory.APPROVED
     );
+    element.change.labels = {Verified: {rejected: account, value: -1}};
     assert.equal(
-      element._computeLabelCategory(
-        {...change, labels: {Verified: {rejected: account, value: -1}}},
-        'Verified'
-      ),
+      element.computeLabelCategory('Verified'),
       LabelCategory.REJECTED
     );
+    element.change.labels = {'Code-Review': {approved: account, value: 1}};
+    element.change.unresolved_comment_count = 1;
     assert.equal(
-      element._computeLabelCategory(
-        {
-          ...change,
-          labels: {'Code-Review': {approved: account, value: 1}},
-          unresolved_comment_count: 1,
-        },
-        'Code-Review'
-      ),
+      element.computeLabelCategory('Code-Review'),
       LabelCategory.UNRESOLVED_COMMENTS
     );
+    element.change.labels = {'Code-Review': {value: 1}};
+    element.change.unresolved_comment_count = 0;
     assert.equal(
-      element._computeLabelCategory(
-        {...change, labels: {'Code-Review': {value: 1}}},
-        'Code-Review'
-      ),
+      element.computeLabelCategory('Code-Review'),
       LabelCategory.POSITIVE
     );
+    element.change.labels = {'Code-Review': {value: -1}};
     assert.equal(
-      element._computeLabelCategory(
-        {...change, labels: {'Code-Review': {value: -1}}},
-        'Code-Review'
-      ),
+      element.computeLabelCategory('Code-Review'),
       LabelCategory.NEGATIVE
     );
+    element.change.labels = {'Code-Review': {value: -1}};
     assert.equal(
-      element._computeLabelCategory(
-        {...change, labels: {'Code-Review': {value: -1}}},
-        'Verified'
-      ),
+      element.computeLabelCategory('Verified'),
       LabelCategory.NOT_APPLICABLE
     );
   });
 
-  test('_computeLabelClass', () => {
+  test('computeLabelClass', () => {
+    element.change = {
+      ...change,
+      labels: {},
+    };
     assert.equal(
-      element._computeLabelClass({...change, labels: {}}, 'Verified'),
+      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(
-        {...change, labels: {Verified: {approved: account, value: 1}}},
-        'Verified'
-      ),
-      'cell label u-green'
-    );
-    assert.equal(
-      element._computeLabelClass(
-        {...change, labels: {Verified: {rejected: account, value: -1}}},
-        'Verified'
-      ),
-      'cell label u-red'
-    );
-    assert.equal(
-      element._computeLabelClass(
-        {...change, labels: {'Code-Review': {value: 1}}},
-        'Code-Review'
-      ),
+      element.computeLabelClass('Code-Review'),
       'cell label u-green u-monospace'
     );
+    element.change.labels = {'Code-Review': {value: -1}};
     assert.equal(
-      element._computeLabelClass(
-        {...change, labels: {'Code-Review': {value: -1}}},
-        'Code-Review'
-      ),
+      element.computeLabelClass('Code-Review'),
       'cell label u-monospace u-red'
     );
+    element.change.labels = {'Code-Review': {value: -1}};
     assert.equal(
-      element._computeLabelClass(
-        {...change, labels: {'Code-Review': {value: -1}}},
-        'Verified'
-      ),
+      element.computeLabelClass('Verified'),
       'cell label u-gray-background'
     );
   });
 
-  test('_computeLabelTitle', () => {
+  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({...change, labels: {}}, 'Verified'),
+      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(
-        {...change, labels: {Verified: {approved: {name: 'Diffy'}}}},
-        'Verified'
-      ),
-      'Verified by Diffy'
-    );
-    assert.equal(
-      element._computeLabelTitle(
-        {...change, labels: {Verified: {approved: {name: 'Diffy'}}}},
-        'Code-Review'
-      ),
-      'Label not applicable'
-    );
-    assert.equal(
-      element._computeLabelTitle(
-        {...change, labels: {Verified: {rejected: {name: 'Diffy'}}}},
-        'Verified'
-      ),
-      'Verified by Diffy'
-    );
-    assert.equal(
-      element._computeLabelTitle(
-        {
-          ...change,
-          labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}},
-        },
-        'Code-Review'
-      ),
+      element.computeLabelTitle('Code-Review'),
       'Code-Review by Diffy'
     );
+
+    element.change.labels = {
+      'Code-Review': {recommended: {name: 'Diffy'}, value: 1},
+    };
     assert.equal(
-      element._computeLabelTitle(
-        {
-          ...change,
-          labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}},
-        },
-        'Code-Review'
-      ),
+      element.computeLabelTitle('Code-Review'),
       'Code-Review by Diffy'
     );
+
+    element.change.labels = {
+      'Code-Review': {recommended: {name: 'Diffy'}, rejected: {name: 'Admin'}},
+    };
     assert.equal(
-      element._computeLabelTitle(
-        {
-          ...change,
-          labels: {
-            'Code-Review': {
-              recommended: {name: 'Diffy'},
-              rejected: {name: 'Admin'},
-            },
-          },
-        },
-        'Code-Review'
-      ),
+      element.computeLabelTitle('Code-Review'),
       'Code-Review by Admin'
     );
+
+    element.change.labels = {
+      'Code-Review': {approved: {name: 'Diffy'}, rejected: {name: 'Admin'}},
+    };
     assert.equal(
-      element._computeLabelTitle(
-        {
-          ...change,
-          labels: {
-            'Code-Review': {
-              approved: {name: 'Diffy'},
-              rejected: {name: 'Admin'},
-            },
-          },
-        },
-        'Code-Review'
-      ),
+      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(
-        {
-          ...change,
-          labels: {
-            'Code-Review': {
-              recommended: {name: 'Diffy'},
-              disliked: {name: 'Admin'},
-              value: -1,
-            },
-          },
-        },
-        'Code-Review'
-      ),
+      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(
-        {
-          ...change,
-          labels: {
-            'Code-Review': {
-              approved: {name: 'Diffy'},
-              disliked: {name: 'Admin'},
-              value: -1,
-            },
-          },
-        },
-        'Code-Review'
-      ),
+      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(
-        {
-          ...change,
-          labels: {'Code-Review': {approved: account, value: 1}},
-          unresolved_comment_count: 1,
-        },
-        'Code-Review'
-      ),
+      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(
-        {
-          ...change,
-          labels: {'Code-Review': {approved: {name: 'Diffy'}, value: 1}},
-          unresolved_comment_count: 1,
-        },
-        'Code-Review'
-      ),
+      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(
-        {
-          ...change,
-          labels: {'Code-Review': {approved: account, value: 1}},
-          unresolved_comment_count: 2,
-        },
-        'Code-Review'
-      ),
+      element.computeLabelTitle('Code-Review'),
       '2 unresolved comments'
     );
   });
 
-  test('_computeLabelIcon', () => {
-    assert.equal(
-      element._computeLabelIcon({...change, labels: {}}, 'missingLabel'),
-      ''
-    );
-    assert.equal(
-      element._computeLabelIcon(
-        {...change, labels: {Verified: {approved: account, value: 1}}},
-        'Verified'
-      ),
-      'gr-icons:check'
-    );
-    assert.equal(
-      element._computeLabelIcon(
-        {
-          ...change,
-          labels: {'Code-Review': {approved: account, value: 1}},
-          unresolved_comment_count: 1,
-        },
-        'Code-Review'
-      ),
-      'gr-icons:comment'
-    );
+  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', () => {
-    assert.equal(
-      element._computeLabelValue({...change, labels: {}}, 'Verified'),
-      ''
-    );
-    assert.equal(
-      element._computeLabelValue(
-        {...change, labels: {Verified: {approved: account, value: 1}}},
-        'Verified'
-      ),
-      '✓'
-    );
-    assert.equal(
-      element._computeLabelValue(
-        {...change, labels: {Verified: {value: 1}}},
-        'Verified'
-      ),
-      '+1'
-    );
-    assert.equal(
-      element._computeLabelValue(
-        {...change, labels: {Verified: {value: -1}}},
-        'Verified'
-      ),
-      '-1'
-    );
-    assert.equal(
-      element._computeLabelValue(
-        {...change, labels: {Verified: {approved: account}}},
-        'Verified'
-      ),
-      '✓'
-    );
-    assert.equal(
-      element._computeLabelValue(
-        {...change, labels: {Verified: {rejected: account}}},
-        'Verified'
-      ),
-      '✕'
-    );
+  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 () => {
@@ -362,50 +300,113 @@
       'Subject',
       'Status',
       'Owner',
-      'Assignee',
       'Reviewers',
       'Comments',
       'Repo',
       'Branch',
       'Updated',
       'Size',
+      ' Status ',
     ];
 
-    await flush();
+    await element.updateComplete;
 
     for (const column of columnNames) {
-      const elementClass = '.' + column.toLowerCase();
+      const elementClass = '.' + column.trim().toLowerCase();
       assert.isFalse(
         queryAndAssert(element, elementClass).hasAttribute('hidden')
       );
     }
   });
 
+  suite('checkbox', () => {
+    test('selection checkbox is only shown if experiment is enabled', async () => {
+      assert.isNotOk(query(element, '.selection'));
+      stubFlags('isEnabled').returns(true);
+      element.requestUpdate();
+      await element.updateComplete;
+      assert.isOk(query(element, '.selection'));
+    });
+
+    test('bulk actions checkboxes', async () => {
+      stubFlags('isEnabled').returns(true);
+      element.change = {...createChange(), _number: 1 as NumericChangeId};
+      bulkActionsModel.sync([element.change]);
+      await element.updateComplete;
+
+      const checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '.selection > input'
+      );
+      tap(checkbox);
+      let selectedChangeNums = await waitUntilObserved(
+        bulkActionsModel.selectedChangeNums$,
+        s => s.length === 1
+      );
+
+      assert.deepEqual(selectedChangeNums, [1]);
+
+      tap(checkbox);
+      selectedChangeNums = await waitUntilObserved(
+        bulkActionsModel.selectedChangeNums$,
+        s => s.length === 0
+      );
+
+      assert.deepEqual(selectedChangeNums, []);
+    });
+
+    test('checkbox state updates with model updates', async () => {
+      stubFlags('isEnabled').returns(true);
+      element.requestUpdate();
+      await element.updateComplete;
+
+      element.change = {...createChange(), _number: 1 as NumericChangeId};
+      bulkActionsModel.sync([element.change]);
+      bulkActionsModel.addSelectedChangeNum(element.change._number);
+      await waitUntilObserved(
+        bulkActionsModel.selectedChangeNums$,
+        s => s.length === 1
+      );
+      await element.updateComplete;
+
+      const checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '.selection > input'
+      );
+      assert.isTrue(checkbox.checked);
+
+      bulkActionsModel.removeSelectedChangeNum(element.change._number);
+      await waitUntilObserved(
+        bulkActionsModel.selectedChangeNums$,
+        s => s.length === 0
+      );
+      await element.updateComplete;
+
+      assert.isFalse(checkbox.checked);
+    });
+  });
+
   test('repo column hidden', async () => {
     element.visibleChangeTableColumns = [
       'Subject',
       'Status',
       'Owner',
-      'Assignee',
       'Reviewers',
       'Comments',
       'Branch',
       'Updated',
       'Size',
+      ' Status ',
     ];
 
-    await flush();
+    await element.updateComplete;
 
     for (const column of columnNames) {
-      const elementClass = '.' + column.toLowerCase();
+      const elementClass = '.' + column.trim().toLowerCase();
       if (column === 'Repo') {
-        assert.isTrue(
-          queryAndAssert(element, elementClass).hasAttribute('hidden')
-        );
+        assert.isNotOk(query(element, elementClass));
       } else {
-        assert.isFalse(
-          queryAndAssert(element, elementClass).hasAttribute('hidden')
-        );
+        assert.isOk(query(element, elementClass));
       }
     }
   });
@@ -429,16 +430,14 @@
       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],
       });
     }
     attSetIds.forEach(id => (element.change!.attention_set![id] = {account}));
 
-    const actual = element
-      ._computeReviewers(element.change)
-      .map(r => r._account_id);
+    const actual = element.computeReviewers().map(r => r._account_id);
     assert.deepEqual(actual, expected as AccountId[]);
   }
 
@@ -468,84 +467,80 @@
   test('random column does not exist', async () => {
     element.visibleChangeTableColumns = ['Bad'];
 
-    await flush();
+    await element.updateComplete;
     const elementClass = '.bad';
     assert.isNotOk(query(element, elementClass));
   });
 
-  test('assignee only displayed if there is one', async () => {
-    element.change = change;
-    await flush();
-    assert.isNotOk(query(element, '.assignee gr-account-link'));
-    assert.equal(
-      queryAndAssert(element, '.assignee').textContent!.trim(),
-      '--'
-    );
+  test('TShirt sizing tooltip', () => {
     element.change = {
       ...change,
-      assignee: {
-        name: 'test',
-        status: 'test',
-      },
+      insertions: NaN,
+      deletions: NaN,
     };
-    await flush();
-    queryAndAssert(element, '.assignee gr-account-link');
-  });
-
-  test('TShirt sizing tooltip', () => {
-    assert.equal(
-      element._computeSizeTooltip({
-        ...change,
-        insertions: NaN,
-        deletions: NaN,
-      }),
-      'Size unknown'
-    );
-    assert.equal(
-      element._computeSizeTooltip({...change, insertions: 0, deletions: 0}),
-      'Size unknown'
-    );
-    assert.equal(
-      element._computeSizeTooltip({...change, insertions: 1, deletions: 2}),
-      'added 1, removed 2 lines'
-    );
+    assert.equal(element.computeSizeTooltip(), 'Size unknown');
+    element.change = {
+      ...change,
+      insertions: 0,
+      deletions: 0,
+    };
+    assert.equal(element.computeSizeTooltip(), 'Size unknown');
+    element.change = {
+      ...change,
+      insertions: 1,
+      deletions: 2,
+    };
+    assert.equal(element.computeSizeTooltip(), 'added 1, removed 2 lines');
   });
 
   test('TShirt sizing', () => {
-    assert.equal(
-      element._computeChangeSize({
-        ...change,
-        insertions: NaN,
-        deletions: NaN,
-      }),
-      null
-    );
-    assert.equal(
-      element._computeChangeSize({...change, insertions: 1, deletions: 1}),
-      'XS'
-    );
-    assert.equal(
-      element._computeChangeSize({...change, insertions: 9, deletions: 1}),
-      'S'
-    );
-    assert.equal(
-      element._computeChangeSize({...change, insertions: 10, deletions: 200}),
-      'M'
-    );
-    assert.equal(
-      element._computeChangeSize({...change, insertions: 99, deletions: 900}),
-      'L'
-    );
-    assert.equal(
-      element._computeChangeSize({...change, insertions: 99, deletions: 999}),
-      'XL'
-    );
+    element.change = {
+      ...change,
+      insertions: NaN,
+      deletions: NaN,
+    };
+    assert.equal(element.computeChangeSize(), null);
+
+    element.change = {
+      ...change,
+      insertions: 1,
+      deletions: 1,
+    };
+    assert.equal(element.computeChangeSize(), 'XS');
+
+    element.change = {
+      ...change,
+      insertions: 9,
+      deletions: 1,
+    };
+    assert.equal(element.computeChangeSize(), 'S');
+
+    element.change = {
+      ...change,
+      insertions: 10,
+      deletions: 200,
+    };
+    assert.equal(element.computeChangeSize(), 'M');
+
+    element.change = {
+      ...change,
+      insertions: 99,
+      deletions: 900,
+    };
+    assert.equal(element.computeChangeSize(), 'L');
+
+    element.change = {
+      ...change,
+      insertions: 99,
+      deletions: 999,
+    };
+    assert.equal(element.computeChangeSize(), 'XL');
   });
 
   test('change params passed to gr-navigation', async () => {
     const navStub = sinon.stub(GerritNav);
     element.change = change;
-    await flush();
+    await element.updateComplete;
 
     assert.deepEqual(navStub.getUrlForChange.lastCall.args, [change]);
     assert.deepEqual(navStub.getUrlForProjectChanges.lastCall.args, [
@@ -565,14 +560,87 @@
     ]);
   });
 
-  test('_computeRepoDisplay', () => {
-    assert.equal(element._computeRepoDisplay(change), 'host/a/test/repo');
-    assert.equal(
-      element._computeTruncatedRepoDisplay(change),
-      'host/…/test/repo'
-    );
+  test('computeRepoDisplay', () => {
+    element.change = {...change};
+    assert.equal(element.computeRepoDisplay(), 'host/a/test/repo');
+    assert.equal(element.computeTruncatedRepoDisplay(), 'host/…/test/repo');
     delete change.internalHost;
-    assert.equal(element._computeRepoDisplay(change), 'a/test/repo');
-    assert.equal(element._computeTruncatedRepoDisplay(change), '…/test/repo');
+    element.change = {...change};
+    assert.equal(element.computeRepoDisplay(), 'a/test/repo');
+    assert.equal(element.computeTruncatedRepoDisplay(), '…/test/repo');
+  });
+
+  test('renders', async () => {
+    element.showStar = true;
+    element.showNumber = true;
+    element.account = createAccountWithId(1);
+    element.config = createServerInfo();
+    element.change = createChange();
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(`
+      <gr-change-star></gr-change-star>
+      <a href="">42</a>
+      <a href="" title="Test subject">
+        <div class="container">
+          <div class="content"> Test subject </div>
+          <div class="spacer"> Test subject </div>
+          <span></span>
+        </div>
+      </a>
+      <span class="placeholder"> -- </span>
+      <gr-account-label
+        deselected=""
+        clickable=""
+        highlightattention=""
+      ></gr-account-label>
+      <div></div>
+      <span></span>
+      <a class="fullRepo" href=""> test-project </a>
+      <a class="truncatedRepo" href="" title="test-project"> test-project </a>
+      <a href=""> test-branch </a>
+      <gr-date-formatter withtooltip=""></gr-date-formatter>
+      <gr-date-formatter withtooltip=""></gr-date-formatter>
+      <gr-date-formatter forcerelative="" relativeoptionnoago="" withtooltip="">
+      </gr-date-formatter>
+      <gr-tooltip-content has-tooltip="" title="Size unknown">
+        <span class="placeholder"> -- </span>
+      </gr-tooltip-content>
+      <gr-change-list-column-requirements-summary>
+      </gr-change-list-column-requirements-summary>
+    `);
+  });
+
+  test('renders requirement with new submit requirements', async () => {
+    sinon.stub(getAppContext().flagsService, 'isEnabled').returns(true);
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      name: StandardLabels.CODE_REVIEW,
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    const change: ChangeInfo = {
+      ...createChange(),
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
+      unresolved_comment_count: 1,
+    };
+    const element = (
+      await fixture<DIProviderElement>(
+        wrapInProvider(
+          html`<gr-change-list-item
+            .change=${change}
+            .labelNames=${[StandardLabels.CODE_REVIEW]}
+          ></gr-change-list-item>`,
+          bulkActionsModelToken,
+          bulkActionsModel
+        )
+      )
+    ).element as GrChangeListItem;
+
+    const requirement = queryAndAssert(element, '.requirement');
+    expect(requirement).dom.to
+      .equal(/* HTML */ ` <gr-change-list-column-requirement>
+    </gr-change-list-column-requirement>`);
   });
 });
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..458fe9a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -0,0 +1,273 @@
+/**
+ * @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 {resolve} from '../../../models/dependency';
+import {AccountInfo, ChangeInfo, NumericChangeId} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-dialog/gr-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {getAppContext} from '../../../services/app-context';
+import {
+  GrReviewerSuggestionsProvider,
+  ReviewerSuggestionsProvider,
+  SUGGESTIONS_PROVIDERS_USERS_TYPES,
+} 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';
+
+const SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE: Record<
+  ReviewerState,
+  SUGGESTIONS_PROVIDERS_USERS_TYPES
+> = {
+  REVIEWER: SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
+  CC: SUGGESTIONS_PROVIDERS_USERS_TYPES.CC,
+  REMOVED: SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY,
+};
+
+@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,
+    AccountInfo[]
+  > = new Map();
+
+  @state() private suggestionsProviderByReviewerState: Map<
+    ReviewerState,
+    ReviewerSuggestionsProvider
+  > = new Map();
+
+  @state() private progressByChangeNum = new Map<
+    NumericChangeId,
+    ProgressStatus
+  >();
+
+  @state() private isOverlayOpen = false;
+
+  @query('gr-overlay') private overlay!: GrOverlay;
+
+  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private restApiService = getAppContext().restApiService;
+
+  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;
+      }
+    `;
+  }
+
+  override connectedCallback(): void {
+    super.connectedCallback();
+    subscribe(
+      this,
+      this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+      }
+    );
+  }
+
+  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" 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>
+      </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;
+    }
+    return html`
+      <gr-account-list
+        id=${id}
+        .accounts=${updatedAccounts}
+        .removableValues=${[]}
+        .suggestionsProvider=${suggestionsProvider}
+        .placeholder=${placeholder}
+      >
+      </gr-account-list>
+    `;
+  }
+
+  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]) {
+      this.updatedAccountsByReviewerState.set(
+        state,
+        this.getCurrentAccounts(state)
+      );
+      if (this.selectedChanges.length > 0) {
+        this.suggestionsProviderByReviewerState.set(
+          state,
+          this.createSuggestionsProvider(state)
+        );
+      }
+    }
+    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 saveReviewers() {
+    this.progressByChangeNum = new Map(
+      this.selectedChanges.map(change => [
+        change._number,
+        ProgressStatus.RUNNING,
+      ])
+    );
+    const inFlightActions = this.getBulkActionsModel().addReviewers(
+      this.updatedAccountsByReviewerState
+    );
+    for (let index = 0; index < this.selectedChanges.length; index++) {
+      const change = this.selectedChanges[index];
+      inFlightActions[index]
+        .then(() => {
+          this.progressByChangeNum.set(
+            change._number,
+            ProgressStatus.SUCCESSFUL
+          );
+          this.requestUpdate();
+        })
+        .catch(() => {
+          this.progressByChangeNum.set(change._number, ProgressStatus.FAILED);
+          this.requestUpdate();
+        });
+    }
+  }
+
+  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] ?? []
+    );
+    if (reviewersPerChange.length === 0) {
+      return [];
+    }
+    // Gets reviewers present in all changes
+    return reviewersPerChange.reduce((a, b) =>
+      a.filter(reviewer => b.includes(reviewer))
+    );
+  }
+
+  private createSuggestionsProvider(
+    state: ReviewerState
+  ): ReviewerSuggestionsProvider {
+    const suggestionsProvider = GrReviewerSuggestionsProvider.create(
+      this.restApiService,
+      // TODO: fan out and get suggestions allowed by all changes
+      this.selectedChanges[0]._number,
+      SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE[state]
+    );
+    suggestionsProvider.init();
+    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..afc7b4b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -0,0 +1,263 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html} from '@open-wc/testing-helpers';
+import {AccountInfo, 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 '../../../test/common-test-setup-karma';
+import {
+  createAccountWithIdNameAndEmail,
+  createChange,
+} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId} from '../../../types/common';
+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 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;
+
+  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);
+    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" 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>
+          </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]);
+      ccList.accounts.push(accounts[5]);
+      await flush();
+      dialog.confirmButton!.click();
+      await element.updateComplete;
+
+      assert.isTrue(saveChangeReviewStub.calledTwice);
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
+        changes[0]._number,
+        'current',
+        {
+          reviewers: [
+            {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
+          ],
+        },
+      ]);
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [
+        changes[1]._number,
+        'current',
+        {
+          reviewers: [
+            {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
+          ],
+        },
+      ]);
+    });
+
+    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');
+    });
+  });
+});
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
new file mode 100644
index 0000000..85ea644
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -0,0 +1,351 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {LitElement, html, css, PropertyValues} from 'lit';
+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,
+  GerritNav,
+} from '../../core/gr-navigation/gr-navigation';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {getAppContext} from '../../../services/app-context';
+import {ChangeInfo, ServerInfo, AccountInfo} from '../../../api/rest-api';
+import {changeListStyles} from '../../../styles/gr-change-list-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {Metadata} from '../../../utils/change-metadata-util';
+import {WAITING} from '../../../constants/constants';
+import {ifDefined} from 'lit/directives/if-defined';
+import {provide} from '../../../models/dependency';
+import {
+  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--';
+const MAX_SHORTCUT_CHARS = 5;
+const INVALID_TOKENS = ['limit:', 'age:', '-age:'];
+
+export function computeLabelShortcut(labelName: string) {
+  if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
+    labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
+  }
+  // Compute label shortcut by splitting token by - and capitalizing first
+  // letter of each token.
+  return labelName
+    .split('-')
+    .reduce((previousValue, currentValue) => {
+      if (!currentValue) {
+        return previousValue;
+      }
+      return previousValue + currentValue[0].toUpperCase();
+    }, '')
+    .slice(0, MAX_SHORTCUT_CHARS);
+}
+
+@customElement('gr-change-list-section')
+export class GrChangeListSection extends LitElement {
+  @property({type: Array})
+  visibleChangeTableColumns?: string[];
+
+  @property({type: Boolean})
+  showStar = false;
+
+  @property({type: Boolean})
+  showNumber?: boolean; // No default value to prevent flickering.
+
+  @property({type: Number})
+  selectedIndex?: number; // The relative index of the change that is selected
+
+  @property({type: Array})
+  labelNames: string[] = [];
+
+  @property({type: Array})
+  dynamicHeaderEndpoints?: string[];
+
+  @property({type: Object})
+  changeSection!: ChangeListSection;
+
+  @property({type: Object})
+  config?: ServerInfo;
+
+  @property({type: Boolean})
+  isCursorMoving = false;
+
+  /**
+   * The logged-in user's account, or an empty object if no user is logged
+   * in.
+   */
+  @property({type: Object})
+  account: AccountInfo | undefined = undefined;
+
+  @state() showBulkActionsHeader = false;
+
+  private readonly flagsService = getAppContext().flagsService;
+
+  bulkActionsModel: BulkActionsModel = new BulkActionsModel(
+    getAppContext().restApiService
+  );
+
+  static override get styles() {
+    return [
+      changeListStyles,
+      fontStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: contents;
+        }
+        .section-count-label {
+          color: var(--deemphasized-text-color);
+          font-family: var(--font-family);
+          font-size: var(--font-size-small);
+          font-weight: var(--font-weight-normal);
+          line-height: var(--line-height-small);
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    provide(this, bulkActionsModelToken, () => this.bulkActionsModel);
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    subscribe(
+      this,
+      this.bulkActionsModel.selectedChangeNums$,
+      selectedChanges =>
+        (this.showBulkActionsHeader = selectedChanges.length > 0)
+    );
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('changeSection')) {
+      // 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.
+      if (this.flagsService.isEnabled(KnownExperimentId.BULK_ACTIONS)) {
+        this.bulkActionsModel.sync(this.changeSection.results);
+      }
+    }
+  }
+
+  override render() {
+    const columns = this.computeColumns();
+    const colSpan = this.computeColspan(columns);
+    return html`
+      ${this.renderSectionHeader(colSpan)}
+      <tbody class="groupContent">
+        ${this.isEmpty()
+          ? this.renderNoChangesRow(colSpan)
+          : this.renderColumnHeaders(columns)}
+        ${this.changeSection.results.map((change, index) =>
+          this.renderChangeRow(change, index, columns)
+        )}
+      </tbody>
+    `;
+  }
+
+  private renderNoChangesRow(colSpan: number) {
+    return html`
+      <tr class="noChanges">
+        <td class="leftPadding" aria-hidden="true"></td>
+        <td
+          class="star"
+          ?aria-hidden=${!this.showStar}
+          ?hidden=${!this.showStar}
+        ></td>
+        <td class="cell" colspan=${colSpan}>
+          ${this.changeSection.emptyStateSlotName
+            ? html`<slot name=${this.changeSection.emptyStateSlotName}></slot>`
+            : 'No changes'}
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderSectionHeader(colSpan: number) {
+    if (
+      this.changeSection.name === undefined ||
+      this.changeSection.countLabel === undefined ||
+      this.changeSection.query === undefined
+    )
+      return;
+
+    return html`
+      <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}>
+            <h2 class="heading-3">
+              <a
+                href=${this.sectionHref(this.changeSection.query)}
+                class="section-title"
+              >
+                <span class="section-name">${this.changeSection.name}</span>
+                <span class="section-count-label"
+                  >${this.changeSection.countLabel}</span
+                >
+              </a>
+            </h2>
+          </td>
+        </tr>
+      </tbody>
+    `;
+  }
+
+  private renderColumnHeaders(columns: string[]) {
+    return html`
+      <tr class="groupTitle">
+        ${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>`;
+  }
+
+  private renderHeaderCell(item: string) {
+    return html`<td class=${item.toLowerCase()}>${item}</td>`;
+  }
+
+  private renderLabelHeader(labelName: string) {
+    return html`
+      <td class="label" title=${labelName}>
+        ${computeLabelShortcut(labelName)}
+      </td>
+    `;
+  }
+
+  private renderEndpointHeader(pluginHeader: string) {
+    return html`
+      <td class="endpoint">
+        <gr-endpoint-decorator .name=${pluginHeader}></gr-endpoint-decorator>
+      </td>
+    `;
+  }
+
+  private renderChangeRow(
+    change: ChangeInfo,
+    index: number,
+    columns: string[]
+  ) {
+    const ariaLabel = this.computeAriaLabel(change);
+    const selected = this.computeItemSelected(index);
+    const tabindex = this.computeTabIndex(index);
+    return html`
+      <gr-change-list-item
+        .account=${this.account}
+        ?selected=${selected}
+        .change=${change}
+        .config=${this.config}
+        .sectionName=${this.changeSection.name}
+        .visibleChangeTableColumns=${columns}
+        .showNumber=${this.showNumber}
+        ?showStar=${this.showStar}
+        tabindex=${ifDefined(tabindex)}
+        .labelNames=${this.labelNames}
+        aria-label=${ariaLabel}
+      ></gr-change-list-item>
+    `;
+  }
+
+  /**
+   * This methods allows us to customize the columns per section.
+   * Private but used in test
+   *
+   */
+  computeColumns() {
+    const section = this.changeSection;
+    if (!section || !this.visibleChangeTableColumns) return [];
+    const cols = [...this.visibleChangeTableColumns];
+    const updatedIndex = cols.indexOf(Metadata.UPDATED);
+    if (section.name === YOUR_TURN.name && updatedIndex !== -1) {
+      cols[updatedIndex] = WAITING;
+    }
+    if (section.name === CLOSED.name && updatedIndex !== -1) {
+      cols[updatedIndex] = Metadata.SUBMITTED;
+    }
+    return cols;
+  }
+
+  // private but used in test
+  computeItemSelected(index: number) {
+    return index === this.selectedIndex;
+  }
+
+  private computeTabIndex(index: number) {
+    if (this.isCursorMoving) return 0;
+    return this.computeItemSelected(index) ? 0 : undefined;
+  }
+
+  // private but used in test
+  computeColspan(cols: string[]) {
+    if (!cols || !this.labelNames) return 1;
+    return cols.length + this.labelNames.length + NUMBER_FIXED_COLUMNS;
+  }
+
+  // private but used in test
+  processQuery(query: string) {
+    let tokens = query.split(' ');
+    tokens = tokens.filter(
+      token =>
+        !INVALID_TOKENS.some(invalidToken => token.startsWith(invalidToken))
+    );
+    return tokens.join(' ');
+  }
+
+  private sectionHref(query?: string) {
+    if (!query) return;
+    return GerritNav.getUrlForSearchQuery(this.processQuery(query));
+  }
+
+  // private but used in test
+  isEmpty() {
+    return !this.changeSection.results?.length;
+  }
+
+  private computeAriaLabel(change?: ChangeInfo) {
+    const sectionName = this.changeSection.name;
+    if (!change) return '';
+    return change.subject + (sectionName ? `, section: ${sectionName}` : '');
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-section': GrChangeListSection;
+  }
+}
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
new file mode 100644
index 0000000..6a09c45
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
@@ -0,0 +1,294 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+  GrChangeListSection,
+  computeLabelShortcut,
+} from './gr-change-list-section';
+import '../../../test/common-test-setup-karma';
+import './gr-change-list-section';
+import {
+  createChange,
+  createAccountDetailWithId,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {NumericChangeId, ChangeInfoId} from '../../../api/rest-api';
+import {
+  queryAll,
+  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 {fixture, html} from '@open-wc/testing-helpers';
+
+suite('gr-change-list section', () => {
+  let element: GrChangeListSection;
+
+  setup(async () => {
+    const changeSection: ChangeListSection = {
+      name: 'test',
+      query: 'test',
+      results: [
+        {
+          ...createChange(),
+          _number: 0 as NumericChangeId,
+          id: '0' as ChangeInfoId,
+        },
+        {
+          ...createChange(),
+          _number: 1 as NumericChangeId,
+          id: '1' as ChangeInfoId,
+        },
+      ],
+      emptyStateSlotName: 'test',
+    };
+    element = await fixture<GrChangeListSection>(
+      html`<gr-change-list-section
+        .account=${createAccountDetailWithId(1)}
+        .config=${createServerInfo()}
+        .visibleChangeTableColumns=${columnNames}
+        .changeSection=${changeSection}
+      ></gr-change-list-section> `
+    );
+  });
+
+  test('selection checkbox is only shown if experiment is enabled', async () => {
+    assert.isNotOk(query(element, '.selection'));
+
+    stubFlags('isEnabled').returns(true);
+    element.requestUpdate();
+    await element.updateComplete;
+
+    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 () => {
+      isEnabled = stubFlags('isEnabled');
+      isEnabled.returns(true);
+      element.requestUpdate();
+      await element.updateComplete;
+    });
+
+    test('changing section triggers model sync', async () => {
+      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.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('colspans', async () => {
+    element.visibleChangeTableColumns = [];
+    element.changeSection = {results: [{...createChange()}]};
+    await element.updateComplete;
+    const tdItemCount = queryAll<HTMLTableElement>(element, 'td').length;
+
+    element.labelNames = [];
+    assert.equal(tdItemCount, element.computeColspan(element.computeColumns()));
+  });
+
+  test('computeItemSelected', () => {
+    element.selectedIndex = 1;
+    assert.isTrue(element.computeItemSelected(1));
+    assert.isFalse(element.computeItemSelected(2));
+  });
+
+  test('computed fields', () => {
+    assert.equal(computeLabelShortcut('Code-Review'), 'CR');
+    assert.equal(computeLabelShortcut('Verified'), 'V');
+    assert.equal(computeLabelShortcut('Library-Compliance'), 'LC');
+    assert.equal(computeLabelShortcut('PolyGerrit-Review'), 'PR');
+    assert.equal(computeLabelShortcut('polygerrit-review'), 'PR');
+    assert.equal(
+      computeLabelShortcut('Invalid-Prolog-Rules-Label-Name--Verified'),
+      'V'
+    );
+    assert.equal(computeLabelShortcut('Some-Special-Label-7'), 'SSL7');
+    assert.equal(computeLabelShortcut('--Too----many----dashes---'), 'TMD');
+    assert.equal(
+      computeLabelShortcut('Really-rather-entirely-too-long-of-a-label-name'),
+      'RRETL'
+    );
+  });
+
+  suite('empty section slots', () => {
+    test('empty section', async () => {
+      element.changeSection = {results: []};
+      await element.updateComplete;
+      const listItems = queryAll<GrChangeListItem>(
+        element,
+        'gr-change-list-item'
+      );
+      assert.equal(listItems.length, 0);
+      const noChangesMsg = queryAll<HTMLTableRowElement>(element, '.noChanges');
+      assert.equal(noChangesMsg.length, 1);
+    });
+
+    test('are shown on empty sections with slot name', async () => {
+      const section = {
+        name: 'test',
+        query: 'test',
+        results: [],
+        emptyStateSlotName: 'test',
+      };
+      element.changeSection = section;
+      await element.updateComplete;
+
+      assert.isEmpty(queryAll(element, 'gr-change-list-item'));
+      queryAndAssert(element, 'slot[name="test"]');
+    });
+
+    test('are not shown on empty sections without slot name', async () => {
+      const section = {name: 'test', query: 'test', results: []};
+      element.changeSection = section;
+      await element.updateComplete;
+
+      assert.isEmpty(queryAll(element, 'gr-change-list-item'));
+      assert.notExists(query(element, 'slot[name="test"]'));
+    });
+
+    test('are not shown on non-empty sections with slot name', async () => {
+      const section = {
+        name: 'test',
+        query: 'test',
+        emptyStateSlotName: 'test',
+        results: [
+          {
+            ...createChange(),
+            _number: 0 as NumericChangeId,
+            labels: {Verified: {approved: {}}},
+          },
+        ],
+      };
+      element.changeSection = section;
+      await element.updateComplete;
+
+      assert.isNotEmpty(queryAll(element, 'gr-change-list-item'));
+      assert.notExists(query(element, 'slot[name="test"]'));
+    });
+  });
+
+  suite('dashboard queries', () => {
+    test('query without age and limit unchanged', () => {
+      const query = 'status:closed owner:me';
+      assert.deepEqual(element.processQuery(query), query);
+    });
+
+    test('query with age and limit', () => {
+      const query = 'status:closed age:1week limit:10 owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element.processQuery(query), expectedQuery);
+    });
+
+    test('query with age', () => {
+      const query = 'status:closed age:1week owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element.processQuery(query), expectedQuery);
+    });
+
+    test('query with limit', () => {
+      const query = 'status:closed limit:10 owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element.processQuery(query), expectedQuery);
+    });
+
+    test('query with age as value and not key', () => {
+      const query = 'status:closed random:age';
+      const expectedQuery = 'status:closed random:age';
+      assert.deepEqual(element.processQuery(query), expectedQuery);
+    });
+
+    test('query with limit as value and not key', () => {
+      const query = 'status:closed random:limit';
+      const expectedQuery = 'status:closed random:limit';
+      assert.deepEqual(element.processQuery(query), expectedQuery);
+    });
+
+    test('query with -age key', () => {
+      const query = 'status:closed -age:1week';
+      const expectedQuery = 'status:closed';
+      assert.deepEqual(element.processQuery(query), expectedQuery);
+    });
+  });
+});
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 e11ac9b..9743987 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
@@ -19,12 +19,8 @@
 import '../gr-change-list/gr-change-list';
 import '../gr-repo-header/gr-repo-header';
 import '../gr-user-header/gr-user-header';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-list-view_html';
 import {page} from '../../../utils/page-wrapper-utils';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {AppElementParams} from '../../gr-app-types';
 import {
   AccountDetailInfo,
@@ -36,9 +32,13 @@
 } from '../../../types/common';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {ChangeListViewState} from '../../../types/types';
-import {fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {fire, fireTitleChange} from '../../../utils/event-util';
+import {getAppContext} from '../../../services/app-context';
 import {GerritView} from '../../../services/router/router-model';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state, query} from 'lit/decorators';
+import {ValueChangedEvent} from '../../../types/events';
 
 const LOOKUP_QUERY_PATTERNS: RegExp[] = [
   /^\s*i?[0-9a-f]{7,40}\s*$/i, // CHANGE_ID
@@ -53,105 +53,243 @@
 
 const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
 
-export interface GrChangeListView {
-  $: {
-    prevArrow: HTMLAnchorElement;
-    nextArrow: HTMLAnchorElement;
-  };
-}
-
 @customElement('gr-change-list-view')
-export class GrChangeListView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrChangeListView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
    * @event title-change
    */
 
-  @property({type: Object, observer: '_paramsChanged'})
-  params?: AppElementParams;
+  @query('#prevArrow') protected prevArrow?: HTMLAnchorElement;
 
-  @property({type: Boolean, computed: '_computeLoggedIn(account)'})
-  _loggedIn?: boolean;
+  @query('#nextArrow') protected nextArrow?: HTMLAnchorElement;
+
+  @property({type: Object})
+  params?: AppElementParams;
 
   @property({type: Object})
   account: AccountDetailInfo | null = null;
 
-  @property({type: Object, notify: true})
+  @property({type: Object})
   viewState: ChangeListViewState = {};
 
   @property({type: Object})
   preferences?: PreferencesInput;
 
-  @property({type: Number})
-  _changesPerPage?: number;
+  // private but used in test
+  @state() changesPerPage?: number;
 
-  @property({type: String})
-  _query = '';
+  // private but used in test
+  @state() query = '';
 
-  @property({type: Number})
-  _offset?: number;
+  // private but used in test
+  @state() offset?: number;
 
-  @property({type: Array, observer: '_changesChanged'})
-  _changes?: ChangeInfo[];
+  // private but used in test
+  @state() changes?: ChangeInfo[];
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: String})
-  _userId: AccountId | EmailAddress | null = null;
+  // private but used in test
+  @state() userId: AccountId | EmailAddress | null = null;
 
-  @property({type: String})
-  _repo: RepoName | null = null;
+  // private but used in test
+  @state() repo: RepoName | null = null;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private reporting = appContext.reportingService;
+  private reporting = getAppContext().reportingService;
 
   constructor() {
     super();
-    this.addEventListener('next-page', () => this._handleNextPage());
-    this.addEventListener('previous-page', () => this._handlePreviousPage());
+    this.addEventListener('next-page', () => this.handleNextPage());
+    this.addEventListener('previous-page', () => this.handlePreviousPage());
     this.addEventListener('reload', () => this.reload());
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadPreferences();
+    this.loadPreferences();
+  }
+
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        .loading {
+          color: var(--deemphasized-text-color);
+          padding: var(--spacing-l);
+        }
+        gr-change-list {
+          width: 100%;
+        }
+        gr-user-header,
+        gr-repo-header {
+          border-bottom: 1px solid var(--border-color);
+        }
+        nav {
+          align-items: center;
+          display: flex;
+          height: 3rem;
+          justify-content: flex-end;
+          margin-right: 20px;
+        }
+        nav,
+        iron-icon {
+          color: var(--deemphasized-text-color);
+        }
+        iron-icon {
+          height: 1.85rem;
+          margin-left: 16px;
+          width: 1.85rem;
+        }
+        .hide {
+          display: none;
+        }
+        @media only screen and (max-width: 50em) {
+          .loading,
+          .error {
+            padding: 0 var(--spacing-l);
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    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 class="loading" ?hidden=${!this.loading}>Loading...</div>
+      <div ?hidden=${this.loading}>
+        ${this.renderRepoHeader()} ${this.renderUserHeader(loggedIn)}
+        <gr-change-list
+          .account=${this.account}
+          .changes=${this.changes}
+          .preferences=${this.preferences}
+          .selectedIndex=${this.viewState.selectedChangeIndex}
+          .showStar=${loggedIn}
+          @selected-index-changed=${(e: ValueChangedEvent<number>) => {
+            this.handleSelectedIndexChanged(e);
+          }}
+          @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
+            this.handleToggleStar(e);
+          }}
+        ></gr-change-list>
+        ${this.renderChangeListViewNav()}
+      </div>
+    `;
+  }
+
+  private renderRepoHeader() {
+    if (!this.repo) return;
+
+    return html` <gr-repo-header .repo=${this.repo}></gr-repo-header> `;
+  }
+
+  private renderUserHeader(loggedIn: boolean) {
+    if (!this.userId) return;
+
+    return html`
+      <gr-user-header
+        .userId=${this.userId}
+        showDashboardLink
+        .loggedIn=${loggedIn}
+      ></gr-user-header>
+    `;
+  }
+
+  private renderChangeListViewNav() {
+    if (this.loading || !this.changes || !this.changes.length) return;
+
+    return html`
+      <nav>
+        Page ${this.computePage()} ${this.renderPrevArrow()}
+        ${this.renderNextArrow()}
+      </nav>
+    `;
+  }
+
+  private renderPrevArrow() {
+    if (this.offset === 0) return;
+
+    return html`
+      <a id="prevArrow" href=${this.computeNavLink(-1)}>
+        <iron-icon icon="gr-icons:chevron-left" aria-label="Older"> </iron-icon>
+      </a>
+    `;
+  }
+
+  private renderNextArrow() {
+    if (
+      !(
+        this.changes?.length &&
+        this.changes[this.changes.length - 1]._more_changes
+      )
+    )
+      return;
+
+    return html`
+      <a id="nextArrow" href=${this.computeNavLink(1)}>
+        <iron-icon icon="gr-icons:chevron-right" aria-label="Newer">
+        </iron-icon>
+      </a>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this.paramsChanged();
+    }
+
+    if (changedProperties.has('changes')) {
+      this.changesChanged();
+    }
   }
 
   reload() {
-    if (this._loading) return;
-    this._loading = true;
-    this._getChanges().then(changes => {
-      this._changes = changes || [];
-      this._loading = false;
+    if (this.loading) return;
+    this.loading = true;
+    this.getChanges().then(changes => {
+      this.changes = changes || [];
+      this.loading = false;
     });
   }
 
-  _paramsChanged(value: AppElementParams) {
-    if (value.view !== GerritView.SEARCH) return;
+  private paramsChanged() {
+    const value = this.params;
+    if (!value || value.view !== GerritView.SEARCH) return;
 
-    this._loading = true;
-    this._query = value.query;
+    this.loading = true;
+    this.query = value.query;
     const offset = Number(value.offset);
-    this._offset = isNaN(offset) ? 0 : offset;
+    this.offset = isNaN(offset) ? 0 : offset;
     if (
-      this.viewState.query !== this._query ||
-      this.viewState.offset !== this._offset
+      this.viewState.query !== this.query ||
+      this.viewState.offset !== this.offset
     ) {
-      this.set('viewState.selectedChangeIndex', 0);
-      this.set('viewState.query', this._query);
-      this.set('viewState.offset', this._offset);
+      this.viewState.selectedChangeIndex = 0;
+      this.viewState.query = this.query;
+      this.viewState.offset = this.offset;
+      fire(this, 'view-state-change-list-view-changed', {
+        value: this.viewState,
+      });
     }
 
     // NOTE: This method may be called before attachment. Fire title-change
     // in an async so that attachment to the DOM can take place first.
-    setTimeout(() => fireTitleChange(this, this._query));
+    setTimeout(() => fireTitleChange(this, this.query));
 
     this.restApiService
       .getPreferences()
@@ -159,33 +297,29 @@
         if (!prefs) {
           throw new Error('getPreferences returned undefined');
         }
-        this._changesPerPage = prefs.changes_per_page;
-        return this._getChanges();
+        this.changesPerPage = prefs.changes_per_page;
+        return this.getChanges();
       })
       .then(changes => {
         changes = changes || [];
-        if (this._query && changes.length === 1) {
+        if (this.query && changes.length === 1) {
           for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
-            if (this._query.match(queryPattern)) {
+            if (this.query.match(queryPattern)) {
               // "Back"/"Forward" buttons work correctly only with
               // opt_redirect options
-              GerritNav.navigateToChange(
-                changes[0],
-                undefined,
-                undefined,
-                undefined,
-                true
-              );
+              GerritNav.navigateToChange(changes[0], {
+                redirect: true,
+              });
               return;
             }
           }
         }
-        this._changes = changes;
-        this._loading = false;
+        this.changes = changes;
+        this.loading = false;
       });
   }
 
-  _loadPreferences() {
+  private loadPreferences() {
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         this.restApiService.getPreferences().then(preferences => {
@@ -197,15 +331,18 @@
     });
   }
 
-  _getChanges() {
+  // private but used in test
+  getChanges() {
     return this.restApiService.getChanges(
-      this._changesPerPage,
-      this._query,
-      this._offset
+      this.changesPerPage,
+      this.query,
+      this.offset
     );
   }
 
-  _limitFor(query: string, defaultLimit: number) {
+  // private but used in test
+  limitFor(query: string, defaultLimit?: number) {
+    if (defaultLimit === undefined) return 0;
     const match = query.match(LIMIT_OPERATOR_PATTERN);
     if (!match) {
       return defaultLimit;
@@ -213,78 +350,53 @@
     return Number(match[1]);
   }
 
-  _computeNavLink(
-    query: string,
-    offset: number | undefined,
-    direction: number,
-    changesPerPage: number
-  ) {
-    offset = offset ?? 0;
-    const limit = this._limitFor(query, changesPerPage);
+  // private but used in test
+  computeNavLink(direction: number) {
+    const offset = this.offset ?? 0;
+    const limit = this.limitFor(this.query, this.changesPerPage);
     const newOffset = Math.max(0, offset + limit * direction);
-    return GerritNav.getUrlForSearchQuery(query, newOffset);
+    return GerritNav.getUrlForSearchQuery(this.query, newOffset);
   }
 
-  _computePrevArrowClass(offset?: number) {
-    return offset === 0 ? 'hide' : '';
+  // private but used in test
+  handleNextPage() {
+    if (!this.nextArrow || !this.changesPerPage) return;
+    page.show(this.computeNavLink(1));
   }
 
-  _computeNextArrowClass(changes?: ChangeInfo[]) {
-    const more = changes?.length && changes[changes.length - 1]._more_changes;
-    return more ? '' : 'hide';
+  // private but used in test
+  handlePreviousPage() {
+    if (!this.prevArrow || !this.changesPerPage) return;
+    page.show(this.computeNavLink(-1));
   }
 
-  _computeNavClass(loading?: boolean) {
-    return loading || !this._changes || !this._changes.length ? 'hide' : '';
-  }
-
-  _handleNextPage() {
-    if (this.$.nextArrow.hidden || !this._changesPerPage) return;
-    page.show(
-      this._computeNavLink(this._query, this._offset, 1, this._changesPerPage)
-    );
-  }
-
-  _handlePreviousPage() {
-    if (this.$.prevArrow.hidden || !this._changesPerPage) return;
-    page.show(
-      this._computeNavLink(this._query, this._offset, -1, this._changesPerPage)
-    );
-  }
-
-  _changesChanged(changes?: ChangeInfo[]) {
-    this._userId = null;
-    this._repo = null;
+  private changesChanged() {
+    this.userId = null;
+    this.repo = null;
+    const changes = this.changes;
     if (!changes || !changes.length) {
       return;
     }
-    if (USER_QUERY_PATTERN.test(this._query)) {
+    if (USER_QUERY_PATTERN.test(this.query)) {
       const owner = changes[0].owner;
       const userId = owner._account_id ? owner._account_id : owner.email;
       if (userId) {
-        this._userId = userId;
+        this.userId = userId;
         return;
       }
     }
-    if (REPO_QUERY_PATTERN.test(this._query)) {
-      this._repo = changes[0].project;
+    if (REPO_QUERY_PATTERN.test(this.query)) {
+      this.repo = changes[0].project;
     }
   }
 
-  _computeHeaderClass(id?: string) {
-    return id ? '' : 'hide';
+  // private but used in test
+  computePage() {
+    if (this.offset === undefined || this.changesPerPage === undefined) return;
+    return this.offset / this.changesPerPage + 1;
   }
 
-  _computePage(offset?: number, changesPerPage?: number) {
-    if (offset === undefined || changesPerPage === undefined) return;
-    return offset / changesPerPage + 1;
-  }
-
-  _computeLoggedIn(account?: AccountDetailInfo) {
-    return !!(account && Object.keys(account).length > 0);
-  }
-
-  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+  private handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
     if (e.detail.starred) {
       this.reporting.reportInteraction('change-starred-from-change-list');
     }
@@ -294,16 +406,17 @@
     );
   }
 
-  /**
-   * Returns `this` as the visibility observer target for the keyboard shortcut
-   * mixin to decide whether shortcuts should be enabled or not.
-   */
-  _computeObserverTarget() {
-    return this;
+  private handleSelectedIndexChanged(e: ValueChangedEvent<number>) {
+    if (!this.viewState) return;
+    this.viewState.selectedChangeIndex = e.detail.value;
+    fire(this, 'view-state-change-list-view-changed', {value: this.viewState});
   }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    '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_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
deleted file mode 100644
index 355ef45..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
+++ /dev/null
@@ -1,101 +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;
-    }
-    .loading {
-      color: var(--deemphasized-text-color);
-      padding: var(--spacing-l);
-    }
-    gr-change-list {
-      width: 100%;
-    }
-    gr-user-header,
-    gr-repo-header {
-      border-bottom: 1px solid var(--border-color);
-    }
-    nav {
-      align-items: center;
-      display: flex;
-      height: 3rem;
-      justify-content: flex-end;
-      margin-right: 20px;
-    }
-    nav,
-    iron-icon {
-      color: var(--deemphasized-text-color);
-    }
-    iron-icon {
-      height: 1.85rem;
-      margin-left: 16px;
-      width: 1.85rem;
-    }
-    .hide {
-      display: none;
-    }
-    @media only screen and (max-width: 50em) {
-      .loading,
-      .error {
-        padding: 0 var(--spacing-l);
-      }
-    }
-  </style>
-  <div class="loading" hidden$="[[!_loading]]" hidden="">Loading...</div>
-  <div hidden$="[[_loading]]" hidden="">
-    <gr-repo-header
-      repo="[[_repo]]"
-      class$="[[_computeHeaderClass(_repo)]]"
-    ></gr-repo-header>
-    <gr-user-header
-      user-id="[[_userId]]"
-      showDashboardLink=""
-      logged-in="[[_loggedIn]]"
-      class$="[[_computeHeaderClass(_userId)]]"
-    ></gr-user-header>
-    <gr-change-list
-      account="[[account]]"
-      changes="{{_changes}}"
-      preferences="[[preferences]]"
-      selected-index="{{viewState.selectedChangeIndex}}"
-      show-star="[[_loggedIn]]"
-      on-toggle-star="_handleToggleStar"
-      observer-target="[[_computeObserverTarget()]]"
-    ></gr-change-list>
-    <nav class$="[[_computeNavClass(_loading)]]">
-      Page [[_computePage(_offset, _changesPerPage)]]
-      <a
-        id="prevArrow"
-        href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
-        class$="[[_computePrevArrowClass(_offset)]]"
-      >
-        <iron-icon icon="gr-icons:chevron-left" aria-label="Older"> </iron-icon>
-      </a>
-      <a
-        id="nextArrow"
-        href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
-        class$="[[_computeNextArrowClass(_changes)]]"
-      >
-        <iron-icon icon="gr-icons:chevron-right" aria-label="Newer">
-        </iron-icon>
-      </a>
-    </nav>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
deleted file mode 100644
index 86b2fd1..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
+++ /dev/null
@@ -1,249 +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-change-list-view.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import 'lodash/lodash.js';
-import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-change-list-view');
-
-const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
-const COMMIT_HASH = '12345678';
-
-suite('gr-change-list-view tests', () => {
-  let element;
-
-  setup(() => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getChanges').returns(Promise.resolve([]));
-    stubRestApi('getAccountDetails').returns(Promise.resolve({}));
-    stubRestApi('getAccountStatus').returns(Promise.resolve({}));
-    element = basicFixture.instantiate();
-  });
-
-  teardown(async () => {
-    await flush();
-  });
-
-  test('_computePage', () => {
-    assert.equal(element._computePage(0, 25), 1);
-    assert.equal(element._computePage(50, 25), 3);
-  });
-
-  test('_limitFor', () => {
-    const defaultLimit = 25;
-    const _limitFor = q => element._limitFor(q, defaultLimit);
-    assert.equal(_limitFor(''), defaultLimit);
-    assert.equal(_limitFor('limit:10'), 10);
-    assert.equal(_limitFor('xlimit:10'), defaultLimit);
-    assert.equal(_limitFor('x(limit:10'), 10);
-  });
-
-  test('_computeNavLink', () => {
-    const getUrlStub = sinon.stub(GerritNav, 'getUrlForSearchQuery')
-        .returns('');
-    const query = 'status:open';
-    let offset = 0;
-    let direction = 1;
-    const changesPerPage = 5;
-
-    element._computeNavLink(query, offset, direction, changesPerPage);
-    assert.equal(getUrlStub.lastCall.args[1], 5);
-
-    direction = -1;
-    element._computeNavLink(query, offset, direction, changesPerPage);
-    assert.equal(getUrlStub.lastCall.args[1], 0);
-
-    offset = 5;
-    direction = 1;
-    element._computeNavLink(query, offset, direction, changesPerPage);
-    assert.equal(getUrlStub.lastCall.args[1], 10);
-  });
-
-  test('_computePrevArrowClass', () => {
-    let offset = 0;
-    assert.equal(element._computePrevArrowClass(offset), 'hide');
-    offset = 5;
-    assert.equal(element._computePrevArrowClass(offset), '');
-  });
-
-  test('_computeNextArrowClass', () => {
-    let changes = _.times(25, _.constant({_more_changes: true}));
-    assert.equal(element._computeNextArrowClass(changes), '');
-    changes = _.times(25, _.constant({}));
-    assert.equal(element._computeNextArrowClass(changes), 'hide');
-  });
-
-  test('_computeNavClass', () => {
-    let loading = true;
-    assert.equal(element._computeNavClass(loading), 'hide');
-    loading = false;
-    assert.equal(element._computeNavClass(loading), 'hide');
-    element._changes = [];
-    assert.equal(element._computeNavClass(loading), 'hide');
-    element._changes = _.times(5, _.constant({}));
-    assert.equal(element._computeNavClass(loading), '');
-  });
-
-  test('_handleNextPage', () => {
-    const showStub = sinon.stub(page, 'show');
-    element._changesPerPage = 10;
-    element.$.nextArrow.hidden = true;
-    element._handleNextPage();
-    assert.isFalse(showStub.called);
-    element.$.nextArrow.hidden = false;
-    element._handleNextPage();
-    assert.isTrue(showStub.called);
-  });
-
-  test('_handlePreviousPage', () => {
-    const showStub = sinon.stub(page, 'show');
-    element._changesPerPage = 10;
-    element.$.prevArrow.hidden = true;
-    element._handlePreviousPage();
-    assert.isFalse(showStub.called);
-    element.$.prevArrow.hidden = false;
-    element._handlePreviousPage();
-    assert.isTrue(showStub.called);
-  });
-
-  test('_userId query', async () => {
-    assert.isNull(element._userId);
-    element._query = 'owner: foo@bar';
-    element._changes = [{owner: {email: 'foo@bar'}}];
-    await flush();
-    assert.equal(element._userId, 'foo@bar');
-
-    element._query = 'foo bar baz';
-    element._changes = [{owner: {email: 'foo@bar'}}];
-    assert.isNull(element._userId);
-  });
-
-  test('_userId query without email', async () => {
-    assert.isNull(element._userId);
-    element._query = 'owner: foo@bar';
-    element._changes = [{owner: {}}];
-    await flush();
-    assert.isNull(element._userId);
-  });
-
-  test('_repo query', async () => {
-    assert.isNull(element._repo);
-    element._query = 'project: test-repo';
-    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-    await flush();
-    assert.equal(element._repo, 'test-repo');
-    element._query = 'foo bar baz';
-    element._changes = [{owner: {email: 'foo@bar'}}];
-    assert.isNull(element._repo);
-  });
-
-  test('_repo query with open status', async () => {
-    assert.isNull(element._repo);
-    element._query = 'project:test-repo status:open';
-    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-    await flush();
-    assert.equal(element._repo, 'test-repo');
-    element._query = 'foo bar baz';
-    element._changes = [{owner: {email: 'foo@bar'}}];
-    assert.isNull(element._repo);
-  });
-
-  suite('query based navigation', () => {
-    setup(() => {
-    });
-
-    teardown(async () => {
-      await flush();
-      sinon.restore();
-    });
-
-    test('Searching for a change ID redirects to change', async () => {
-      const change = {_number: 1};
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([change]));
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake(
-          (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
-            assert.equal(url, change);
-            assert.isTrue(opt_redirect);
-            promise.resolve();
-          });
-
-      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-      await promise;
-    });
-
-    test('Searching for a change num redirects to change', async () => {
-      const change = {_number: 1};
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([change]));
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake(
-          (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
-            assert.equal(url, change);
-            assert.isTrue(opt_redirect);
-            promise.resolve();
-          });
-
-      element.params = {view: GerritNav.View.SEARCH, query: '1'};
-      await promise;
-    });
-
-    test('Commit hash redirects to change', async () => {
-      const change = {_number: 1};
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([change]));
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake(
-          (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
-            assert.equal(url, change);
-            assert.isTrue(opt_redirect);
-            promise.resolve();
-          });
-
-      element.params = {view: GerritNav.View.SEARCH, query: COMMIT_HASH};
-      await promise;
-    });
-
-    test('Searching for an invalid change ID searches', async () => {
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([]));
-      const stub = sinon.stub(GerritNav, 'navigateToChange');
-
-      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-      await flush();
-
-      assert.isFalse(stub.called);
-    });
-
-    test('Change ID with multiple search results searches', async () => {
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([{}, {}]));
-      const stub = sinon.stub(GerritNav, 'navigateToChange');
-
-      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-      await flush();
-
-      assert.isFalse(stub.called);
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..0633f46
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -0,0 +1,364 @@
+/**
+ * @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-change-list-view';
+import {GrChangeListView} from './gr-change-list-view';
+import {page} from '../../../utils/page-wrapper-utils';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  mockPromise,
+  query,
+  stubRestApi,
+  queryAndAssert,
+  stubFlags,
+} from '../../../test/test-utils';
+import {createChange} from '../../../test/test-data-generators.js';
+import {
+  ChangeInfo,
+  EmailAddress,
+  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');
+
+const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
+const COMMIT_HASH = '12345678';
+
+suite('gr-change-list-view tests', () => {
+  let element: GrChangeListView;
+
+  setup(async () => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getChanges').returns(Promise.resolve([]));
+    stubRestApi('getAccountDetails').returns(Promise.resolve(undefined));
+    stubRestApi('getAccountStatus').returns(Promise.resolve(undefined));
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  teardown(async () => {
+    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;
+    assert.equal(element.computePage(), 1);
+    element.offset = 50;
+    element.changesPerPage = 25;
+    assert.equal(element.computePage(), 3);
+  });
+
+  test('limitFor', () => {
+    const defaultLimit = 25;
+    const limitFor = (q: string) => element.limitFor(q, defaultLimit);
+    assert.equal(limitFor(''), defaultLimit);
+    assert.equal(limitFor('limit:10'), 10);
+    assert.equal(limitFor('xlimit:10'), defaultLimit);
+    assert.equal(limitFor('x(limit:10'), 10);
+  });
+
+  test('computeNavLink', () => {
+    const getUrlStub = sinon
+      .stub(GerritNav, 'getUrlForSearchQuery')
+      .returns('');
+    element.query = 'status:open';
+    element.offset = 0;
+    element.changesPerPage = 5;
+    let direction = 1;
+
+    element.computeNavLink(direction);
+    assert.equal(getUrlStub.lastCall.args[1], 5);
+
+    direction = -1;
+    element.computeNavLink(direction);
+    assert.equal(getUrlStub.lastCall.args[1], 0);
+
+    element.offset = 5;
+    direction = 1;
+    element.computeNavLink(direction);
+    assert.equal(getUrlStub.lastCall.args[1], 10);
+  });
+
+  test('prevArrow', async () => {
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
+    element.offset = 0;
+    element.loading = false;
+    await element.updateComplete;
+    assert.isNotOk(query(element, '#prevArrow'));
+
+    element.offset = 5;
+    await element.updateComplete;
+    assert.isOk(query(element, '#prevArrow'));
+  });
+
+  test('nextArrow', async () => {
+    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 = 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 = Array(25)
+      .fill(0)
+      .map(_ => createChange());
+    element.changesPerPage = 10;
+    element.loading = false;
+    await element.updateComplete;
+    element.handleNextPage();
+    assert.isFalse(showStub.called);
+
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => ({...createChange(), _more_changes: true} as ChangeInfo));
+    element.loading = false;
+    await element.updateComplete;
+    element.handleNextPage();
+    assert.isTrue(showStub.called);
+  });
+
+  test('handlePreviousPage', async () => {
+    const showStub = sinon.stub(page, 'show');
+    element.offset = 0;
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
+    element.changesPerPage = 10;
+    element.loading = false;
+    await element.updateComplete;
+    element.handlePreviousPage();
+    assert.isFalse(showStub.called);
+
+    element.offset = 25;
+    await element.updateComplete;
+    element.handlePreviousPage();
+    assert.isTrue(showStub.called);
+  });
+
+  test('userId query', async () => {
+    assert.isNull(element.userId);
+    element.query = 'owner: foo@bar';
+    element.changes = [
+      {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+    ];
+    await element.updateComplete;
+    assert.equal(element.userId, 'foo@bar' as EmailAddress);
+
+    element.query = 'foo bar baz';
+    element.changes = [
+      {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+    ];
+    await element.updateComplete;
+    assert.isNull(element.userId);
+  });
+
+  test('userId query without email', async () => {
+    assert.isNull(element.userId);
+    element.query = 'owner: foo@bar';
+    element.changes = [{...createChange(), owner: {}}];
+    await element.updateComplete;
+    assert.isNull(element.userId);
+  });
+
+  test('repo query', async () => {
+    assert.isNull(element.repo);
+    element.query = 'project: test-repo';
+    element.changes = [
+      {
+        ...createChange(),
+        owner: {email: 'foo@bar' as EmailAddress},
+        project: 'test-repo' as RepoName,
+      },
+    ];
+    await element.updateComplete;
+    assert.equal(element.repo, 'test-repo' as RepoName);
+
+    element.query = 'foo bar baz';
+    element.changes = [
+      {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+    ];
+    await element.updateComplete;
+    assert.isNull(element.repo);
+  });
+
+  test('repo query with open status', async () => {
+    assert.isNull(element.repo);
+    element.query = 'project:test-repo status:open';
+    element.changes = [
+      {
+        ...createChange(),
+        owner: {email: 'foo@bar' as EmailAddress},
+        project: 'test-repo' as RepoName,
+      },
+    ];
+    await element.updateComplete;
+    assert.equal(element.repo, 'test-repo' as RepoName);
+
+    element.query = 'foo bar baz';
+    element.changes = [
+      {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+    ];
+    await element.updateComplete;
+    assert.isNull(element.repo);
+  });
+
+  suite('query based navigation', () => {
+    setup(() => {});
+
+    teardown(async () => {
+      await element.updateComplete;
+      sinon.restore();
+    });
+
+    test('Searching for a change ID redirects to change', async () => {
+      const change = {...createChange(), _number: 1 as NumericChangeId};
+      sinon.stub(element, 'getChanges').returns(Promise.resolve([change]));
+      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();
+      });
+
+      element.params = {
+        view: GerritNav.View.SEARCH,
+        query: CHANGE_ID,
+        offset: '',
+      };
+      await promise;
+    });
+
+    test('Searching for a change num redirects to change', async () => {
+      const change = {...createChange(), _number: 1 as NumericChangeId};
+      sinon.stub(element, 'getChanges').returns(Promise.resolve([change]));
+      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();
+      });
+
+      element.params = {view: GerritNav.View.SEARCH, query: '1', offset: ''};
+      await promise;
+    });
+
+    test('Commit hash redirects to change', async () => {
+      const change = {...createChange(), _number: 1 as NumericChangeId};
+      sinon.stub(element, 'getChanges').returns(Promise.resolve([change]));
+      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();
+      });
+
+      element.params = {
+        view: GerritNav.View.SEARCH,
+        query: COMMIT_HASH,
+        offset: '',
+      };
+      await promise;
+    });
+
+    test('Searching for an invalid change ID searches', async () => {
+      sinon.stub(element, 'getChanges').returns(Promise.resolve([]));
+      const stub = sinon.stub(GerritNav, 'navigateToChange');
+
+      element.params = {
+        view: GerritNav.View.SEARCH,
+        query: CHANGE_ID,
+        offset: '',
+      };
+      await element.updateComplete;
+
+      assert.isFalse(stub.called);
+    });
+
+    test('Change ID with multiple search results searches', async () => {
+      sinon.stub(element, 'getChanges').returns(Promise.resolve(undefined));
+      const stub = sinon.stub(GerritNav, 'navigateToChange');
+
+      element.params = {
+        view: GerritNav.View.SEARCH,
+        query: CHANGE_ID,
+        offset: '',
+      };
+      await element.updateComplete;
+
+      assert.isFalse(stub.called);
+    });
+  });
+});
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 a79dd8e..2c10c1e 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
@@ -15,30 +15,15 @@
  * limitations under the License.
  */
 
-import '../../../styles/gr-change-list-styles';
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
 import '../gr-change-list-item/gr-change-list-item';
-import '../../../styles/shared-styles';
+import '../gr-change-list-section/gr-change-list-section';
+import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-list_html';
-import {appContext} from '../../../services/app-context';
-import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {
-  GerritNav,
-  DashboardSection,
-  YOUR_TURN,
-  CLOSED,
-} from '../../core/gr-navigation/gr-navigation';
+import {getAppContext} from '../../../services/app-context';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {isOwner} from '../../../utils/change-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {
   AccountInfo,
@@ -46,48 +31,77 @@
   ServerInfo,
   PreferencesInput,
 } from '../../../types/common';
-import {hasAttention} from '../../../utils/attention-set-util';
-import {fireEvent, fireReload} from '../../../utils/event-util';
+import {fire, fireEvent, fireReload} from '../../../utils/event-util';
 import {ScrollMode} from '../../../constants/constants';
-import {listen} from '../../../services/shortcuts/shortcuts-service';
-
-const NUMBER_FIXED_COLUMNS = 3;
-const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
-const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
-const MAX_SHORTCUT_CHARS = 5;
+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';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {ShortcutController} from '../../lit/shortcut-controller';
+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',
-  'Assignee',
   'Reviewers',
   'Comments',
   'Repo',
   'Branch',
   'Updated',
   'Size',
+  ' Status ', // spaces to differentiate from old 'Status'
 ];
 
 export interface ChangeListSection {
+  countLabel?: string;
+  emptyStateSlotName?: string;
   name?: string;
   query?: string;
   results: ChangeInfo[];
 }
 
-export interface GrChangeList {
-  $: {};
+/**
+ * Calculate the relative index of the currently selected change wrt to the
+ * section it belongs to.
+ * The 10th change in the overall list may be the 4th change in it's section
+ * so this method maps 10 to 4.
+ * selectedIndex contains the index of the change wrt the entire change list.
+ * Private but used in test
+ *
+ */
+export function computeRelativeIndex(
+  selectedIndex?: number,
+  sectionIndex?: number,
+  sections?: ChangeListSection[]
+) {
+  if (
+    selectedIndex === undefined ||
+    sectionIndex === undefined ||
+    sections === undefined
+  )
+    return;
+  for (let i = 0; i < sectionIndex; i++)
+    selectedIndex -= sections[i].results.length;
+  if (selectedIndex < 0) return; // selected change lies in previous sections
+
+  // the selectedIndex lies in the current section
+  if (selectedIndex < sections[sectionIndex].results.length)
+    return selectedIndex;
+  return; // selected change lies in future sections
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-change-list')
-export class GrChangeList extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrChangeList extends LitElement {
   /**
    * Fired when next page key shortcut was pressed.
    *
@@ -107,7 +121,7 @@
   @property({type: Object})
   account: AccountInfo | undefined = undefined;
 
-  @property({type: Array, observer: '_changesChanged'})
+  @property({type: Array})
   changes?: ChangeInfo[];
 
   /**
@@ -115,15 +129,11 @@
    * properties should not be used together.
    */
   @property({type: Array})
-  sections: ChangeListSection[] = [];
+  sections?: ChangeListSection[] = [];
 
-  @property({type: Array, computed: '_computeLabelNames(sections)'})
-  labelNames?: string[];
+  @state() private dynamicHeaderEndpoints?: string[];
 
-  @property({type: Array})
-  _dynamicHeaderEndpoints?: string[];
-
-  @property({type: Number, notify: true})
+  @property({type: Number, attribute: 'selected-index'})
   selectedIndex?: number;
 
   @property({type: Boolean})
@@ -147,27 +157,14 @@
   @property({type: Boolean})
   isCursorMoving = false;
 
-  @property({type: Object})
-  _config?: ServerInfo;
+  // private but used in test
+  @state() config?: ServerInfo;
 
-  private readonly flagsService = appContext.flagsService;
+  private readonly flagsService = getAppContext().flagsService;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  override keyboardShortcuts(): ShortcutListener[] {
-    return [
-      listen(Shortcut.CURSOR_NEXT_CHANGE, _ => this._nextChange()),
-      listen(Shortcut.CURSOR_PREV_CHANGE, _ => this._prevChange()),
-      listen(Shortcut.NEXT_PAGE, _ => this._nextPage()),
-      listen(Shortcut.PREV_PAGE, _ => this._prevPage()),
-      listen(Shortcut.OPEN_CHANGE, _ => this.openChange()),
-      listen(Shortcut.TOGGLE_CHANGE_REVIEWED, _ =>
-        this._toggleChangeReviewed()
-      ),
-      listen(Shortcut.TOGGLE_CHANGE_STAR, _ => this._toggleChangeStar()),
-      listen(Shortcut.REFRESH_CHANGE_LIST, _ => this._refreshChangeList()),
-    ];
-  }
+  private readonly shortcuts = new ShortcutController(this);
 
   private cursor = new GrCursorManager();
 
@@ -175,22 +172,33 @@
     super();
     this.cursor.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.cursor.focusOnMove = true;
-    this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
-  }
-
-  override ready() {
-    super.ready();
-    this.restApiService.getConfig().then(config => {
-      this._config = config;
-    });
+    this.shortcuts.addAbstract(Shortcut.CURSOR_NEXT_CHANGE, () =>
+      this.nextChange()
+    );
+    this.shortcuts.addAbstract(Shortcut.CURSOR_PREV_CHANGE, () =>
+      this.prevChange()
+    );
+    this.shortcuts.addAbstract(Shortcut.NEXT_PAGE, () => this.nextPage());
+    this.shortcuts.addAbstract(Shortcut.PREV_PAGE, () => this.prevPage());
+    this.shortcuts.addAbstract(Shortcut.OPEN_CHANGE, () => this.openChange());
+    this.shortcuts.addAbstract(Shortcut.TOGGLE_CHANGE_STAR, () =>
+      this.toggleChangeStar()
+    );
+    this.shortcuts.addAbstract(Shortcut.REFRESH_CHANGE_LIST, () =>
+      this.refreshChangeList()
+    );
+    addGlobalShortcut({key: Key.ENTER}, () => this.openChange());
   }
 
   override connectedCallback() {
     super.connectedCallback();
+    this.restApiService.getConfig().then(config => {
+      this.config = config;
+    });
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._dynamicHeaderEndpoints =
+        this.dynamicHeaderEndpoints =
           getPluginEndpoints().getDynamicEndpoints('change-list-header');
       });
   }
@@ -200,106 +208,148 @@
     super.disconnectedCallback();
   }
 
-  /**
-   * shortcut-service catches keyboard events globally. Some keyboard
-   * events must be scoped to a component level (e.g. `enter`) in order to not
-   * override native browser functionality.
-   *
-   * Context: Issue 7294
-   */
-  _scopedKeydownHandler(e: KeyboardEvent) {
-    if (e.keyCode === 13) {
-      // Enter.
-      this.openChange();
-    }
+  static override get styles() {
+    return [
+      changeListStyles,
+      fontStyles,
+      sharedStyles,
+      css`
+        #changeList {
+          border-collapse: collapse;
+          width: 100%;
+        }
+        .section-count-label {
+          color: var(--deemphasized-text-color);
+          font-family: var(--font-family);
+          font-size: var(--font-size-small);
+          font-weight: var(--font-weight-normal);
+          line-height: var(--line-height-small);
+        }
+        a.section-title:hover {
+          text-decoration: none;
+        }
+        a.section-title:hover .section-count-label {
+          text-decoration: none;
+        }
+        a.section-title:hover .section-name {
+          text-decoration: underline;
+        }
+      `,
+    ];
   }
 
-  _lowerCase(column: string) {
-    return column.toLowerCase();
+  override render() {
+    if (!this.sections) return;
+    const labelNames = this.computeLabelNames(this.sections);
+    return html`
+      <table id="changeList">
+        ${this.sections.map((changeSection, sectionIndex) =>
+          this.renderSection(changeSection, sectionIndex, labelNames)
+        )}
+      </table>
+    `;
   }
 
-  @observe('account', 'preferences', '_config')
-  _computePreferences(
-    account?: AccountInfo,
-    preferences?: PreferencesInput,
-    config?: ServerInfo
+  private renderSection(
+    changeSection: ChangeListSection,
+    sectionIndex: number,
+    labelNames: string[]
   ) {
-    if (!config) {
-      return;
+    return html`
+      <gr-change-list-section
+        .changeSection=${changeSection}
+        .labelNames=${labelNames}
+        .dynamicHeaderEndpoints=${this.dynamicHeaderEndpoints}
+        .isCursorMoving=${this.isCursorMoving}
+        .config=${this.config}
+        .account=${this.account}
+        .selectedIndex=${computeRelativeIndex(
+          this.selectedIndex,
+          sectionIndex,
+          this.sections
+        )}
+        ?showStar=${this.showStar}
+        .showNumber=${this.showNumber}
+        .visibleChangeTableColumns=${this.visibleChangeTableColumns}
+      >
+        ${changeSection.emptyStateSlotName
+          ? html`<slot
+              slot=${changeSection.emptyStateSlotName}
+              name=${changeSection.emptyStateSlotName}
+            ></slot>`
+          : nothing}
+      </gr-change-list-section>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (
+      changedProperties.has('account') ||
+      changedProperties.has('preferences') ||
+      changedProperties.has('config') ||
+      changedProperties.has('sections')
+    ) {
+      this.computePreferences();
     }
 
+    if (changedProperties.has('changes')) {
+      this.changesChanged();
+    }
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('sections')) {
+      this.sectionsChanged();
+    }
+  }
+
+  private computePreferences() {
+    if (!this.config) return;
+
     this.changeTableColumns = columnNames;
     this.showNumber = false;
     this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
-      this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
+      this._isColumnEnabled(col, this.config)
     );
-    if (account && preferences) {
-      this.showNumber = !!(
-        preferences && preferences.legacycid_in_change_table
-      );
-      if (preferences.change_table && preferences.change_table.length > 0) {
-        const prefColumns = preferences.change_table.map(column =>
+    if (this.account && this.preferences) {
+      this.showNumber = !!this.preferences?.legacycid_in_change_table;
+      if (
+        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,
-            config,
-            this.flagsService.enabledExperiments
-          )
+          this._isColumnEnabled(col, this.config)
         );
       }
     }
   }
 
   /**
-   * Is the column disabled by a server config or experiment? For example the
-   * assignee feature might be disabled and thus the corresponding column is
-   * also disabled.
-   *
+   * Is the column disabled by a server config or experiment?
    */
-  _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
+  _isColumnEnabled(column: string, config?: ServerInfo) {
+    if (!columnNames.includes(column)) return false;
     if (!config || !config.change) return true;
-    if (column === 'Assignee') return !!config.change.enable_assignee;
-    if (column === 'Comments') return experiments.includes('comments-column');
+    if (column === '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
+      );
     return true;
   }
 
-  /**
-   * This methods allows us to customize the columns per section.
-   *
-   * @param visibleColumns are the columns according to configs and user prefs
-   */
-  _computeColumns(
-    section?: ChangeListSection,
-    visibleColumns?: string[]
-  ): string[] {
-    if (!section || !visibleColumns) return [];
-    const cols = [...visibleColumns];
-    const updatedIndex = cols.indexOf('Updated');
-    if (section.name === YOUR_TURN.name && updatedIndex !== -1) {
-      cols[updatedIndex] = 'Waiting';
-    }
-    if (section.name === CLOSED.name && updatedIndex !== -1) {
-      cols[updatedIndex] = 'Submitted';
-    }
-    return cols;
-  }
-
-  _computeColspan(
-    section?: ChangeListSection,
-    visibleColumns?: string[],
-    labelNames?: string[]
-  ) {
-    const cols = this._computeColumns(section, visibleColumns);
-    if (!cols || !labelNames) return 1;
-    return cols.length + labelNames.length + NUMBER_FIXED_COLUMNS;
-  }
-
-  _computeLabelNames(sections: ChangeListSection[]) {
-    if (!sections) {
-      return [];
-    }
+  // private but used in test
+  computeLabelNames(sections: ChangeListSection[]) {
+    if (!sections) return [];
     let labels: string[] = [];
     const nonExistingLabel = function (item: string) {
       return !labels.includes(item);
@@ -316,149 +366,65 @@
         labels = labels.concat(currentLabels.filter(nonExistingLabel));
       }
     }
+
+    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);
+      }
+    }
     return labels.sort();
   }
 
-  _computeLabelShortcut(labelName: string) {
-    if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
-      labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
-    }
-    return labelName
-      .split('-')
-      .reduce((a, i) => {
-        if (!i) {
-          return a;
-        }
-        return a + i[0].toUpperCase();
-      }, '')
-      .slice(0, MAX_SHORTCUT_CHARS);
+  private changesChanged() {
+    this.sections = this.changes ? [{results: this.changes}] : [];
   }
 
-  _changesChanged(changes: ChangeInfo[]) {
-    this.sections = changes ? [{results: changes}] : [];
-  }
-
-  _processQuery(query: string) {
-    let tokens = query.split(' ');
-    const invalidTokens = ['limit:', 'age:', '-age:'];
-    tokens = tokens.filter(
-      token =>
-        !invalidTokens.some(invalidToken => token.startsWith(invalidToken))
-    );
-    return tokens.join(' ');
-  }
-
-  _sectionHref(query: string) {
-    return GerritNav.getUrlForSearchQuery(this._processQuery(query));
-  }
-
-  /**
-   * Maps an index local to a particular section to the absolute index
-   * across all the changes on the page.
-   *
-   * @param sectionIndex index of section
-   * @param localIndex index of row within section
-   * @return absolute index of row in the aggregate dashboard
-   */
-  _computeItemAbsoluteIndex(sectionIndex: number, localIndex: number) {
-    let idx = 0;
-    for (let i = 0; i < sectionIndex; i++) {
-      idx += this.sections[i].results.length;
-    }
-    return idx + localIndex;
-  }
-
-  _computeItemSelected(
-    sectionIndex: number,
-    index: number,
-    selectedIndex: number
-  ) {
-    const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
-    return idx === selectedIndex;
-  }
-
-  _computeTabIndex(
-    sectionIndex: number,
-    index: number,
-    selectedIndex: number,
-    isCursorMoving: boolean
-  ) {
-    if (isCursorMoving) return 0;
-    return this._computeItemSelected(sectionIndex, index, selectedIndex)
-      ? 0
-      : undefined;
-  }
-
-  _computeItemHighlight(
-    account?: AccountInfo,
-    change?: ChangeInfo,
-    sectionName?: string
-  ) {
-    if (!change || !account) return false;
-    if (CLOSED_STATUS.indexOf(change.status) !== -1) return false;
-    return (
-      hasAttention(account, change) &&
-      !isOwner(change, account) &&
-      sectionName === YOUR_TURN.name
-    );
-  }
-
-  _nextChange() {
+  private nextChange() {
     this.isCursorMoving = true;
     this.cursor.next();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
+    fire(this, 'selected-index-changed', {value: this.cursor.index});
   }
 
-  _prevChange() {
+  private prevChange() {
     this.isCursorMoving = true;
     this.cursor.previous();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
+    fire(this, 'selected-index-changed', {value: this.cursor.index});
   }
 
-  openChange() {
-    const change = this._changeForIndex(this.selectedIndex);
+  private async openChange() {
+    const change = await this.changeForIndex(this.selectedIndex);
     if (change) GerritNav.navigateToChange(change);
   }
 
-  _nextPage() {
+  private nextPage() {
     fireEvent(this, 'next-page');
   }
 
-  _prevPage() {
-    this.dispatchEvent(
-      new CustomEvent('previous-page', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+  private prevPage() {
+    fireEvent(this, 'previous-page');
   }
 
-  _toggleChangeReviewed() {
-    this._toggleReviewedForIndex(this.selectedIndex);
-  }
-
-  _toggleReviewedForIndex(index?: number) {
-    const changeEls = this._getListItems();
-    if (index === undefined || index >= changeEls.length || !changeEls[index]) {
-      return;
-    }
-
-    const changeEl = changeEls[index];
-    changeEl.toggleReviewed();
-  }
-
-  _refreshChangeList() {
+  private refreshChangeList() {
     fireReload(this);
   }
 
-  _toggleChangeStar() {
-    this._toggleStarForIndex(this.selectedIndex);
+  private toggleChangeStar() {
+    this.toggleStarForIndex(this.selectedIndex);
   }
 
-  _toggleStarForIndex(index?: number) {
-    const changeEls = this._getListItems();
+  private async toggleStarForIndex(index?: number) {
+    const changeEls = await this.getListItems();
     if (index === undefined || index >= changeEls.length || !changeEls[index]) {
       return;
     }
@@ -468,46 +434,47 @@
     if (grChangeStar) grChangeStar.toggleStar();
   }
 
-  _changeForIndex(index?: number) {
-    const changeEls = this._getListItems();
+  private async changeForIndex(index?: number) {
+    const changeEls = await this.getListItems();
     if (index !== undefined && index < changeEls.length && changeEls[index]) {
       return changeEls[index].change;
     }
     return null;
   }
 
-  _getListItems() {
-    const items = this.root?.querySelectorAll('gr-change-list-item');
-    return !items ? [] : Array.from(items);
+  // Private but used in tests
+  async getListItems() {
+    const items: GrChangeListItem[] = [];
+    const sections = queryAll<GrChangeListSection>(
+      this,
+      'gr-change-list-section'
+    );
+    await Promise.all(Array.from(sections).map(s => s.updateComplete));
+    for (const section of sections) {
+      // getListItems() is triggered when sectionsChanged observer is triggered
+      // In some cases <gr-change-list-item> has not been attached to the DOM
+      // yet and hence queryAll returns []
+      // Once the items have been attached, sectionsChanged() is not called
+      // again and the cursor stops are not updated to have the correct value
+      // hence wait for section to render before querying for items
+      const res = queryAll<GrChangeListItem>(section, 'gr-change-list-item');
+      items.push(...res);
+    }
+    return items;
   }
 
-  @observe('sections.*')
-  _sectionsChanged() {
-    // Flush DOM operations so that the list item elements will be loaded.
-    afterNextRender(this, () => {
-      this.cursor.stops = this._getListItems();
-      this.cursor.moveToStart();
-      if (this.selectedIndex) this.cursor.setCursorAtIndex(this.selectedIndex);
-    });
-  }
-
-  _getSpecialEmptySlot(section: DashboardSection) {
-    if (section.isOutgoing) return 'empty-outgoing';
-    if (section.name === YOUR_TURN.name) return 'empty-your-turn';
-    return '';
-  }
-
-  _isEmpty(section: DashboardSection) {
-    return !section.results?.length;
-  }
-
-  _computeAriaLabel(change?: ChangeInfo, sectionName?: string) {
-    if (!change) return '';
-    return change.subject + (sectionName ? `, section: ${sectionName}` : '');
+  // Private but used in tests
+  async sectionsChanged() {
+    this.cursor.stops = await this.getListItems();
+    this.cursor.moveToStart();
+    if (this.selectedIndex) this.cursor.setCursorAtIndex(this.selectedIndex);
   }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'selected-index-changed': ValueChangedEvent<number>;
+  }
   interface HTMLElementTagNameMap {
     'gr-change-list': GrChangeList;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
deleted file mode 100644
index c31da77..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ /dev/null
@@ -1,162 +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-change-list-styles">
-    #changeList {
-      border-collapse: collapse;
-      width: 100%;
-    }
-    .section-count-label {
-      color: var(--deemphasized-text-color);
-      font-family: var(--font-family);
-      font-size: var(--font-size-small);
-      font-weight: var(--font-weight-normal);
-      line-height: var(--line-height-small);
-    }
-    a.section-title:hover {
-      text-decoration: none;
-    }
-    a.section-title:hover .section-count-label {
-      text-decoration: none;
-    }
-    a.section-title:hover .section-name {
-      text-decoration: underline;
-    }
-  </style>
-  <table id="changeList">
-    <template
-      is="dom-repeat"
-      items="[[sections]]"
-      as="changeSection"
-      index-as="sectionIndex"
-    >
-      <template is="dom-if" if="[[changeSection.name]]">
-        <tbody>
-          <tr class="groupHeader">
-            <td aria-hidden="true" class="leftPadding"></td>
-            <td
-              aria-hidden="true"
-              class="star"
-              hidden$="[[!showStar]]"
-              hidden=""
-            ></td>
-            <td
-              class="cell"
-              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
-            >
-              <h2 class="heading-3">
-                <a
-                  href$="[[_sectionHref(changeSection.query)]]"
-                  class="section-title"
-                >
-                  <span class="section-name">[[changeSection.name]]</span>
-                  <span class="section-count-label"
-                    >[[changeSection.countLabel]]</span
-                  >
-                </a>
-              </h2>
-            </td>
-          </tr>
-        </tbody>
-      </template>
-      <tbody class="groupContent">
-        <template is="dom-if" if="[[_isEmpty(changeSection)]]">
-          <tr class="noChanges">
-            <td aria-hidden="true" class="leftPadding"></td>
-            <td
-              aria-hidden="[[!showStar]]"
-              class="star"
-              hidden$="[[!showStar]]"
-            ></td>
-            <td
-              class="cell"
-              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
-            >
-              <template
-                is="dom-if"
-                if="[[_getSpecialEmptySlot(changeSection)]]"
-              >
-                <slot name="[[_getSpecialEmptySlot(changeSection)]]"></slot>
-              </template>
-              <template
-                is="dom-if"
-                if="[[!_getSpecialEmptySlot(changeSection)]]"
-              >
-                No changes
-              </template>
-            </td>
-          </tr>
-        </template>
-        <template is="dom-if" if="[[!_isEmpty(changeSection)]]">
-          <tr class="groupTitle">
-            <td aria-hidden="true" class="leftPadding"></td>
-            <td
-              aria-label="Star status column"
-              class="star"
-              hidden$="[[!showStar]]"
-              hidden=""
-            ></td>
-            <td class="number" hidden$="[[!showNumber]]" hidden="">#</td>
-            <template
-              is="dom-repeat"
-              items="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
-              as="item"
-            >
-              <td class$="[[_lowerCase(item)]]">[[item]]</td>
-            </template>
-            <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-              <td class="label" title$="[[labelName]]">
-                [[_computeLabelShortcut(labelName)]]
-              </td>
-            </template>
-            <template
-              is="dom-repeat"
-              items="[[_dynamicHeaderEndpoints]]"
-              as="pluginHeader"
-            >
-              <td class="endpoint">
-                <gr-endpoint-decorator name$="[[pluginHeader]]">
-                </gr-endpoint-decorator>
-              </td>
-            </template>
-          </tr>
-        </template>
-        <template is="dom-repeat" items="[[changeSection.results]]" as="change">
-          <gr-change-list-item
-            account="[[account]]"
-            selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
-            highlight$="[[_computeItemHighlight(account, change, changeSection.name)]]"
-            change="[[change]]"
-            config="[[_config]]"
-            section-name="[[changeSection.name]]"
-            visible-change-table-columns="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
-            show-number="[[showNumber]]"
-            show-star="[[showStar]]"
-            tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex, isCursorMoving)]]"
-            label-names="[[labelNames]]"
-            aria-label$="[[_computeAriaLabel(change, changeSection.name)]]"
-          ></gr-change-list-item>
-        </template>
-      </tbody>
-    </template>
-  </table>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
deleted file mode 100644
index de1a0a2..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ /dev/null
@@ -1,517 +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 './gr-change-list.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {mockPromise} from '../../../test/test-utils.js';
-import {YOUR_TURN} from '../../core/gr-navigation/gr-navigation.js';
-
-const basicFixture = fixtureFromElement('gr-change-list');
-
-suite('gr-change-list basic tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('test show change number not logged in', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.account = undefined;
-      element.preferences = undefined;
-      element._config = {};
-    });
-
-    test('show number disabled', () => {
-      assert.isFalse(element.showNumber);
-    });
-  });
-
-  suite('test show change number preference enabled', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [],
-      };
-      element.account = {_account_id: 1001};
-      element._config = {};
-      flush();
-    });
-
-    test('show number enabled', () => {
-      assert.isTrue(element.showNumber);
-    });
-  });
-
-  suite('test show change number preference disabled', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      // legacycid_in_change_table is not set when false.
-      element.preferences = {
-        time_format: 'HHMM_12',
-        change_table: [],
-      };
-      element.account = {_account_id: 1001};
-      element._config = {};
-      flush();
-    });
-
-    test('show number disabled', () => {
-      assert.isFalse(element.showNumber);
-    });
-  });
-
-  test('computed fields', () => {
-    assert.equal(element._computeLabelNames(
-        [{results: [{_number: 0, labels: {}}]}]).length, 0);
-    assert.equal(element._computeLabelNames([
-      {results: [
-        {_number: 0, labels: {Verified: {approved: {}}}},
-        {
-          _number: 1,
-          labels: {
-            'Verified': {approved: {}},
-            'Code-Review': {approved: {}},
-          },
-        },
-        {
-          _number: 2,
-          labels: {
-            'Verified': {approved: {}},
-            'Library-Compliance': {approved: {}},
-          },
-        },
-      ]},
-    ]).length, 3);
-
-    assert.equal(element._computeLabelShortcut('Code-Review'), 'CR');
-    assert.equal(element._computeLabelShortcut('Verified'), 'V');
-    assert.equal(element._computeLabelShortcut('Library-Compliance'), 'LC');
-    assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
-    assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
-    assert.equal(element._computeLabelShortcut(
-        'Invalid-Prolog-Rules-Label-Name--Verified'), 'V');
-    assert.equal(element._computeLabelShortcut(
-        'Some-Special-Label-7'), 'SSL7');
-    assert.equal(element._computeLabelShortcut('--Too----many----dashes---'),
-        'TMD');
-    assert.equal(element._computeLabelShortcut(
-        'Really-rather-entirely-too-long-of-a-label-name'), 'RRETL');
-  });
-
-  test('colspans', () => {
-    element.sections = [
-      {results: [{}]},
-    ];
-    flush();
-    const tdItemCount = element.root.querySelectorAll(
-        'td').length;
-
-    const changeTableColumns = [];
-    const labelNames = [];
-    assert.equal(tdItemCount, element._computeColspan(
-        {}, changeTableColumns, labelNames));
-  });
-
-  test('keyboard shortcuts', async () => {
-    sinon.stub(element, '_computeLabelNames');
-    element.sections = [
-      {results: new Array(1)},
-      {results: new Array(2)},
-    ];
-    element.selectedIndex = 0;
-    element.changes = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    await flush();
-    const promise = mockPromise();
-    afterNextRender(element, () => {
-      promise.resolve();
-    });
-    await promise;
-    const elementItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(elementItems.length, 3);
-
-    assert.isTrue(elementItems[0].hasAttribute('selected'));
-    MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-    assert.equal(element.selectedIndex, 1);
-    assert.isTrue(elementItems[1].hasAttribute('selected'));
-    MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-    assert.equal(element.selectedIndex, 2);
-    assert.isTrue(elementItems[2].hasAttribute('selected'));
-
-    const navStub = sinon.stub(GerritNav, 'navigateToChange');
-    assert.equal(element.selectedIndex, 2);
-    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-    assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-        'Should navigate to /c/2/');
-
-    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-    assert.equal(element.selectedIndex, 1);
-    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-    assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-        'Should navigate to /c/1/');
-
-    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-    assert.equal(element.selectedIndex, 0);
-  });
-
-  test('no changes', () => {
-    element.changes = [];
-    flush();
-    const listItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(listItems.length, 0);
-    const noChangesMsg =
-        element.root.querySelector('.noChanges');
-    assert.ok(noChangesMsg);
-  });
-
-  test('empty sections', () => {
-    element.sections = [{results: []}, {results: []}];
-    flush();
-    const listItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(listItems.length, 0);
-    const noChangesMsg = element.root.querySelectorAll(
-        '.noChanges');
-    assert.equal(noChangesMsg.length, 2);
-  });
-
-  suite('empty section', () => {
-    test('not shown on empty non-outgoing sections', () => {
-      const section = {results: []};
-      assert.isTrue(element._isEmpty(section));
-      assert.equal(element._getSpecialEmptySlot(section), '');
-    });
-
-    test('shown on empty outgoing sections', () => {
-      const section = {results: [], isOutgoing: true};
-      assert.isTrue(element._isEmpty(section));
-      assert.equal(element._getSpecialEmptySlot(section), 'empty-outgoing');
-    });
-
-    test('shown on empty outgoing sections', () => {
-      const section = {results: [], name: YOUR_TURN.name};
-      assert.isTrue(element._isEmpty(section));
-      assert.equal(element._getSpecialEmptySlot(section), 'empty-your-turn');
-    });
-
-    test('not shown on non-empty outgoing sections', () => {
-      const section = {isOutgoing: true, results: [
-        {_number: 0, labels: {Verified: {approved: {}}}}]};
-      assert.isFalse(element._isEmpty(section));
-    });
-  });
-
-  suite('empty column preference', () => {
-    let element;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.sections = [
-        {results: [{}]},
-      ];
-      element.account = {_account_id: 1001};
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [],
-      };
-      element._config = {};
-      flush();
-    });
-
-    test('show number enabled', () => {
-      assert.isTrue(element.showNumber);
-    });
-
-    test('all columns visible', () => {
-      for (const column of element.changeTableColumns) {
-        const elementClass = '.' + element._lowerCase(column);
-        assert.isFalse(element.shadowRoot
-            .querySelector(elementClass).hidden);
-      }
-    });
-  });
-
-  suite('full column preference', () => {
-    let element;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.sections = [
-        {results: [{}]},
-      ];
-      element.account = {_account_id: 1001};
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [
-          'Subject',
-          'Status',
-          'Owner',
-          'Assignee',
-          'Reviewers',
-          'Comments',
-          'Repo',
-          'Branch',
-          'Updated',
-          'Size',
-        ],
-      };
-      element._config = {};
-      flush();
-    });
-
-    test('all columns visible', () => {
-      for (const column of element.changeTableColumns) {
-        const elementClass = '.' + element._lowerCase(column);
-        assert.isFalse(element.shadowRoot
-            .querySelector(elementClass).hidden);
-      }
-    });
-  });
-
-  suite('partial column preference', () => {
-    let element;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.sections = [
-        {results: [{}]},
-      ];
-      element.account = {_account_id: 1001};
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [
-          'Subject',
-          'Status',
-          'Owner',
-          'Assignee',
-          'Reviewers',
-          'Comments',
-          'Branch',
-          'Updated',
-          'Size',
-        ],
-      };
-      element._config = {};
-      flush();
-    });
-
-    test('all columns except repo visible', () => {
-      for (const column of element.changeTableColumns) {
-        const elementClass = '.' + column.toLowerCase();
-        if (column === 'Repo') {
-          assert.isNotOk(element.shadowRoot.querySelector(elementClass));
-        } else {
-          assert.isOk(element.shadowRoot.querySelector(elementClass));
-        }
-      }
-    });
-  });
-
-  suite('random column does not exist', () => {
-    let element;
-
-    /* This would only exist if somebody manually updated the config
-    file. */
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.account = {_account_id: 1001};
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [
-          'Bad',
-        ],
-      };
-      flush();
-    });
-
-    test('bad column does not exist', () => {
-      const elementClass = '.bad';
-      assert.isNotOk(element.shadowRoot
-          .querySelector(elementClass));
-    });
-  });
-
-  suite('dashboard queries', () => {
-    let element;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-    });
-
-    teardown(() => { sinon.restore(); });
-
-    test('query without age and limit unchanged', () => {
-      const query = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), query);
-    });
-
-    test('query with age and limit', () => {
-      const query = 'status:closed age:1week limit:10 owner:me';
-      const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with age', () => {
-      const query = 'status:closed age:1week owner:me';
-      const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with limit', () => {
-      const query = 'status:closed limit:10 owner:me';
-      const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with age as value and not key', () => {
-      const query = 'status:closed random:age';
-      const expectedQuery = 'status:closed random:age';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with limit as value and not key', () => {
-      const query = 'status:closed random:limit';
-      const expectedQuery = 'status:closed random:limit';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with -age key', () => {
-      const query = 'status:closed -age:1week';
-      const expectedQuery = 'status:closed';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-  });
-
-  suite('gr-change-list sections', () => {
-    let element;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-    });
-
-    test('keyboard shortcuts', async () => {
-      element.selectedIndex = 0;
-      element.sections = [
-        {
-          results: [
-            {_number: 0},
-            {_number: 1},
-            {_number: 2},
-          ],
-        },
-        {
-          results: [
-            {_number: 3},
-            {_number: 4},
-            {_number: 5},
-          ],
-        },
-        {
-          results: [
-            {_number: 6},
-            {_number: 7},
-            {_number: 8},
-          ],
-        },
-      ];
-      await flush();
-      const promise = mockPromise();
-      afterNextRender(element, () => {
-        promise.resolve();
-      });
-      await promise;
-      const elementItems = element.root.querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 9);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-
-      const navStub = sinon.stub(GerritNav, 'navigateToChange');
-      assert.equal(element.selectedIndex, 2);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-          'Should navigate to /c/2/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-          'Should navigate to /c/1/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert.equal(element.selectedIndex, 4);
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
-          'Should navigate to /c/4/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-      const change = element._changeForIndex(element.selectedIndex);
-      assert.equal(change.reviewed, true,
-          'Should mark change as reviewed');
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-      assert.equal(change.reviewed, false,
-          'Should mark change as unreviewed');
-    });
-
-    test('_computeItemHighlight gives false for null account', () => {
-      assert.isFalse(
-          element._computeItemHighlight(null, {assignee: {_account_id: 42}}));
-    });
-
-    test('_computeItemAbsoluteIndex', () => {
-      sinon.stub(element, '_computeLabelNames');
-      element.sections = [
-        {results: new Array(1)},
-        {results: new Array(2)},
-        {results: new Array(3)},
-      ];
-
-      assert.equal(element._computeItemAbsoluteIndex(0, 0), 0);
-      // Out of range but no matter.
-      assert.equal(element._computeItemAbsoluteIndex(0, 1), 1);
-
-      assert.equal(element._computeItemAbsoluteIndex(1, 0), 1);
-      assert.equal(element._computeItemAbsoluteIndex(1, 1), 2);
-      assert.equal(element._computeItemAbsoluteIndex(1, 2), 3);
-      assert.equal(element._computeItemAbsoluteIndex(2, 0), 3);
-      assert.equal(element._computeItemAbsoluteIndex(3, 0), 6);
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..365ba72
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -0,0 +1,495 @@
+/**
+ * @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 './gr-change-list';
+import {GrChangeList, computeRelativeIndex} from './gr-change-list';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  pressKey,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubFlags,
+  waitUntil,
+} from '../../../test/test-utils';
+import {Key} from '../../../utils/dom-util';
+import {TimeFormat} from '../../../constants/constants';
+import {AccountId, NumericChangeId} from '../../../types/common';
+import {
+  createChange,
+  createServerInfo,
+} 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';
+
+const basicFixture = fixtureFromElement('gr-change-list');
+
+suite('gr-change-list basic tests', () => {
+  let element: GrChangeList;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('renders', async () => {
+    element.preferences = {
+      legacycid_in_change_table: true,
+      time_format: TimeFormat.HHMM_12,
+      change_table: [],
+    };
+    element.account = {_account_id: 1001 as AccountId};
+    element.config = createServerInfo();
+    element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+    element.selectedIndex = 0;
+    element.changes = [
+      {...createChange(), _number: 0 as NumericChangeId},
+      {...createChange(), _number: 1 as NumericChangeId},
+      {...createChange(), _number: 2 as NumericChangeId},
+    ];
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-change-list-section> </gr-change-list-section>
+      <table id="changeList"></table>
+    `);
+  });
+
+  suite('test show change number not logged in', () => {
+    setup(async () => {
+      element = basicFixture.instantiate();
+      element.account = undefined;
+      element.preferences = undefined;
+      element.config = createServerInfo();
+      await element.updateComplete;
+    });
+
+    test('show number disabled', () => {
+      assert.isFalse(element.showNumber);
+    });
+  });
+
+  suite('test show change number preference enabled', () => {
+    setup(async () => {
+      element = basicFixture.instantiate();
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: TimeFormat.HHMM_12,
+        change_table: [],
+      };
+      element.account = {_account_id: 1001 as AccountId};
+      element.config = createServerInfo();
+      await element.updateComplete;
+    });
+
+    test('show number enabled', () => {
+      assert.isTrue(element.showNumber);
+    });
+  });
+
+  suite('test show change number preference disabled', () => {
+    setup(async () => {
+      element = basicFixture.instantiate();
+      // legacycid_in_change_table is not set when false.
+      element.preferences = {
+        time_format: TimeFormat.HHMM_12,
+        change_table: [],
+      };
+      element.account = {_account_id: 1001 as AccountId};
+      element.config = createServerInfo();
+      await element.updateComplete;
+    });
+
+    test('show number disabled', () => {
+      assert.isFalse(element.showNumber);
+    });
+  });
+
+  test('computeRelativeIndex', () => {
+    element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+
+    let selectedChangeIndex = 0;
+    assert.equal(
+      computeRelativeIndex(selectedChangeIndex, 0, element.sections),
+      0
+    );
+
+    // index lies outside the first section
+    assert.equal(
+      computeRelativeIndex(selectedChangeIndex, 1, element.sections),
+      undefined
+    );
+
+    selectedChangeIndex = 2;
+
+    // index lies outside the first section
+    assert.equal(
+      computeRelativeIndex(selectedChangeIndex, 0, element.sections),
+      undefined
+    );
+
+    // 3rd change belongs to the second section
+    assert.equal(
+      computeRelativeIndex(selectedChangeIndex, 1, element.sections),
+      1
+    );
+  });
+
+  test('computed fields', () => {
+    assert.equal(
+      element.computeLabelNames([
+        {
+          results: [
+            {...createChange(), _number: 0 as NumericChangeId, labels: {}},
+          ],
+        },
+      ]).length,
+      0
+    );
+    assert.equal(
+      element.computeLabelNames([
+        {
+          results: [
+            {
+              ...createChange(),
+              _number: 0 as NumericChangeId,
+              labels: {Verified: {approved: {}}},
+            },
+            {
+              ...createChange(),
+              _number: 1 as NumericChangeId,
+              labels: {
+                Verified: {approved: {}},
+                'Code-Review': {approved: {}},
+              },
+            },
+            {
+              ...createChange(),
+              _number: 2 as NumericChangeId,
+              labels: {
+                Verified: {approved: {}},
+                'Library-Compliance': {approved: {}},
+              },
+            },
+          ],
+        },
+      ]).length,
+      3
+    );
+  });
+
+  test('keyboard shortcuts', async () => {
+    sinon.stub(element, 'computeLabelNames');
+    element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+    element.selectedIndex = 0;
+    element.preferences = {
+      legacycid_in_change_table: true,
+      time_format: TimeFormat.HHMM_12,
+      change_table: [
+        'Subject',
+        'Status',
+        'Owner',
+        'Reviewers',
+        'Comments',
+        'Repo',
+        'Branch',
+        'Updated',
+        'Size',
+        ' Status ',
+      ],
+    };
+    element.config = createServerInfo();
+    element.changes = [
+      {...createChange(), _number: 0 as NumericChangeId},
+      {...createChange(), _number: 1 as NumericChangeId},
+      {...createChange(), _number: 2 as NumericChangeId},
+    ];
+    // explicitly trigger sectionsChanged so that cursor stops are properly
+    // updated
+    await element.sectionsChanged();
+    await element.updateComplete;
+    const section = queryAndAssert<GrChangeListSection>(
+      element,
+      'gr-change-list-section'
+    );
+    await section.updateComplete;
+    const elementItems = queryAll<GrChangeListItem>(
+      section,
+      'gr-change-list-item'
+    );
+    assert.equal(elementItems.length, 3);
+
+    assert.isTrue(elementItems[0].hasAttribute('selected'));
+    await element.updateComplete;
+    pressKey(element, 'j');
+    await element.updateComplete;
+    await section.updateComplete;
+
+    assert.equal(element.selectedIndex, 1);
+    assert.isTrue(elementItems[1].hasAttribute('selected'));
+    pressKey(element, 'j');
+    await element.updateComplete;
+    assert.equal(element.selectedIndex, 2);
+    assert.isTrue(elementItems[2].hasAttribute('selected'));
+
+    const navStub = sinon.stub(GerritNav, 'navigateToChange');
+    assert.equal(element.selectedIndex, 2);
+    pressKey(element, Key.ENTER);
+    await waitUntil(() => navStub.callCount > 1);
+    await element.updateComplete;
+    assert.deepEqual(
+      navStub.lastCall.args[0],
+      {...createChange(), _number: 2 as NumericChangeId},
+      'Should navigate to /c/2/'
+    );
+
+    pressKey(element, 'k');
+    await element.updateComplete;
+    await section.updateComplete;
+
+    assert.equal(element.selectedIndex, 1);
+
+    const prevCount = navStub.callCount;
+    pressKey(element, Key.ENTER);
+
+    await waitUntil(() => navStub.callCount > prevCount);
+    assert.deepEqual(
+      navStub.lastCall.args[0],
+      {...createChange(), _number: 1 as NumericChangeId},
+      'Should navigate to /c/1/'
+    );
+
+    pressKey(element, 'k');
+    pressKey(element, 'k');
+    pressKey(element, 'k');
+    assert.equal(element.selectedIndex, 0);
+  });
+
+  test('no changes', async () => {
+    element.changes = [];
+    await element.updateComplete;
+    const listItems = queryAll<GrChangeListItem>(
+      element,
+      'gr-change-list-item'
+    );
+    assert.equal(listItems.length, 0);
+    const section = queryAndAssert(element, 'gr-change-list-section');
+    const noChangesMsg = queryAndAssert<HTMLTableRowElement>(
+      section,
+      '.noChanges'
+    );
+    assert.ok(noChangesMsg);
+  });
+
+  test('empty sections', async () => {
+    element.sections = [{results: []}, {results: []}];
+    await element.updateComplete;
+    const listItems = queryAll<GrChangeListItem>(
+      element,
+      'gr-change-list-item'
+    );
+    assert.equal(listItems.length, 0);
+    const sections = queryAll<GrChangeListSection>(
+      element,
+      'gr-change-list-section'
+    );
+    sections.forEach(section => {
+      assert.isOk(query(section, '.noChanges'));
+    });
+  });
+
+  suite('empty column preference', () => {
+    let element: GrChangeList;
+
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      element = basicFixture.instantiate();
+      element.sections = [{results: [{...createChange()}]}];
+      element.account = {_account_id: 1001 as AccountId};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: TimeFormat.HHMM_12,
+        change_table: [],
+      };
+      element.config = createServerInfo();
+      await element.updateComplete;
+    });
+
+    test('show number enabled', () => {
+      assert.isTrue(element.showNumber);
+    });
+
+    test('all columns visible', () => {
+      for (const column of element.changeTableColumns!) {
+        const elementClass = '.' + column.trim().toLowerCase();
+        const section = queryAndAssert(element, 'gr-change-list-section');
+        assert.isFalse(
+          queryAndAssert<HTMLElement>(section, elementClass)!.hidden
+        );
+      }
+    });
+  });
+
+  suite('full column preference', () => {
+    let element: GrChangeList;
+
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      element = basicFixture.instantiate();
+      element.sections = [{results: [{...createChange()}]}];
+      element.account = {_account_id: 1001 as AccountId};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: TimeFormat.HHMM_12,
+        change_table: [
+          'Subject',
+          'Status',
+          'Owner',
+          'Reviewers',
+          'Comments',
+          'Repo',
+          'Branch',
+          'Updated',
+          'Size',
+          ' Status ',
+        ],
+      };
+      element.config = createServerInfo();
+      await element.updateComplete;
+    });
+
+    test('all columns visible', () => {
+      for (const column of element.changeTableColumns!) {
+        const elementClass = '.' + column.trim().toLowerCase();
+        const section = queryAndAssert(element, 'gr-change-list-section');
+        assert.isFalse(
+          queryAndAssert<HTMLElement>(section, elementClass).hidden
+        );
+      }
+    });
+  });
+
+  suite('partial column preference', () => {
+    let element: GrChangeList;
+
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      element = basicFixture.instantiate();
+      element.sections = [{results: [{...createChange()}]}];
+      element.account = {_account_id: 1001 as AccountId};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: TimeFormat.HHMM_12,
+        change_table: [
+          'Subject',
+          'Status',
+          'Owner',
+          'Reviewers',
+          'Comments',
+          'Branch',
+          'Updated',
+          'Size',
+          ' Status ',
+        ],
+      };
+      element.config = createServerInfo();
+      await element.updateComplete;
+    });
+
+    test('all columns except repo visible', () => {
+      for (const column of element.changeTableColumns!) {
+        const elementClass = '.' + column.trim().toLowerCase();
+        const section = queryAndAssert(element, 'gr-change-list-section');
+        if (column === 'Repo') {
+          assert.isNotOk(query<HTMLElement>(section, elementClass));
+        } else {
+          assert.isOk(queryAndAssert<HTMLElement>(section, elementClass));
+        }
+      }
+    });
+  });
+
+  test('obsolete column in preferences not visible', () => {
+    assert.isTrue(element._isColumnEnabled('Subject'));
+    assert.isFalse(element._isColumnEnabled('Assignee'));
+  });
+
+  test('showStar and showNumber', async () => {
+    element = basicFixture.instantiate();
+    element.sections = [{results: [{...createChange()}], name: 'a'}];
+    element.account = {_account_id: 1001 as AccountId};
+    element.preferences = {
+      legacycid_in_change_table: false, // sets showNumber false
+      time_format: TimeFormat.HHMM_12,
+      change_table: [
+        'Subject',
+        'Status',
+        'Owner',
+        'Reviewers',
+        'Comments',
+        'Branch',
+        'Updated',
+        'Size',
+        ' Status ',
+      ],
+    };
+    element.config = createServerInfo();
+    await element.updateComplete;
+    const section = query<GrChangeListSection>(
+      element,
+      'gr-change-list-section'
+    )!;
+    await section.updateComplete;
+
+    const items = await element.getListItems();
+    assert.equal(items.length, 1);
+
+    assert.isNotOk(query(query(section, 'gr-change-list-item'), '.star'));
+    assert.isNotOk(query(query(section, 'gr-change-list-item'), '.number'));
+
+    element.showStar = true;
+    await element.updateComplete;
+    await section.updateComplete;
+    assert.isOk(query(query(section, 'gr-change-list-item'), '.star'));
+    assert.isNotOk(query(query(section, 'gr-change-list-item'), '.number'));
+
+    element.showNumber = true;
+    await element.updateComplete;
+    await section.updateComplete;
+    assert.isOk(query(query(section, 'gr-change-list-item'), '.star'));
+    assert.isOk(query(query(section, 'gr-change-list-item'), '.number'));
+  });
+
+  suite('random column does not exist', () => {
+    let element: GrChangeList;
+
+    /* This would only exist if somebody manually updated the config
+    file. */
+    setup(async () => {
+      element = basicFixture.instantiate();
+      element.account = {_account_id: 1001 as AccountId};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: TimeFormat.HHMM_12,
+        change_table: ['Bad'],
+      };
+      await element.updateComplete;
+    });
+
+    test('bad column does not exist', () => {
+      assert.isNotOk(query<HTMLElement>(element, '.bad'));
+    });
+  });
+});
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-create-destination-dialog/gr-create-destination-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
index ff9666b..c686d70 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
@@ -18,73 +18,91 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-repo-branch-picker/gr-repo-branch-picker';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-create-destination-dialog_html';
-import {customElement, property} from '@polymer/decorators';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {RepoName, BranchName} from '../../../types/common';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html} from 'lit';
+import {customElement, state, query} from 'lit/decorators';
+import {assertIsDefined} from '../../../utils/common-util';
+import {BindValueChangeEvent} from '../../../types/events';
 
 export interface CreateDestinationConfirmDetail {
   repo?: RepoName;
   branch?: BranchName;
 }
 
-/**
- * Fired when a destination has been picked. Event details contain the repo
- * name and the branch name.
- *
- * @event confirm
- */
-export interface GrCreateDestinationDialog {
-  $: {
-    createOverlay: GrOverlay;
-  };
-}
-
 @customElement('gr-create-destination-dialog')
-export class GrCreateDestinationDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrCreateDestinationDialog extends LitElement {
+  /**
+   * Fired when a destination has been picked. Event details contain the repo
+   * name and the branch name.
+   *
+   * @event confirm
+   */
+
+  @query('#createOverlay') private createOverlay?: GrOverlay;
+
+  @state() private repo?: RepoName;
+
+  @state() private branch?: BranchName;
+
+  static override get styles() {
+    return [sharedStyles];
   }
 
-  @property({type: String})
-  _repo?: RepoName;
-
-  @property({type: String})
-  _branch?: BranchName;
-
-  @property({
-    type: Boolean,
-    computed: '_computeRepoAndBranchSelected(_repo, _branch)',
-  })
-  _repoAndBranchSelected = false;
+  override render() {
+    return html`
+      <gr-overlay id="createOverlay" with-backdrop>
+        <gr-dialog
+          confirm-label="View commands"
+          @confirm=${this.pickerConfirm}
+          @cancel=${() => {
+            assertIsDefined(this.createOverlay, 'createOverlay');
+            this.createOverlay.close();
+          }}
+          ?disabled=${!(this.repo && this.branch)}
+        >
+          <div class="header" slot="header">Create change</div>
+          <div class="main" slot="main">
+            <gr-repo-branch-picker
+              .repo=${this.repo}
+              .branch=${this.branch}
+              @repo-changed=${(e: BindValueChangeEvent) => {
+                this.repo = e.detail.value as RepoName;
+              }}
+              @branch-changed=${(e: BindValueChangeEvent) => {
+                this.branch = e.detail.value as BranchName;
+              }}
+            ></gr-repo-branch-picker>
+            <p>
+              If you haven't done so, you will need to clone the repository.
+            </p>
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
 
   open() {
-    this._repo = '' as RepoName;
-    this._branch = '' as BranchName;
-    this.$.createOverlay.open();
+    assertIsDefined(this.createOverlay, 'createOverlay');
+    this.repo = '' as RepoName;
+    this.branch = '' as BranchName;
+    this.createOverlay.open();
   }
 
-  _handleClose() {
-    this.$.createOverlay.close();
-  }
-
-  _pickerConfirm(e: Event) {
-    this.$.createOverlay.close();
+  private pickerConfirm = (e: Event) => {
+    assertIsDefined(this.createOverlay, 'createOverlay');
+    this.createOverlay.close();
     const detail: CreateDestinationConfirmDetail = {
-      repo: this._repo,
-      branch: this._branch,
+      repo: this.repo,
+      branch: this.branch,
     };
     // e is a 'confirm' event from gr-dialog. We want to fire a more detailed
     // 'confirm' event here, so let's stop propagation of the bare event.
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
-  }
-
-  _computeRepoAndBranchSelected(repo?: RepoName, branch?: BranchName) {
-    return !!(repo && branch);
-  }
+  };
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.ts
deleted file mode 100644
index 0aed75b..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_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`
-  <style include="shared-styles"></style>
-  <gr-overlay id="createOverlay" with-backdrop="">
-    <gr-dialog
-      confirm-label="View commands"
-      on-confirm="_pickerConfirm"
-      on-cancel="_handleClose"
-      disabled="[[!_repoAndBranchSelected]]"
-    >
-      <div class="header" slot="header">Create change</div>
-      <div class="main" slot="main">
-        <gr-repo-branch-picker
-          repo="{{_repo}}"
-          branch="{{_branch}}"
-        ></gr-repo-branch-picker>
-        <p>If you haven't done so, you will need to clone the repository.</p>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
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 9eca3bc..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
@@ -14,8 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-a11y-styles';
-import '../../../styles/shared-styles';
+
 import '../gr-change-list/gr-change-list';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
@@ -24,26 +23,23 @@
 import '../gr-create-change-help/gr-create-change-help';
 import '../gr-create-destination-dialog/gr-create-destination-dialog';
 import '../gr-user-header/gr-user-header';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-dashboard-view_html';
 import {
   GerritNav,
+  OUTGOING,
   UserDashboard,
   YOUR_TURN,
 } from '../../core/gr-navigation/gr-navigation';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {changeIsOpen} from '../../../utils/change-util';
 import {parseDate} from '../../../utils/date-util';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
   AccountDetailInfo,
   ChangeInfo,
   DashboardId,
-  ElementPropertyDeepChange,
   PreferencesInput,
   RepoName,
 } from '../../../types/common';
-import {AppElementDashboardParams, AppElementParams} from '../../gr-app-types';
+import {AppElementDashboardParams} from '../../gr-app-types';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrCreateCommandsDialog} from '../gr-create-commands-dialog/gr-create-commands-dialog';
 import {
@@ -56,38 +52,38 @@
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 import {RELOAD_DASHBOARD_INTERVAL_MS} from '../../../constants/constants';
+import {ChangeListSection} from '../gr-change-list/gr-change-list';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state, query} from 'lit/decorators';
+import {ValueChangedEvent} from '../../../types/events';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
 
-export interface GrDashboardView {
-  $: {
-    confirmDeleteDialog: GrDialog;
-    commandsDialog: GrCreateCommandsDialog;
-    destinationDialog: GrCreateDestinationDialog;
-    confirmDeleteOverlay: GrOverlay;
-  };
-}
-
-interface DashboardChange {
-  name: string;
-  countLabel: string;
-  query: string;
-  results: ChangeInfo[];
-  isOutgoing?: boolean;
-}
+const slotNameBySectionName = new Map<string, string>([
+  [YOUR_TURN.name, 'your-turn-slot'],
+  [OUTGOING.name, 'outgoing-slot'],
+]);
 
 @customElement('gr-dashboard-view')
-export class GrDashboardView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDashboardView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
    * @event title-change
    */
 
+  @query('#confirmDeleteDialog') protected confirmDeleteDialog?: GrDialog;
+
+  @query('#commandsDialog') protected commandsDialog?: GrCreateCommandsDialog;
+
+  @query('#destinationDialog')
+  protected destinationDialog?: GrCreateDestinationDialog;
+
+  @query('#confirmDeleteOverlay') protected confirmDeleteOverlay?: GrOverlay;
+
   @property({type: Object})
   account: AccountDetailInfo | null = null;
 
@@ -98,53 +94,232 @@
   viewState?: DashboardViewState;
 
   @property({type: Object})
-  params?: AppElementParams;
+  params?: AppElementDashboardParams;
 
-  @property({type: Array})
-  _results?: DashboardChange[];
+  // private but used in test
+  @state() results?: ChangeListSection[];
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: Boolean})
-  _showDraftsBanner = false;
+  // private but used in test
+  @state() showDraftsBanner = false;
 
-  @property({type: Boolean})
-  _showNewUserHelp = false;
+  // private but used in test
+  @state() showNewUserHelp = false;
 
-  @property({type: Number})
-  _selectedChangeIndex?: number;
+  // private but used in test
+  @state() selectedChangeIndex?: number;
 
-  private reporting = appContext.reportingService;
+  private reporting = getAppContext().reportingService;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   private lastVisibleTimestampMs = 0;
 
   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();
+    this.loadPreferences();
+    document.addEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
   }
 
-  _loadPreferences() {
+  override disconnectedCallback() {
+    document.removeEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
+    super.disconnectedCallback();
+  }
+
+  static override get styles() {
+    return [
+      a11yStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        .loading {
+          color: var(--deemphasized-text-color);
+          padding: var(--spacing-l);
+        }
+        gr-change-list {
+          width: 100%;
+        }
+        gr-user-header {
+          border-bottom: 1px solid var(--border-color);
+        }
+        .banner {
+          align-items: center;
+          background-color: var(--comment-background-color);
+          border-bottom: 1px solid var(--border-color);
+          display: flex;
+          justify-content: space-between;
+          padding: var(--spacing-xs) var(--spacing-l);
+        }
+        .hide {
+          display: none;
+        }
+        #emptyOutgoing {
+          display: block;
+        }
+        @media only screen and (max-width: 50em) {
+          .loading {
+            padding: 0 var(--spacing-l);
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      ${this.renderBanner()} ${this.renderContent()}
+      <gr-overlay id="confirmDeleteOverlay" with-backdrop>
+        <gr-dialog
+          id="confirmDeleteDialog"
+          confirm-label="Delete"
+          @confirm=${() => {
+            this.handleConfirmDelete();
+          }}
+          @cancel=${() => {
+            this.closeConfirmDeleteOverlay();
+          }}
+        >
+          <div class="header" slot="header">Delete comments</div>
+          <div class="main" slot="main">
+            Are you sure you want to delete all your draft comments in closed
+            changes? This action cannot be undone.
+          </div>
+        </gr-dialog>
+      </gr-overlay>
+      <gr-create-destination-dialog
+        id="destinationDialog"
+        @confirm=${(e: CustomEvent<CreateDestinationConfirmDetail>) => {
+          this.handleDestinationConfirm(e);
+        }}
+      ></gr-create-destination-dialog>
+      <gr-create-commands-dialog
+        id="commandsDialog"
+      ></gr-create-commands-dialog>
+    `;
+  }
+
+  private renderBanner() {
+    if (!this.showDraftsBanner) return;
+
+    return html`
+      <div class="banner">
+        <div>
+          You have draft comments on closed changes.
+          <a href=${this.computeDraftsLink()} target="_blank">(view all)</a>
+        </div>
+        <div>
+          <gr-button
+            class="delete"
+            link
+            @click=${() => {
+              this.handleOpenDeleteDialog();
+            }}
+            >Delete All</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private renderContent() {
+    // 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 class="loading" ?hidden=${!this.loading}>Loading...</div>
+      <div ?hidden=${this.loading}>
+        ${this.renderUserHeader()}
+        <h1 class="assistive-tech-only">Dashboard</h1>
+        <gr-change-list
+          ?showStar=${true}
+          .account=${this.account}
+          .preferences=${this.preferences}
+          .selectedIndex=${this.selectedChangeIndex}
+          .sections=${this.results}
+          @selected-index-changed=${(e: ValueChangedEvent<number>) => {
+            this.handleSelectedIndexChanged(e);
+          }}
+          @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
+            this.handleToggleStar(e);
+          }}
+        >
+          <div id="emptyOutgoing" slot="outgoing-slot">
+            ${this.renderShowNewUserHelp()}
+          </div>
+          <div id="emptyYourTurn" slot="your-turn-slot">
+            <span>No changes need your attention &nbsp;&#x1f389;</span>
+          </div>
+        </gr-change-list>
+      </div>
+    `;
+  }
+
+  private renderUserHeader() {
+    if (
+      !this.params ||
+      this.params.view !== GerritView.DASHBOARD ||
+      !!this.params.project ||
+      !this.params.user ||
+      this.params.user === 'self'
+    ) {
+      return;
+    }
+
+    return html`
+      <gr-user-header .userId=${this.params?.user}></gr-user-header>
+    `;
+  }
+
+  private renderShowNewUserHelp() {
+    if (!this.showNewUserHelp) return ' No changes ';
+
+    return html`
+      <gr-create-change-help
+        @create-tap=${() => {
+          this.handleCreateChangeTap();
+        }}
+      ></gr-create-change-help>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this.paramsChanged();
+    }
+
+    if (changedProperties.has('selectedChangeIndex')) {
+      this.selectedChangeIndexChanged();
+    }
+  }
+
+  private loadPreferences() {
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         this.restApiService.getPreferences().then(preferences => {
@@ -156,7 +331,8 @@
     });
   }
 
-  _getProjectDashboard(
+  // private but used in test
+  getProjectDashboard(
     project: RepoName,
     dashboard: DashboardId
   ): Promise<UserDashboard | undefined> {
@@ -185,59 +361,62 @@
       });
   }
 
-  _computeTitle(user?: string) {
+  // private but used in test
+  computeTitle(user?: string) {
     if (!user || user === 'self') {
       return 'My Reviews';
     }
     return 'Dashboard for ' + user;
   }
 
-  _isViewActive(params: AppElementParams): params is AppElementDashboardParams {
+  private isViewActive(params: AppElementDashboardParams) {
     return params.view === GerritView.DASHBOARD;
   }
 
-  @observe('_selectedChangeIndex')
-  _selectedChangeIndexChanged(selectedChangeIndex: number) {
-    if (!this.params || !this._isViewActive(this.params)) return;
+  private selectedChangeIndexChanged() {
+    if (
+      !this.params ||
+      !this.isViewActive(this.params) ||
+      this.selectedChangeIndex === undefined
+    )
+      return;
     if (!this.viewState) throw new Error('view state undefined');
     if (!this.params.user) throw new Error('user for dashboard is undefined');
-    this.viewState[this.params.user] = selectedChangeIndex;
+    this.viewState[this.params.user] = this.selectedChangeIndex;
   }
 
-  @observe('params.*')
-  _paramsChanged(
-    paramsChangeRecord: ElementPropertyDeepChange<GrDashboardView, 'params'>
-  ) {
-    const params = paramsChangeRecord.base;
-    if (params && this._isViewActive(params) && params.user && this.viewState)
-      this._selectedChangeIndex = this.viewState[params.user] || 0;
-    return this._reload(params);
+  // private but used in test
+  paramsChanged() {
+    if (
+      this.params &&
+      this.isViewActive(this.params) &&
+      this.params.user &&
+      this.viewState
+    )
+      this.selectedChangeIndex = this.viewState[this.params.user] || 0;
+    return this.reload();
   }
 
   /**
    * Reloads the element.
+   *
+   * private but used in test
    */
-  _reload(params?: AppElementParams) {
-    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;
+    this.loading = true;
+    const {project, dashboard, title, user, sections} = this.params;
     const dashboardPromise: Promise<UserDashboard | undefined> = project
-      ? this._getProjectDashboard(project, dashboard)
-      : this.restApiService
-          .getConfig()
-          .then(config =>
-            Promise.resolve(
-              GerritNav.getUserDashboard(
-                user,
-                sections,
-                title || this._computeTitle(user),
-                config
-              )
-            )
-          );
-
+      ? this.getProjectDashboard(project, dashboard)
+      : Promise.resolve(
+          GerritNav.getUserDashboard(
+            user,
+            sections,
+            title || this.computeTitle(user)
+          )
+        );
     // Checking `this.account` to make sure that the user is logged in.
     // Otherwise sending a query for 'owner:self' will result in an error.
     const checkForNewUser = !project && !!this.account && user === 'self';
@@ -246,26 +425,28 @@
         if (res && res.title) {
           fireTitleChange(this, res.title);
         }
-        return this._fetchDashboardChanges(res, checkForNewUser);
+        return this.fetchDashboardChanges(res, checkForNewUser);
       })
       .then(() => {
-        this._maybeShowDraftsBanner(params);
+        this.maybeShowDraftsBanner();
         this.reporting.dashboardDisplayed();
       })
       .catch(err => {
-        fireTitleChange(this, title || this._computeTitle(user));
+        fireTitleChange(this, title || this.computeTitle(user));
         this.reporting.error(err);
       })
-      .then(() => {
-        this._loading = false;
+      .finally(() => {
+        this.loading = false;
       });
   }
 
   /**
-   * Fetches the changes for each dashboard section and sets this._results
+   * Fetches the changes for each dashboard section and sets this.results
    * with the response.
+   *
+   * private but used in test
    */
-  _fetchDashboardChanges(
+  fetchDashboardChanges(
     res: UserDashboard | undefined,
     checkForNewUser: boolean
   ): Promise<void> {
@@ -291,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),
-            isOutgoing: res.sections[i].isOutgoing,
-          };
-        })
-        .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)
+          );
+      });
   }
 
   /**
@@ -324,7 +509,7 @@
    * top where the current user is a reviewer. Owned changes are less important.
    * And then we want to emphasize the changes where the waiting time is larger.
    */
-  _maybeSortResults(name: string, results: ChangeInfo[]) {
+  private maybeSortResults(name: string, results: ChangeInfo[]) {
     const userId = this.account && this.account._account_id;
     const sortedResults = [...results];
     if (name === YOUR_TURN.name && userId) {
@@ -346,7 +531,8 @@
     return sortedResults;
   }
 
-  _computeSectionCountLabel(changes: ChangeInfo[]) {
+  // private but used in test
+  computeSectionCountLabel(changes: ChangeInfo[]) {
     if (!changes || !changes.length || changes.length === 0) {
       return '';
     }
@@ -356,20 +542,8 @@
     return `(${numChanges}${andMore})`;
   }
 
-  _computeUserHeaderClass(params: AppElementParams) {
-    if (
-      !params ||
-      params.view !== GerritView.DASHBOARD ||
-      !!params.project ||
-      !params.user ||
-      params.user === 'self'
-    ) {
-      return 'hide';
-    }
-    return '';
-  }
-
-  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+  // private but used in test
+  handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
     this.restApiService.saveChangeStarred(
       e.detail.change._number,
       e.detail.starred
@@ -380,13 +554,12 @@
     // When a change is updated the same change may appear elsewhere in the
     // dashboard (but is not the same object), so we must update other
     // occurrences of the same change.
-    this._results?.forEach((dashboardChange, dashboardIndex) =>
+    this.results?.forEach((dashboardChange, dashboardIndex) =>
       dashboardChange.results.forEach((change, changeIndex) => {
         if (change.id === e.detail.change.id) {
-          this.set(
-            `_results.${dashboardIndex}.results.${changeIndex}.starred`,
-            e.detail.starred
-          );
+          this.results![dashboardIndex].results[changeIndex].starred =
+            e.detail.starred;
+          this.requestUpdate('results');
         }
       })
     );
@@ -395,18 +568,20 @@
   /**
    * Banner is shown if a user is on their own dashboard and they have draft
    * comments on closed changes.
+   *
+   * private but used in test
    */
-  _maybeShowDraftsBanner(params: AppElementDashboardParams) {
-    this._showDraftsBanner = false;
-    if (!(params.user === 'self')) {
+  maybeShowDraftsBanner() {
+    this.showDraftsBanner = false;
+    if (!(this.params?.user === 'self')) {
       return;
     }
 
-    if (!this._results) {
-      throw new Error('this._results must be set. restAPI returned undefined');
+    if (!this.results) {
+      throw new Error('this.results must be set. restAPI returned undefined');
     }
 
-    const draftSection = this._results.find(
+    const draftSection = this.results.find(
       section => section.query === 'has:draft'
     );
     if (!draftSection || !draftSection.results.length) {
@@ -420,48 +595,49 @@
       return;
     }
 
-    this._showDraftsBanner = true;
+    this.showDraftsBanner = true;
   }
 
-  _computeBannerClass(show: boolean) {
-    return show ? '' : 'hide';
+  // private but used in test
+  handleOpenDeleteDialog() {
+    assertIsDefined(this.confirmDeleteOverlay, 'confirmDeleteOverlay');
+    this.confirmDeleteOverlay.open();
   }
 
-  _handleOpenDeleteDialog() {
-    this.$.confirmDeleteOverlay.open();
-  }
-
-  _handleConfirmDelete() {
-    this.$.confirmDeleteDialog.disabled = true;
+  // private but used in test
+  handleConfirmDelete() {
+    assertIsDefined(this.confirmDeleteDialog, 'confirmDeleteDialog');
+    this.confirmDeleteDialog.disabled = true;
     return this.restApiService.deleteDraftComments('-is:open').then(() => {
-      this._closeConfirmDeleteOverlay();
-      this._reload(this.params);
+      this.closeConfirmDeleteOverlay();
+      this.reload();
     });
   }
 
-  _closeConfirmDeleteOverlay() {
-    this.$.confirmDeleteOverlay.close();
+  private closeConfirmDeleteOverlay() {
+    assertIsDefined(this.confirmDeleteOverlay, 'confirmDeleteOverlay');
+    this.confirmDeleteOverlay.close();
   }
 
-  _computeDraftsLink() {
+  private computeDraftsLink() {
     return GerritNav.getUrlForSearchQuery('has:draft -is:open');
   }
 
-  _handleCreateChangeTap() {
-    this.$.destinationDialog.open();
+  private handleCreateChangeTap() {
+    assertIsDefined(this.destinationDialog, 'destinationDialog');
+    this.destinationDialog.open();
   }
 
-  _handleDestinationConfirm(e: CustomEvent<CreateDestinationConfirmDetail>) {
-    this.$.commandsDialog.branch = e.detail.branch;
-    this.$.commandsDialog.open();
+  private handleDestinationConfirm(
+    e: CustomEvent<CreateDestinationConfirmDetail>
+  ) {
+    assertIsDefined(this.commandsDialog, 'commandsDialog');
+    this.commandsDialog.branch = e.detail.branch;
+    this.commandsDialog.open();
   }
 
-  /**
-   * Returns `this` as the visibility observer target for the keyboard shortcut
-   * mixin to decide whether shortcuts should be enabled or not.
-   */
-  _computeObserverTarget() {
-    return this;
+  private handleSelectedIndexChanged(e: ValueChangedEvent<number>) {
+    this.selectedChangeIndex = e.detail.value;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
deleted file mode 100644
index a55befb..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-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-a11y-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    .loading {
-      color: var(--deemphasized-text-color);
-      padding: var(--spacing-l);
-    }
-    gr-change-list {
-      width: 100%;
-    }
-    gr-user-header {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .banner {
-      align-items: center;
-      background-color: var(--comment-background-color);
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-xs) var(--spacing-l);
-    }
-    .hide {
-      display: none;
-    }
-    #emptyOutgoing {
-      display: block;
-    }
-    @media only screen and (max-width: 50em) {
-      .loading {
-        padding: 0 var(--spacing-l);
-      }
-    }
-  </style>
-  <div class$="banner [[_computeBannerClass(_showDraftsBanner)]]">
-    <div>
-      You have draft comments on closed changes.
-      <a href$="[[_computeDraftsLink(_showDraftsBanner)]]" target="_blank"
-        >(view all)</a
-      >
-    </div>
-    <div>
-      <gr-button class="delete" link="" on-click="_handleOpenDeleteDialog"
-        >Delete All</gr-button
-      >
-    </div>
-  </div>
-  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-  <div hidden$="[[_loading]]" hidden="">
-    <gr-user-header
-      user-id="[[params.user]]"
-      class$="[[_computeUserHeaderClass(params)]]"
-    ></gr-user-header>
-    <h1 class="assistive-tech-only">Dashboard</h1>
-    <gr-change-list
-      show-star=""
-      account="[[account]]"
-      preferences="[[preferences]]"
-      selected-index="{{_selectedChangeIndex}}"
-      sections="[[_results]]"
-      on-toggle-star="_handleToggleStar"
-      observer-target="[[_computeObserverTarget()]]"
-    >
-      <div id="emptyOutgoing" slot="empty-outgoing">
-        <template is="dom-if" if="[[_showNewUserHelp]]">
-          <gr-create-change-help
-            on-create-tap="_handleCreateChangeTap"
-          ></gr-create-change-help>
-        </template>
-        <template is="dom-if" if="[[!_showNewUserHelp]]"> No changes </template>
-      </div>
-      <div id="emptyYourTurn" slot="empty-your-turn">
-        <span>No changes need your attention &nbsp;&#x1f389;</span>
-      </div>
-    </gr-change-list>
-  </div>
-  <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
-    <gr-dialog
-      id="confirmDeleteDialog"
-      confirm-label="Delete"
-      on-confirm="_handleConfirmDelete"
-      on-cancel="_closeConfirmDeleteOverlay"
-    >
-      <div class="header" slot="header">Delete comments</div>
-      <div class="main" slot="main">
-        Are you sure you want to delete all your draft comments in closed
-        changes? This action cannot be undone.
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-  <gr-create-destination-dialog
-    id="destinationDialog"
-    on-confirm="_handleDestinationConfirm"
-  ></gr-create-destination-dialog>
-  <gr-create-commands-dialog id="commandsDialog"></gr-create-commands-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
deleted file mode 100644
index aa76347..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
+++ /dev/null
@@ -1,452 +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-dashboard-view.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {GerritView} from '../../../services/router/router-model.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {createAccountWithId} from '../../../test/test-data-generators.js';
-import {addListenerForTest, stubRestApi, isHidden, mockPromise} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-dashboard-view');
-
-suite('gr-dashboard-view tests', () => {
-  let element;
-
-  let paramsChangedPromise;
-  let getChangesStub;
-
-  setup(() => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getAccountDetails').returns(Promise.resolve({}));
-    stubRestApi('getAccountStatus').returns(Promise.resolve(false));
-    getChangesStub= stubRestApi('getChanges').callsFake(
-        (_, qs) => Promise.resolve(qs.map(() => [])));
-
-    element = basicFixture.instantiate();
-
-    let resolver;
-    paramsChangedPromise = new Promise(resolve => {
-      resolver = resolve;
-    });
-    const paramsChanged = element._paramsChanged.bind(element);
-    sinon.stub(element, '_paramsChanged').callsFake( params => {
-      paramsChanged(params).then(() => resolver());
-    });
-  });
-
-  suite('drafts banner functionality', () => {
-    suite('_maybeShowDraftsBanner', () => {
-      test('not dashboard/self', () => {
-        element._maybeShowDraftsBanner({
-          view: GerritView.DASHBOARD,
-          user: 'notself',
-        });
-        assert.isFalse(element._showDraftsBanner);
-      });
-
-      test('no drafts at all', () => {
-        element._results = [];
-        element._maybeShowDraftsBanner({
-          view: GerritView.DASHBOARD,
-          user: 'self',
-        });
-        assert.isFalse(element._showDraftsBanner);
-      });
-
-      test('no drafts on open changes', () => {
-        const openChange = {status: ChangeStatus.NEW};
-        element._results = [{query: 'has:draft', results: [openChange]}];
-        element._maybeShowDraftsBanner({
-          view: GerritView.DASHBOARD,
-          user: 'self',
-        });
-        assert.isFalse(element._showDraftsBanner);
-      });
-
-      test('no drafts on not open changes', () => {
-        const notOpenChange = {status: '_'};
-        element._results = [{query: 'has:draft', results: [notOpenChange]}];
-        assert.isFalse(changeIsOpen(element._results[0].results[0]));
-        element._maybeShowDraftsBanner({
-          view: GerritView.DASHBOARD,
-          user: 'self',
-        });
-        assert.isTrue(element._showDraftsBanner);
-      });
-    });
-
-    test('_showDraftsBanner', () => {
-      element._showDraftsBanner = false;
-      flush();
-      assert.isTrue(isHidden(element.shadowRoot
-          .querySelector('.banner')));
-
-      element._showDraftsBanner = true;
-      flush();
-      assert.isFalse(isHidden(element.shadowRoot
-          .querySelector('.banner')));
-    });
-
-    test('delete tap opens dialog', () => {
-      sinon.stub(element, '_handleOpenDeleteDialog');
-      element._showDraftsBanner = true;
-      flush();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.banner .delete'));
-      assert.isTrue(element._handleOpenDeleteDialog.called);
-    });
-
-    test('delete comments flow', async () => {
-      sinon.spy(element, '_handleConfirmDelete');
-      sinon.stub(element, '_reload');
-
-      // Set up control over timing of when RPC resolves.
-      let deleteDraftCommentsPromiseResolver;
-      const deleteDraftCommentsPromise = new Promise(resolve => {
-        deleteDraftCommentsPromiseResolver = resolve;
-      });
-      const deleteStub = stubRestApi('deleteDraftComments')
-          .returns(deleteDraftCommentsPromise);
-
-      // Open confirmation dialog and tap confirm button.
-      await element.$.confirmDeleteOverlay.open();
-      MockInteractions.tap(element.$.confirmDeleteDialog.confirmButton);
-      flush();
-      assert.isTrue(deleteStub.calledWithExactly('-is:open'));
-      assert.isTrue(element.$.confirmDeleteDialog.disabled);
-      assert.equal(element._reload.callCount, 0);
-
-      // Verify state after RPC resolves.
-      deleteDraftCommentsPromiseResolver([]);
-      await deleteDraftCommentsPromise;
-      assert.equal(element._reload.callCount, 1);
-    });
-  });
-
-  test('_computeTitle', () => {
-    assert.equal(element._computeTitle('self'), 'My Reviews');
-    assert.equal(element._computeTitle('not self'), 'Dashboard for not self');
-  });
-
-  suite('_computeSectionCountLabel', () => {
-    test('empty changes dont count label', () => {
-      assert.equal('', element._computeSectionCountLabel([]));
-    });
-
-    test('1 change', () => {
-      assert.equal('(1)',
-          element._computeSectionCountLabel(['1']));
-    });
-
-    test('2 changes', () => {
-      assert.equal('(2)',
-          element._computeSectionCountLabel(['1', '2']));
-    });
-
-    test('1 change and more', () => {
-      assert.equal('(1 and more)',
-          element._computeSectionCountLabel([{_more_changes: true}]));
-    });
-  });
-
-  suite('_isViewActive', () => {
-    test('nothing happens when user param is falsy', () => {
-      element.params = {};
-      flush();
-      assert.equal(getChangesStub.callCount, 0);
-
-      element.params = {user: ''};
-      flush();
-      assert.equal(getChangesStub.callCount, 0);
-    });
-
-    test('content is refreshed when user param is updated', () => {
-      element.params = {
-        view: GerritNav.View.DASHBOARD,
-        user: 'self',
-      };
-      return paramsChangedPromise.then(() => {
-        assert.equal(getChangesStub.callCount, 1);
-      });
-    });
-  });
-
-  suite('selfOnly sections', () => {
-    test('viewing self dashboard includes selfOnly sections', () => {
-      element.params = {
-        view: GerritNav.View.DASHBOARD,
-        sections: [
-          {query: '1'},
-          {query: '2', selfOnly: true},
-        ],
-        user: 'self',
-      };
-      return paramsChangedPromise.then(() => {
-        assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2']));
-      });
-    });
-
-    test('viewing dashboard when logged in includes owner:self query', () => {
-      element.account = createAccountWithId(1);
-      element.params = {
-        view: GerritNav.View.DASHBOARD,
-        sections: [
-          {query: '1'},
-          {query: '2', selfOnly: true},
-        ],
-        user: 'self',
-      };
-      return paramsChangedPromise.then(() => {
-        assert.isTrue(getChangesStub.calledWith(undefined,
-            ['1', '2', 'owner:self limit:1']));
-      });
-    });
-
-    test('viewing another user\'s dashboard omits selfOnly sections', () => {
-      element.params = {
-        view: GerritNav.View.DASHBOARD,
-        sections: [
-          {query: '1'},
-          {query: '2', selfOnly: true},
-        ],
-        user: 'user',
-      };
-      return paramsChangedPromise.then(() => {
-        assert.isTrue(getChangesStub.calledWith(undefined, ['1']));
-      });
-    });
-  });
-
-  test('suffixForDashboard is included in getChanges query', () => {
-    element.params = {
-      view: GerritNav.View.DASHBOARD,
-      sections: [
-        {query: '1'},
-        {query: '2', suffixForDashboard: 'suffix'},
-      ],
-    };
-    return paramsChangedPromise.then(() => {
-      assert.isTrue(getChangesStub.calledOnce);
-      assert.deepEqual(
-          getChangesStub.firstCall.args, [undefined, ['1', '2 suffix']]);
-    });
-  });
-
-  suite('_getProjectDashboard', () => {
-    test('dashboard with foreach', () => {
-      stubRestApi('getDashboard')
-          .callsFake( () => Promise.resolve({
-            title: 'title',
-            foreach: 'foreach for ${project}',
-            sections: [
-              {name: 'section 1', query: 'query 1'},
-              {name: 'section 2', query: '${project} query 2'},
-            ],
-          }));
-      return element._getProjectDashboard('project', '').then(dashboard => {
-        assert.deepEqual(
-            dashboard,
-            {
-              title: 'title',
-              sections: [
-                {name: 'section 1', query: 'query 1 foreach for project'},
-                {
-                  name: 'section 2',
-                  query: 'project query 2 foreach for project',
-                },
-              ],
-            });
-      });
-    });
-
-    test('dashboard without foreach', () => {
-      stubRestApi('getDashboard').callsFake(
-          () => Promise.resolve({
-            title: 'title',
-            sections: [
-              {name: 'section 1', query: 'query 1'},
-              {name: 'section 2', query: '${project} query 2'},
-            ],
-          }));
-      return element._getProjectDashboard('project', '').then(dashboard => {
-        assert.deepEqual(
-            dashboard,
-            {
-              title: 'title',
-              sections: [
-                {name: 'section 1', query: 'query 1'},
-                {name: 'section 2', query: 'project query 2'},
-              ],
-            });
-      });
-    });
-  });
-
-  test('hideIfEmpty sections', () => {
-    const sections = [
-      {name: 'test1', query: 'test1', hideIfEmpty: true},
-      {name: 'test2', query: 'test2', hideIfEmpty: true},
-    ];
-    getChangesStub.restore();
-    stubRestApi('getChanges')
-        .returns(Promise.resolve([[], ['nonempty']]));
-
-    return element._fetchDashboardChanges({sections}, false).then(() => {
-      assert.equal(element._results.length, 1);
-      assert.equal(element._results[0].name, 'test2');
-    });
-  });
-
-  test('preserve isOutgoing sections', () => {
-    const sections = [
-      {name: 'test1', query: 'test1', isOutgoing: true},
-      {name: 'test2', query: 'test2'},
-    ];
-    getChangesStub.restore();
-    stubRestApi('getChanges')
-        .returns(Promise.resolve([[], []]));
-
-    return element._fetchDashboardChanges({sections}, false).then(() => {
-      assert.equal(element._results.length, 2);
-      assert.isTrue(element._results[0].isOutgoing);
-      assert.isNotOk(element._results[1].isOutgoing);
-    });
-  });
-
-  test('toggling star will update change everywhere', () => {
-    // It is important that the same change is represented by multiple objects
-    // and all are updated.
-    const change = {id: '5', starred: false};
-    const sameChange = {id: '5', starred: false};
-    const differentChange = {id: '4', starred: false};
-    element._results = [
-      {query: 'has:draft', results: [change]},
-      {query: 'is:open', results: [sameChange, differentChange]},
-    ];
-
-    element._handleToggleStar(
-        new CustomEvent('toggle-star', {
-          detail: {
-            change,
-            starred: true,
-          },
-        })
-    );
-
-    assert.isTrue(change.starred);
-    assert.isTrue(sameChange.starred);
-    assert.isFalse(differentChange.starred);
-  });
-
-  test('_showNewUserHelp', () => {
-    element._loading = false;
-    element._showNewUserHelp = false;
-    flush();
-
-    assert.equal(element.$.emptyOutgoing.textContent.trim(), 'No changes');
-    assert.isNotOk(element.shadowRoot
-        .querySelector('gr-create-change-help'));
-    element._showNewUserHelp = true;
-    flush();
-
-    assert.notEqual(element.$.emptyOutgoing.textContent.trim(), 'No changes');
-    assert.isOk(element.shadowRoot
-        .querySelector('gr-create-change-help'));
-  });
-
-  test('_computeUserHeaderClass', () => {
-    assert.equal(element._computeUserHeaderClass(undefined), 'hide');
-    assert.equal(element._computeUserHeaderClass({}), 'hide');
-    assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
-    assert.equal(element._computeUserHeaderClass({user: 'user'}), 'hide');
-    assert.equal(
-        element._computeUserHeaderClass({
-          view: GerritView.DASHBOARD,
-          user: 'user',
-        }),
-        '');
-    assert.equal(
-        element._computeUserHeaderClass({project: 'p', user: 'user'}),
-        'hide');
-    assert.equal(
-        element._computeUserHeaderClass({
-          view: GerritView.DASHBOARD,
-          project: 'p',
-          user: 'user',
-        }),
-        'hide');
-  });
-
-  test('404 page', async () => {
-    const response = {status: 404};
-    stubRestApi('getDashboard').callsFake(
-        async (project, dashboard, errFn) => {
-          errFn(response);
-        });
-    const promise = mockPromise();
-    addListenerForTest(document, 'page-error', e => {
-      assert.strictEqual(e.detail.response, response);
-      promise.resolve();
-    });
-    element.params = {
-      view: GerritNav.View.DASHBOARD,
-      project: 'project',
-      dashboard: 'dashboard',
-    };
-    await Promise.all([paramsChangedPromise, promise]);
-  });
-
-  test('params change triggers dashboardDisplayed()', async () => {
-    stubRestApi('getDashboard').returns(Promise.resolve({
-      title: 'title',
-      sections: [],
-    }));
-    sinon.stub(element.reporting, 'dashboardDisplayed');
-    element.params = {
-      view: GerritNav.View.DASHBOARD,
-      project: 'project',
-      dashboard: 'dashboard',
-    };
-    await paramsChangedPromise;
-    assert.isTrue(element.reporting.dashboardDisplayed.calledOnce);
-  });
-
-  test('selectedChangeIndex is derived from the params', async () => {
-    stubRestApi('getDashboard').returns(Promise.resolve({
-      title: 'title',
-      sections: [],
-    }));
-    element.viewState = {
-      101001: 23,
-    };
-    element.params = {
-      view: GerritNav.View.DASHBOARD,
-      project: 'project',
-      dashboard: 'dashboard',
-      user: '101001',
-    };
-    flush();
-    sinon.stub(element.reporting, 'dashboardDisplayed');
-    await paramsChangedPromise;
-    assert.equal(element._selectedChangeIndex, 23);
-  });
-});
-
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
new file mode 100644
index 0000000..e5aff61
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
@@ -0,0 +1,618 @@
+/**
+ * @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-dashboard-view';
+import {GrDashboardView} from './gr-dashboard-view';
+import {GerritView} from '../../../services/router/router-model';
+import {changeIsOpen} from '../../../utils/change-util';
+import {ChangeStatus} from '../../../constants/constants';
+import {
+  createAccountDetailWithId,
+  createChange,
+} from '../../../test/test-data-generators';
+import {
+  addListenerForTest,
+  stubReporting,
+  stubRestApi,
+  mockPromise,
+  queryAndAssert,
+  query,
+  stubFlags,
+  waitUntil,
+} from '../../../test/test-utils';
+import {
+  ChangeInfoId,
+  DashboardId,
+  RepoName,
+  Timestamp,
+} from '../../../types/common';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+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: SinonStubbedMember<
+    RestApiService['getChangesForMultipleQueries']
+  >;
+
+  setup(async () => {
+    getChangesStub = stubRestApi('getChangesForMultipleQueries');
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getAccountDetails').returns(
+      Promise.resolve({
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+    stubRestApi('getAccountStatus').returns(Promise.resolve(undefined));
+
+    element = await fixture<GrDashboardView>(html`
+      <gr-dashboard-view></gr-dashboard-view>
+    `);
+
+    await element.updateComplete;
+    let resolver: (value?: any) => void;
+    paramsChangedPromise = new Promise(resolve => {
+      resolver = resolve;
+    });
+    const paramsChanged = element.paramsChanged.bind(element);
+    sinon
+      .stub(element, 'paramsChanged')
+      .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.params = {
+          view: GerritView.DASHBOARD,
+          user: 'notself',
+          dashboard: '' as DashboardId,
+        };
+        element.maybeShowDraftsBanner();
+        assert.isFalse(element.showDraftsBanner);
+      });
+
+      test('no drafts at all', () => {
+        element.results = [];
+        element.params = {
+          view: GerritView.DASHBOARD,
+          user: 'self',
+          dashboard: '' as DashboardId,
+        };
+        element.maybeShowDraftsBanner();
+        assert.isFalse(element.showDraftsBanner);
+      });
+
+      test('no drafts on open changes', () => {
+        const openChange = {...createChange(), status: ChangeStatus.NEW};
+        element.results = [
+          {countLabel: '', name: '', query: 'has:draft', results: [openChange]},
+        ];
+        element.params = {
+          view: GerritView.DASHBOARD,
+          user: 'self',
+          dashboard: '' as DashboardId,
+        };
+        element.maybeShowDraftsBanner();
+        assert.isFalse(element.showDraftsBanner);
+      });
+
+      test('no drafts on not open changes', () => {
+        const notOpenChange = {...createChange(), status: '_' as ChangeStatus};
+        element.results = [
+          {
+            name: '',
+            countLabel: '',
+            query: 'has:draft',
+            results: [notOpenChange],
+          },
+        ];
+        assert.isFalse(changeIsOpen(element.results[0].results[0]));
+        element.params = {
+          view: GerritView.DASHBOARD,
+          user: 'self',
+          dashboard: '' as DashboardId,
+        };
+        element.maybeShowDraftsBanner();
+        assert.isTrue(element.showDraftsBanner);
+      });
+    });
+
+    test('showDraftsBanner', async () => {
+      element.showDraftsBanner = false;
+      await element.updateComplete;
+      assert.isNotOk(query(element, '.banner'));
+
+      element.showDraftsBanner = true;
+      await element.updateComplete;
+      assert.isOk(query(element, '.banner'));
+    });
+
+    test('delete tap opens dialog', async () => {
+      const handleOpenDeleteDialogStub = sinon.stub(
+        element,
+        'handleOpenDeleteDialog'
+      );
+      element.showDraftsBanner = true;
+      await element.updateComplete;
+
+      MockInteractions.tap(queryAndAssert(element, '.banner .delete'));
+      assert.isTrue(handleOpenDeleteDialogStub.called);
+    });
+
+    test('delete comments flow', async () => {
+      sinon.spy(element, 'handleConfirmDelete');
+      const reloadStub = sinon.stub(element, 'reload');
+
+      // Set up control over timing of when RPC resolves.
+      let deleteDraftCommentsPromiseResolver: (
+        value: Response | PromiseLike<Response>
+      ) => void;
+      const deleteDraftCommentsPromise: Promise<Response> = new Promise(
+        resolve => {
+          deleteDraftCommentsPromiseResolver = resolve;
+          return Promise.resolve(new Response());
+        }
+      );
+
+      const deleteStub = stubRestApi('deleteDraftComments').returns(
+        deleteDraftCommentsPromise
+      );
+
+      // Open confirmation dialog and tap confirm button.
+      await queryAndAssert<GrOverlay>(element, '#confirmDeleteOverlay').open();
+      MockInteractions.tap(
+        queryAndAssert<GrDialog>(element, '#confirmDeleteDialog').confirmButton!
+      );
+      await element.updateComplete;
+      assert.isTrue(deleteStub.calledWithExactly('-is:open'));
+      assert.isTrue(
+        queryAndAssert<GrDialog>(element, '#confirmDeleteDialog').disabled
+      );
+      assert.equal(reloadStub.callCount, 0);
+
+      // Verify state after RPC resolves.
+      // We have to put this in setTimeout otherwise typescript fails with
+      // variable is used before assigned.
+      setTimeout(() => deleteDraftCommentsPromiseResolver(new Response()), 0);
+      await deleteDraftCommentsPromise;
+      assert.equal(reloadStub.callCount, 1);
+    });
+  });
+
+  test('computeTitle', () => {
+    assert.equal(element.computeTitle('self'), 'My Reviews');
+    assert.equal(element.computeTitle('not self'), 'Dashboard for not self');
+  });
+
+  suite('computeSectionCountLabel', () => {
+    test('empty changes dont count label', () => {
+      assert.equal('', element.computeSectionCountLabel([]));
+    });
+
+    test('1 change', () => {
+      assert.equal('(1)', element.computeSectionCountLabel([createChange()]));
+    });
+
+    test('2 changes', () => {
+      assert.equal(
+        '(2)',
+        element.computeSectionCountLabel([createChange(), createChange()])
+      );
+    });
+
+    test('1 change and more', () => {
+      assert.equal(
+        '(1 and more)',
+        element.computeSectionCountLabel([
+          {...createChange(), _more_changes: true},
+        ])
+      );
+    });
+  });
+
+  suite('_isViewActive', () => {
+    test('nothing happens when user param is falsy', async () => {
+      element.params = undefined;
+      await element.updateComplete;
+      assert.equal(getChangesStub.callCount, 0);
+    });
+
+    test('content is refreshed when user param is updated', async () => {
+      element.params = {
+        view: GerritView.DASHBOARD,
+        user: 'self',
+        dashboard: '' as DashboardId,
+      };
+      await paramsChangedPromise;
+      assert.isTrue(getChangesStub.called);
+    });
+  });
+
+  suite('selfOnly sections', () => {
+    test('viewing self dashboard includes selfOnly sections', async () => {
+      element.params = {
+        view: GerritView.DASHBOARD,
+        user: 'self',
+        dashboard: '' as DashboardId,
+        sections: [
+          {name: '', query: '1'},
+          {name: '', query: '2', selfOnly: true},
+        ],
+      };
+      await paramsChangedPromise;
+      assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2']));
+    });
+
+    test('viewing dashboard when logged in includes owner:self query', async () => {
+      element.account = createAccountDetailWithId(1);
+      element.params = {
+        view: GerritView.DASHBOARD,
+        user: 'self',
+        dashboard: '' as DashboardId,
+        sections: [
+          {name: '', query: '1'},
+          {name: '', query: '2', selfOnly: true},
+        ],
+      };
+      await paramsChangedPromise;
+      assert.isTrue(
+        getChangesStub.calledWith(undefined, ['1', '2', 'owner:self limit:1'])
+      );
+    });
+
+    test("viewing another user's dashboard omits selfOnly sections", async () => {
+      element.params = {
+        view: GerritView.DASHBOARD,
+        user: 'user',
+        dashboard: '' as DashboardId,
+        sections: [
+          {name: '', query: '1'},
+          {name: '', query: '2', selfOnly: true},
+        ],
+      };
+      await paramsChangedPromise;
+      assert.isTrue(getChangesStub.calledWith(undefined, ['1']));
+    });
+  });
+
+  test('suffixForDashboard is included in getChanges query', async () => {
+    element.params = {
+      view: GerritView.DASHBOARD,
+      dashboard: '' as DashboardId,
+      sections: [
+        {name: '', query: '1'},
+        {name: '', query: '2', suffixForDashboard: 'suffix'},
+      ],
+    };
+    await paramsChangedPromise;
+    assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2 suffix']));
+  });
+
+  suite('getProjectDashboard', () => {
+    test('dashboard with foreach', async () => {
+      stubRestApi('getDashboard').callsFake(() =>
+        Promise.resolve({
+          id: '' as DashboardId,
+          project: 'project' as RepoName,
+          defining_project: '' as RepoName,
+          ref: '',
+          path: '',
+          url: '',
+          title: 'title',
+          foreach: 'foreach for ${project}',
+          sections: [
+            {name: 'section 1', query: 'query 1'},
+            {name: 'section 2', query: '${project} query 2'},
+          ],
+        })
+      );
+      const dashboard = await element.getProjectDashboard(
+        'project' as RepoName,
+        '' as DashboardId
+      );
+      assert.deepEqual(dashboard, {
+        title: 'title',
+        sections: [
+          {name: 'section 1', query: 'query 1 foreach for project'},
+          {
+            name: 'section 2',
+            query: 'project query 2 foreach for project',
+          },
+        ],
+      });
+    });
+
+    test('dashboard without foreach', async () => {
+      stubRestApi('getDashboard').callsFake(() =>
+        Promise.resolve({
+          id: '' as DashboardId,
+          project: 'project' as RepoName,
+          defining_project: '' as RepoName,
+          ref: '',
+          path: '',
+          url: '',
+          title: 'title',
+          sections: [
+            {name: 'section 1', query: 'query 1'},
+            {name: 'section 2', query: '${project} query 2'},
+          ],
+        })
+      );
+      const dashboard = await element.getProjectDashboard(
+        'project' as RepoName,
+        '' as DashboardId
+      );
+      assert.deepEqual(dashboard, {
+        title: 'title',
+        sections: [
+          {name: 'section 1', query: 'query 1'},
+          {name: 'section 2', query: 'project query 2'},
+        ],
+      });
+    });
+  });
+
+  test('hideIfEmpty sections', 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);
+    assert.equal(element.results!.length, 1);
+    assert.equal(element.results![0].name, 'test1');
+  });
+
+  test('sets slot name to section name if custom state is requested', async () => {
+    const sections = [
+      {name: 'Outgoing reviews', query: 'test1'},
+      {name: 'test2', query: 'test2'},
+    ];
+    getChangesStub.returns(Promise.resolve([[], []]));
+
+    await element.fetchDashboardChanges({sections}, false);
+    assert.equal(element.results!.length, 2);
+    assert.equal(element.results![0].emptyStateSlotName, 'outgoing-slot');
+    assert.isNotOk(element.results![1].emptyStateSlotName);
+  });
+
+  test('toggling star will update change everywhere', () => {
+    // It is important that the same change is represented by multiple objects
+    // and all are updated.
+    const change = {...createChange(), id: '5' as ChangeInfoId, starred: false};
+    const sameChange = {
+      ...createChange(),
+      id: '5' as ChangeInfoId,
+      starred: false,
+    };
+    const differentChange = {
+      ...createChange(),
+      id: '4' as ChangeInfoId,
+      starred: false,
+    };
+    element.results = [
+      {name: '', countLabel: '', query: 'has:draft', results: [change]},
+      {
+        name: '',
+        countLabel: '',
+        query: 'is:open',
+        results: [sameChange, differentChange],
+      },
+    ];
+
+    element.handleToggleStar(
+      new CustomEvent('toggle-star', {
+        detail: {
+          change,
+          starred: true,
+        },
+      })
+    );
+
+    assert.isTrue(change.starred);
+    assert.isTrue(sameChange.starred);
+    assert.isFalse(differentChange.starred);
+  });
+
+  test('showNewUserHelp', async () => {
+    element.loading = false;
+    element.showNewUserHelp = false;
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#emptyOutgoing'
+      ).textContent!.trim(),
+      'No changes'
+    );
+    query<GrCreateChangeHelp>(element, 'gr-create-change-help');
+    assert.isNotOk(query<GrCreateChangeHelp>(element, 'gr-create-change-help'));
+    element.showNewUserHelp = true;
+    await element.updateComplete;
+
+    assert.notEqual(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#emptyOutgoing'
+      ).textContent!.trim(),
+      'No changes'
+    );
+    assert.isOk(query<GrCreateChangeHelp>(element, 'gr-create-change-help'));
+  });
+
+  test('gr-user-header', async () => {
+    element.params = undefined;
+    await element.updateComplete;
+    assert.isNotOk(query(element, 'gr-user-header'));
+
+    element.params = {
+      view: GerritView.DASHBOARD,
+      dashboard: '' as DashboardId,
+      user: 'self',
+    };
+    await element.updateComplete;
+    assert.isNotOk(query(element, 'gr-user-header'));
+
+    element.loading = false;
+    element.params = {
+      view: GerritView.DASHBOARD,
+      dashboard: '' as DashboardId,
+      user: 'user',
+    };
+    await element.updateComplete;
+    assert.isOk(query(element, 'gr-user-header'));
+
+    element.params = {
+      view: GerritView.DASHBOARD,
+      dashboard: '' as DashboardId,
+      project: 'p' as RepoName,
+      user: 'user',
+    };
+    await element.updateComplete;
+    assert.isNotOk(query(element, 'gr-user-header'));
+  });
+
+  test('404 page', async () => {
+    const response = {...new Response(), status: 404};
+    stubRestApi('getDashboard').callsFake(
+      async (_project, _dashboard, errFn) => {
+        if (errFn !== undefined) {
+          errFn(response);
+        }
+        return Promise.resolve(undefined);
+      }
+    );
+    const promise = mockPromise();
+    addListenerForTest(document, 'page-error', e => {
+      assert.strictEqual((e as PageErrorEvent).detail.response, response);
+      promise.resolve();
+    });
+    element.params = {
+      view: GerritView.DASHBOARD,
+      dashboard: 'dashboard' as DashboardId,
+      project: 'project' as RepoName,
+      user: '',
+    };
+    await Promise.all([paramsChangedPromise, promise]);
+  });
+
+  test('params change triggers dashboardDisplayed()', async () => {
+    stubRestApi('getDashboard').returns(
+      Promise.resolve({
+        id: '' as DashboardId,
+        project: 'project' as RepoName,
+        defining_project: '' as RepoName,
+        ref: '',
+        path: '',
+        url: '',
+        title: 'title',
+        foreach: 'foreach for ${project}',
+        sections: [],
+      })
+    );
+    getChangesStub.returns(Promise.resolve([]));
+    const dashboardDisplayedStub = stubReporting('dashboardDisplayed');
+    element.params = {
+      view: GerritView.DASHBOARD,
+      dashboard: 'dashboard' as DashboardId,
+      project: 'project' as RepoName,
+      user: '',
+    };
+    await paramsChangedPromise;
+    assert.isTrue(dashboardDisplayedStub.calledOnce);
+  });
+
+  test('selectedChangeIndex is derived from the params', async () => {
+    stubRestApi('getDashboard').returns(
+      Promise.resolve({
+        id: '' as DashboardId,
+        project: 'project' as RepoName,
+        defining_project: '' as RepoName,
+        ref: '',
+        path: '',
+        url: '',
+        title: 'title',
+        foreach: 'foreach for ${project}',
+        sections: [],
+      })
+    );
+    element.viewState = {
+      101001: 23,
+    };
+    element.params = {
+      view: GerritView.DASHBOARD,
+      dashboard: 'dashboard' as DashboardId,
+      project: 'project' as RepoName,
+      user: '101001',
+    };
+    await element.updateComplete;
+    stubReporting('dashboardDisplayed');
+    await paramsChangedPromise;
+    assert.equal(element.selectedChangeIndex, 23);
+  });
+});
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 d8949ed..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
@@ -18,7 +18,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {RepoName} from '../../../types/common';
 import {WebLinkInfo} from '../../../types/diff';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {dashboardHeaderStyles} from '../../../styles/dashboard-header-styles';
@@ -36,7 +36,7 @@
   @property({type: Array})
   _webLinks: WebLinkInfo[] = [];
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
@@ -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 50de7b9..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
@@ -22,7 +22,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {AccountDetailInfo, AccountId} from '../../../types/common';
 import {getDisplayName} from '../../../utils/display-name-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {dashboardHeaderStyles} from '../../../styles/dashboard-header-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
@@ -46,7 +46,7 @@
   @property({type: String})
   _status = '';
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
@@ -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 d189116..4d58fbf 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,11 +29,9 @@
 import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
 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-change-actions_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {CURRENT} from '../../../utils/patch-set-util';
 import {
   changeIsOpen,
@@ -48,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,
@@ -64,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';
@@ -87,19 +82,22 @@
   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,
   StandardLabels,
 } from '../../../utils/label-util';
-import {CommentThread} from '../../../utils/comment-util';
 import {ShowAlertEventDetail} from '../../../types/events';
 import {
   ActionPriority,
@@ -110,14 +108,19 @@
 } from '../../../api/change-actions';
 import {ErrorCallback} from '../../../api/rest';
 import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
+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.';
 const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
-const CHERRYPICK_IN_PROGRESS = 'Cherry-pick in progress...';
-const CHERRYPICK_COMPLETED = 'Cherry-pick completed';
 
-enum LabelStatus {
+export enum LabelStatus {
   /**
    * This label provides what is necessary for submission.
    */
@@ -322,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 PolymerElement
+  extends LitElement
   implements GrChangeActionsElement
 {
-  static get template() {
-    return htmlTemplate;
-  }
-
   /**
    * Fired when the change should be reloaded.
    *
@@ -375,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
@@ -384,11 +394,12 @@
 
   RevisionActions = RevisionActions;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
-  private readonly jsAPI = appContext.jsApiService;
+  // Accessed in tests
+  readonly jsAPI = getAppContext().jsApiService;
 
-  private readonly changeService = appContext.changeService;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
   @property({type: Object})
   change?: ChangeViewChangeInfo;
@@ -408,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;
@@ -423,7 +434,7 @@
   @property({type: String})
   commitNum?: CommitId;
 
-  @property({type: Boolean, observer: '_computeChainState'})
+  @property({type: Boolean})
   hasParent?: boolean;
 
   @property({type: String})
@@ -432,61 +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})
-  commentThreads: CommentThread[] = [];
+  // _computeAllActions always returns an array
+  // private but used in test
+  @state() allActionValues: UIActionInfo[] = [];
 
-  @property({
-    type: Array,
-    computed:
-      '_computeAllActions(actions.*, revisionActions.*,' +
-      'primaryActionKeys.*, _additionalActions.*, change, ' +
-      '_actionPriorityOverrides.*)',
-  })
-  _allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
+  // private but used in test
+  @state() topLevelActions?: UIActionInfo[];
 
-  @property({
-    type: Array,
-    computed:
-      '_computeTopLevelActions(_allActionValues.*, ' +
-      '_hiddenActions.*, editMode, _overflowActions.*)',
-    observer: '_filterPrimaryActions',
-  })
-  _topLevelActions?: UIActionInfo[];
+  // private but used in test
+  @state() topLevelPrimaryActions?: UIActionInfo[];
 
-  @property({type: Array})
-  _topLevelPrimaryActions?: UIActionInfo[];
+  // private but used in test
+  @state() topLevelSecondaryActions?: UIActionInfo[];
 
-  @property({type: Array})
-  _topLevelSecondaryActions?: UIActionInfo[];
+  @state() private menuActions?: MenuAction[];
 
-  @property({
-    type: Array,
-    computed:
-      '_computeMenuActions(_allActionValues.*, ' +
-      '_hiddenActions.*, _overflowActions.*)',
-  })
-  _menuActions?: MenuAction[];
-
-  @property({type: Array})
-  _overflowActions: OverflowAction[] = [
+  @state() private overflowActions: OverflowAction[] = [
     {
       type: ActionType.CHANGE,
       key: ChangeActions.WIP,
@@ -509,14 +498,6 @@
     },
     {
       type: ActionType.CHANGE,
-      key: ChangeActions.IGNORE,
-    },
-    {
-      type: ActionType.CHANGE,
-      key: ChangeActions.UNIGNORE,
-    },
-    {
-      type: ActionType.CHANGE,
       key: ChangeActions.REVIEWED,
     },
     {
@@ -541,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;
@@ -562,42 +541,333 @@
   @property({type: Boolean})
   editBasedOnCurrentPatchSet = true;
 
-  @property({type: Object})
-  _config?: ServerInfo;
-
   @property({type: Boolean})
   loggedIn = false;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly storage = getAppContext().storageService;
 
   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.actions = this.change?.actions ?? {};
+    }
+
+    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
   ) {
@@ -618,7 +888,7 @@
     }
     const change = this.change;
 
-    this._loading = true;
+    this.loading = true;
     return this.restApiService
       .getChangeRevisionActions(this.changeNum, this.latestPatchNum)
       .then(revisionActions => {
@@ -627,37 +897,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}`);
@@ -669,16 +935,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>(
@@ -686,26 +954,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');
     }
   }
 
@@ -717,7 +985,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 = {
@@ -726,9 +994,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');
     }
   }
 
@@ -741,11 +1011,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');
     }
   }
 
@@ -759,182 +1031,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;
@@ -953,7 +1154,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;
     }
@@ -968,7 +1169,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
@@ -996,7 +1197,6 @@
       codeReviewPermittedValues &&
       this.account?._account_id &&
       isDetailedLabelInfo(codeReviewLabel) &&
-      this._getLabelStatus(codeReviewLabel) === LabelStatus.OK &&
       !isOwner(this.change, this.account) &&
       getApprovalInfo(codeReviewLabel, this.account)?.value !==
         getVotingRange(codeReviewLabel)?.max
@@ -1025,20 +1225,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;
     }
@@ -1060,32 +1260,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 => {
@@ -1095,26 +1286,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 => {
@@ -1125,7 +1315,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) {
@@ -1140,7 +1330,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.
@@ -1149,34 +1339,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;
@@ -1198,21 +1392,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;
@@ -1220,10 +1416,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
@@ -1237,11 +1429,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;
@@ -1258,147 +1449,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,
@@ -1406,16 +1610,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;
@@ -1424,10 +1632,9 @@
       fireAlert(this, ERR_COMMIT_EMPTY);
       return;
     }
-    this.$.overlay.close();
+    this.overlay.close();
     el.hidden = true;
-    fireAlert(this, CHERRYPICK_IN_PROGRESS);
-    this._fireAction(
+    this.fireAction(
       '/cherrypick',
       assertUIActionInfo(this.revisionActions.cherrypick),
       true,
@@ -1440,29 +1647,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,
@@ -1472,7 +1684,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,
@@ -1484,11 +1696,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,
@@ -1498,54 +1713,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();
 
-    this._fireAction(
+    // We need to make sure that all cached version of a change
+    // edit are deleted.
+    this.storage.eraseEditableContentItemsForChangeEdit(this.changeNum);
+
+    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'
@@ -1555,14 +1778,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 = [];
       };
     }
 
@@ -1576,38 +1797,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();
       }
@@ -1615,8 +1839,9 @@
   }
 
   // TODO(rmistry): Redo this after
-  // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
-  _setReviewOnRevert(newChangeId: NumericChangeId) {
+  // https://issues.gerritcodereview.com/issues/40004936 is resolved.
+  // private but used in test
+  setReviewOnRevert(newChangeId: NumericChangeId) {
     const review = this.jsAPI.getReviewPostRevert(this.change);
     if (!review) {
       return Promise.resolve(undefined);
@@ -1624,7 +1849,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;
     }
@@ -1632,8 +1858,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);
             });
@@ -1641,12 +1867,9 @@
         }
         case RevisionActions.CHERRYPICK: {
           const cherrypickChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
-          this._waitForChangeReachable(cherrypickChangeInfo._number).then(
-            () => {
-              fireAlert(this, CHERRYPICK_COMPLETED);
-              GerritNav.navigateToChange(cherrypickChangeInfo);
-            }
-          );
+          this.waitForChangeReachable(cherrypickChangeInfo._number).then(() => {
+            GerritNav.navigateToChange(cherrypickChangeInfo);
+          });
           break;
         }
         case ChangeActions.DELETE:
@@ -1670,7 +1893,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}`
           );
@@ -1683,7 +1906,8 @@
     });
   }
 
-  _handleResponseError(
+  // private but used in test
+  handleResponseError(
     action: UIActionInfo,
     response: Response | undefined | null,
     body?: RequestPayload
@@ -1705,7 +1929,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 => {
@@ -1722,7 +1951,8 @@
     });
   }
 
-  _send(
+  // private but used in test
+  send(
     method: HttpMethod | undefined,
     payload: RequestPayload | undefined,
     actionEndpoint: string,
@@ -1732,7 +1962,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;
@@ -1741,50 +1971,54 @@
         new Error('Properties change and changeNum must be set.')
       );
     }
-    return this.changeService.fetchChangeUpdates(change).then(result => {
-      if (!result.isLatest) {
-        this.dispatchEvent(
-          new CustomEvent<ShowAlertEventDetail>('show-alert', {
-            detail: {
-              message:
-                'Cannot set label: a newer patch has been ' +
-                'uploaded to this change.',
-              action: 'Reload',
-              callback: () => fireReload(this, true),
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
+    return this.getChangeModel()
+      .fetchChangeUpdates(change)
+      .then(result => {
+        if (!result.isLatest) {
+          this.dispatchEvent(
+            new CustomEvent<ShowAlertEventDetail>('show-alert', {
+              detail: {
+                message:
+                  'Cannot set label: a newer patch has been ' +
+                  'uploaded to this change.',
+                action: 'Reload',
+                callback: () => fireReload(this, true),
+              },
+              composed: true,
+              bubbles: true,
+            })
+          );
 
-        // Because this is not a network error, call the cleanup function
-        // but not the error handler.
-        cleanupFn();
+          // Because this is not a network error, call the cleanup function
+          // but not the error handler.
+          cleanupFn();
 
-        return Promise.resolve(undefined);
-      }
-      const patchNum = revisionAction ? this.latestPatchNum : undefined;
-      return this.restApiService
-        .executeChangeAction(
-          changeNum,
-          method,
-          actionEndpoint,
-          patchNum,
-          payload,
-          handleError
-        )
-        .then(response => {
-          cleanupFn.call(this);
-          return response;
-        });
-    });
+          return Promise.resolve(undefined);
+        }
+        const patchNum = revisionAction ? this.latestPatchNum : undefined;
+        return this.restApiService
+          .executeChangeAction(
+            changeNum,
+            method,
+            actionEndpoint,
+            patchNum,
+            payload,
+            handleError
+          )
+          .then(response => {
+            cleanupFn.call(this);
+            return response;
+          });
+      });
   }
 
-  _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,
@@ -1797,49 +2031,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() {
-    if (!this.actions.publishEdit) {
-      return;
-    }
-    this._fireAction(
+  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(
       '/edit:publish',
       assertUIActionInfo(this.actions.publishEdit),
       false,
@@ -1847,93 +2093,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
       );
 
@@ -1955,10 +2179,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;
@@ -1967,41 +2193,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;
@@ -2017,15 +2217,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.
@@ -2033,8 +2224,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 = () => {
@@ -2060,24 +2252,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 d21c29f..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
+++ /dev/null
@@ -1,266 +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"
-      change="[[change]]"
-      action="[[_revisionSubmitAction]]"
-      on-cancel="_handleConfirmDialogCancel"
-      on-confirm="_handleSubmitConfirm"
-      comment-threads="[[commentThreads]]"
-      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 f9413ce..3207279 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
@@ -34,6 +34,7 @@
   query,
   queryAll,
   queryAndAssert,
+  spyStorage,
   stubReporting,
   stubRestApi,
 } from '../../../test/test-utils';
@@ -53,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 {appContext} from '../../../services/app-context';
-
-const basicFixture = fixtureFromElement('gr-change-actions');
+import {getAppContext} from '../../../services/app-context';
+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: {
@@ -126,78 +132,62 @@
         .stub(getPluginLoader(), 'awaitPluginsLoaded')
         .returns(Promise.resolve());
 
-      element = basicFixture.instantiate();
-      element.change = createChangeViewChange();
-      element.changeNum = 42 as NumericChangeId;
-      element.latestPatchNum = 2 as PatchSetNum;
-      element.actions = {
-        '/': {
-          method: HttpMethod.DELETE,
-          label: 'Delete Change',
-          title: 'Delete change X_X',
-          enabled: true,
+      element = await fixture<GrChangeActions>(html`
+        <gr-change-actions></gr-change-actions>
+      `);
+      element.change = {
+        ...createChangeViewChange(),
+        actions: {
+          '/': {
+            method: HttpMethod.DELETE,
+            label: 'Delete Change',
+            title: 'Delete change X_X',
+            enabled: true,
+          },
         },
       };
+      element.changeNum = 42 as NumericChangeId;
+      element.latestPatchNum = 2 as PatchSetNum;
       element.account = {
         _account_id: 123 as AccountId,
       };
       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')
@@ -206,7 +196,7 @@
         'plugin~action': {},
       };
       assert.isOk(element.revisionActions['plugin~action']);
-      await flush();
+      await element.updateComplete;
       assert.isTrue(
         stub.calledWith(
           element.changeNum,
@@ -228,7 +218,7 @@
         'plugin~action': {},
       };
       assert.isOk(element.actions['plugin~action']);
-      await flush();
+      await element.updateComplete;
       assert.isTrue(
         stub.calledWith(element.changeNum, undefined, '/plugin~action')
       );
@@ -265,7 +255,7 @@
     });
 
     test('hide revision action', async () => {
-      await flush();
+      await element.updateComplete;
       let buttonEl: Element | undefined = queryAndAssert(
         element,
         '[data-action-key="submit"]'
@@ -276,14 +266,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);
 
@@ -292,31 +276,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');
     });
@@ -334,10 +322,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'},
@@ -353,16 +341,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: {
@@ -372,25 +362,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: {
@@ -400,18 +401,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',
@@ -420,77 +420,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',
@@ -501,7 +490,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, [
@@ -514,89 +503,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(appContext.jsApiService, 'getReviewPostRevert')
-        .returns(review);
+      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) => {
@@ -608,14 +616,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"]')
@@ -631,34 +639,82 @@
       });
 
       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.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = true;
+        await element.updateComplete;
+
+        const storage = getAppContext().storageService;
+        storage.setEditableContentItem(
+          'c42_ps2_index.php',
+          '<?php\necho 42_ps_2'
+        );
+        storage.setEditableContentItem(
+          'c42_psedit_index.php',
+          '<?php\necho 42_ps_edit'
+        );
+
+        assert.equal(
+          storage.getEditableContentItem('c42_ps2_index.php')!.message,
+          '<?php\necho 42_ps_2'
+        );
+        assert.equal(
+          storage.getEditableContentItem('c42_psedit_index.php')!.message,
+          '<?php\necho 42_ps_edit'
+        );
+
+        assert.isOk(storage.getEditableContentItem('c42_psedit_index.php')!);
+        assert.isOk(storage.getEditableContentItem('c42_ps2_index.php')!);
+        assert.isNotOk(storage.getEditableContentItem('c50_psedit_index.php')!);
+
+        const eraseEditableContentItemsForChangeEditSpy = spyStorage(
+          'eraseEditableContentItemsForChangeEdit'
+        );
+        sinon.stub(element, 'fireAction');
+        element.handleDeleteEditTap();
+        assert.isFalse(
+          queryAndAssert<GrDialog>(element, '#confirmDeleteEditDialog').hidden
+        );
+        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"]')
@@ -670,15 +726,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(
@@ -690,14 +746,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"]')
@@ -713,14 +769,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"]')
@@ -736,17 +792,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"]'));
@@ -754,34 +810,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"]')
@@ -796,12 +851,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',
@@ -812,24 +867,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, [
@@ -845,8 +917,8 @@
         ]);
       });
 
-      test('cherry pick even with conflicts', () => {
-        element._handleCherrypickTap();
+      test('cherry pick even with conflicts', async () => {
+        element.handleCherrypickTap();
         const action = {
           __key: 'cherrypick',
           __type: 'revision',
@@ -857,14 +929,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',
@@ -881,10 +967,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', () => {
@@ -908,20 +1003,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 = [
@@ -957,11 +1060,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([
             {
@@ -979,7 +1085,7 @@
               project: 'A' as RepoName,
             },
           ]);
-          await flush();
+          await element.updateComplete;
           assert.equal(
             error.innerText,
             'Two changes cannot be of the same' + ' project'
@@ -991,8 +1097,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: {
@@ -1002,25 +1108,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
+        );
       });
     });
 
@@ -1035,25 +1147,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);
 
@@ -1063,29 +1179,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: {
@@ -1095,43 +1211,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 = {
@@ -1157,29 +1293,48 @@
     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 = {
-          revert: {
-            method: HttpMethod.POST,
-            label: 'Revert',
-            title: 'Revert the change',
-            enabled: true,
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abcdef' as CommitId,
+          actions: {
+            revert: {
+              method: HttpMethod.POST,
+              label: 'Revert',
+              title: 'Revert the change',
+              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(),
           current_revision: 'abc1234' as CommitId,
+          actions: {
+            revert: {
+              method: HttpMethod.POST,
+              label: 'Revert',
+              title: 'Revert the change',
+              enabled: true,
+            },
+          },
         };
         stubRestApi('getChanges').returns(
           Promise.resolve([
@@ -1199,27 +1354,41 @@
         );
         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,
             current_revision: '2000' as CommitId,
+            actions: {
+              revert: {
+                method: HttpMethod.POST,
+                label: 'Revert',
+                title: 'Revert the change',
+                enabled: true,
+              },
+            },
           };
           getChangesStub = stubRestApi('getChanges').returns(
             Promise.resolve([
@@ -1237,25 +1406,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'
           );
@@ -1274,48 +1447,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"]'
           );
@@ -1334,29 +1517,39 @@
             '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,
             current_revision: '2000' as CommitId,
+            actions: {
+              revert: {
+                method: HttpMethod.POST,
+                label: 'Revert',
+                title: 'Revert the change',
+                enabled: true,
+              },
+            },
           };
           stubRestApi('getChanges').returns(
             Promise.resolve([
@@ -1368,35 +1561,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"]'
@@ -1406,15 +1609,30 @@
             'Revert "random commit message"\n\n' +
             'This reverts commit 2000.\n\nReason ' +
             'for revert: <INSERT REASONING HERE>\n';
-          assert.equal(confirmRevertDialog._message, msg);
-          const editedMsg = msg + 'hello';
-          confirmRevertDialog._message += 'hello';
-          const confirmButton = queryAndAssert(
-            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+          assert.equal(confirmRevertDialog.message, msg);
+          let editedMsg = msg + 'hello';
+          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(
+            '<INSERT REASONING HERE>',
+            ''
+          );
+          editedMsg = editedMsg.replace('<INSERT REASONING HERE>', '');
+          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);
@@ -1423,7 +1641,7 @@
     });
 
     suite('mark change private', () => {
-      setup(() => {
+      setup(async () => {
         const privateAction = {
           __key: 'private',
           __type: 'change',
@@ -1443,34 +1661,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',
@@ -1490,28 +1715,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"]'
+          )
         );
       });
     });
@@ -1520,141 +1752,56 @@
       let fireActionStub: sinon.SinonStub;
       let deleteAction: ActionInfo;
 
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        element.change = {
-          ...createChangeViewChange(),
-          current_revision: 'abc1234' as CommitId,
-        };
+      setup(async () => {
+        fireActionStub = sinon.stub(element, 'fireAction');
         deleteAction = {
           method: HttpMethod.DELETE,
           label: 'Delete Change',
           title: 'Delete change X_X',
           enabled: true,
         };
-        element.actions = {
-          '/': deleteAction,
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          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);
       });
     });
 
-    suite('ignore change', () => {
-      setup(async () => {
-        sinon.stub(element, '_fireAction');
-
-        const IgnoreAction = {
-          __key: 'ignore',
-          __type: 'change',
-          __primary: false,
-          method: HttpMethod.PUT,
-          label: 'Ignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          ignore: IgnoreAction,
-        };
-
-        element.changeNum = 2 as NumericChangeId;
-        element.latestPatchNum = 2 as PatchSetNum;
-
-        await element.reload();
-        await flush();
-      });
-
-      test('make sure the ignore button is not outside of the overflow menu', () => {
-        assert.isNotOk(query(element, '[data-action-key="ignore"]'));
-      });
-
-      test('ignoring change', async () => {
-        queryAndAssert(element.$.moreActions, 'span[data-id="ignore-change"]');
-        element.setActionOverflow(ActionType.CHANGE, 'ignore', false);
-        await flush();
-        queryAndAssert(element, '[data-action-key="ignore"]');
-        assert.isNotOk(
-          query(element.$.moreActions, 'span[data-id="ignore-change"]')
-        );
-      });
-    });
-
-    suite('unignore change', () => {
-      setup(async () => {
-        sinon.stub(element, '_fireAction');
-
-        const UnignoreAction = {
-          __key: 'unignore',
-          __type: 'change',
-          __primary: false,
-          method: HttpMethod.PUT,
-          label: 'Unignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          unignore: UnignoreAction,
-        };
-
-        element.changeNum = 2 as NumericChangeId;
-        element.latestPatchNum = 2 as PatchSetNum;
-
-        await element.reload();
-        await flush();
-      });
-
-      test('unignore button is not outside of the overflow menu', () => {
-        assert.isNotOk(query(element, '[data-action-key="unignore"]'));
-      });
-
-      test('unignoring change', async () => {
-        assert.isOk(
-          query(element.$.moreActions, 'span[data-id="unignore-change"]')
-        );
-        element.setActionOverflow(ActionType.CHANGE, 'unignore', false);
-        await flush();
-        assert.isOk(query(element, '[data-action-key="unignore"]'));
-        assert.isNotOk(
-          query(element.$.moreActions, 'span[data-id="unignore-change"]')
-        );
-      });
-    });
-
     suite('quick approve', () => {
       setup(async () => {
         element.change = {
@@ -1673,7 +1820,7 @@
             foo: ['-1', ' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
       });
 
       test('added when can approve', () => {
@@ -1694,7 +1841,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']"
@@ -1704,8 +1851,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');
       });
 
@@ -1715,7 +1864,7 @@
           status: ChangeStatus.MERGED,
         };
 
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1737,7 +1886,7 @@
             foo: [' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1756,7 +1905,7 @@
             bar: [],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1764,10 +1913,39 @@
         assert.isNotOk(approveButton);
       });
 
+      test('added even when label is optional', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            'Code-Review': {
+              optional: true,
+              values: {
+                '-1': '',
+                ' 0': '',
+                '+1': '',
+              },
+            },
+          },
+          permitted_labels: {
+            'Code-Review': ['-1', ' 0', '+1'],
+          },
+        };
+        await element.updateComplete;
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isOk(approveButton);
+      });
+
       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];
@@ -1787,7 +1965,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1817,7 +1995,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1843,7 +2021,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1869,7 +2047,7 @@
             bar: [' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1895,7 +2073,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1921,8 +2099,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']"
         );
@@ -1954,7 +2132,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1981,7 +2159,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1994,8 +2172,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);
     });
@@ -2008,26 +2186,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'
         );
       });
@@ -2035,15 +2213,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();
@@ -2064,13 +2242,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);
@@ -2080,7 +2258,7 @@
 
         test('fail', async () => {
           stubRestApi('getChange').callsFake(makeGetChange(6));
-          const promise = element._waitForChangeReachable(
+          const promise = element.waitForChangeReachable(
             123 as NumericChangeId
           );
           tickAndFlush(6);
@@ -2090,14 +2268,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;
@@ -2106,7 +2284,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);
@@ -2133,7 +2312,7 @@
         });
 
         test('change action', async () => {
-          await element._send(
+          await element.send(
             HttpMethod.DELETE,
             payload,
             '/endpoint',
@@ -2155,7 +2334,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(
@@ -2174,6 +2353,7 @@
                 },
               ])
             );
+            await element.updateComplete;
           });
         });
 
@@ -2190,7 +2370,7 @@
           });
 
           test('revert submission single change', async () => {
-            await element._send(
+            await element.send(
               HttpMethod.POST,
               {message: 'Revert submission'},
               '/revert_submission',
@@ -2198,7 +2378,7 @@
               cleanup,
               {} as UIActionInfo
             );
-            await element._handleResponse(
+            await element.handleResponse(
               {
                 __key: 'revert_submission',
                 __type: ActionType.CHANGE,
@@ -2222,7 +2402,7 @@
                 ],
               })
             );
-            showActionDialogStub = sinon.stub(element, '_showActionDialog');
+            showActionDialogStub = sinon.stub(element, 'showActionDialog');
             navigateToSearchQueryStub = sinon.stub(
               GerritNav,
               'navigateToSearchQuery'
@@ -2230,7 +2410,7 @@
           });
 
           test('revert submission multiple change', async () => {
-            await element._send(
+            await element.send(
               HttpMethod.POST,
               {message: 'Revert submission'},
               '/revert_submission',
@@ -2238,7 +2418,7 @@
               cleanup,
               {} as UIActionInfo
             );
-            await element._handleResponse(
+            await element.handleResponse(
               {
                 __key: 'revert_submission',
                 __type: ActionType.CHANGE,
@@ -2252,7 +2432,7 @@
         });
 
         test('revision action', async () => {
-          await element._send(
+          await element.send(
             HttpMethod.DELETE,
             payload,
             '/endpoint',
@@ -2283,7 +2463,7 @@
           const sendStub = stubRestApi('executeChangeAction');
 
           return element
-            ._send(
+            .send(
               HttpMethod.DELETE,
               payload,
               '/endpoint',
@@ -2310,14 +2490,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',
@@ -2335,12 +2516,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');
     });
@@ -2351,7 +2532,7 @@
 
     let changeRevisionActions: ActionNameToActionInfoMap = {};
 
-    setup(() => {
+    setup(async () => {
       stubRestApi('getChangeRevisionActions').returns(
         Promise.resolve(changeRevisionActions)
       );
@@ -2361,7 +2542,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();
@@ -2369,33 +2552,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 24d0e76..2c80a86 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
@@ -33,13 +33,6 @@
 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,19 +40,21 @@
   InheritedBooleanInfoConfiguredValue,
   SubmitType,
 } from '../../../constants/constants';
-import {changeIsOpen} from '../../../utils/change-util';
-import {customElement, property, observe} from '@polymer/decorators';
+import {changeIsOpen, isOwner} from '../../../utils/change-util';
 import {
   AccountDetailInfo,
   AccountInfo,
+  ApprovalInfo,
   BranchName,
   ChangeInfo,
   CommitId,
   CommitInfo,
   ConfigInfo,
-  ElementPropertyDeepChange,
   GpgKeyInfo,
   Hashtag,
+  isAccount,
+  isDetailedLabelInfo,
+  LabelInfo,
   LabelNameToInfoMap,
   NumericChangeId,
   ParentCommitInfo,
@@ -72,7 +67,7 @@
 import {assertNever, unique} from '../../../utils/common-util';
 import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
 import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {
   Metadata,
   isSectionSet,
@@ -90,11 +85,22 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {Interaction} from '../../../constants/reporting';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {
+  getApprovalInfo,
+  getCodeReviewLabel,
+  showNewSubmitRequirements,
+} 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',
@@ -123,320 +129,773 @@
   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;
+  @property({type: Object}) repoConfig?: ConfigInfo;
 
-  @property({type: Boolean})
-  parentIsCurrent?: boolean;
+  // private but used in test
+  @state() mutable = false;
 
-  @property({type: String})
-  readonly _notCurrentMessage = NOT_CURRENT_MESSAGE;
+  @state() private readonly notCurrentMessage = NOT_CURRENT_MESSAGE;
 
-  @property({
-    type: Boolean,
-    computed: '_computeTopicReadOnly(_mutable, change)',
-  })
-  _topicReadOnly = true;
+  // private but used in test
+  @state() topicReadOnly = true;
 
-  @property({
-    type: Boolean,
-    computed: '_computeHashtagReadOnly(_mutable, change)',
-  })
-  _hashtagReadOnly = true;
+  // private but used in test
+  @state() hashtagReadOnly = true;
 
-  @property({
-    type: Object,
-    computed:
-      '_computePushCertificateValidation(serverConfig, change, repoConfig)',
-  })
-  _pushCertificateValidation?: PushCertificateValidationInfo;
+  @state() private pushCertificateValidation?: PushCertificateValidationInfo;
 
-  @property({type: Boolean, computed: '_computeShowRequirements(change)'})
-  _showRequirements = false;
+  // private but used in test
+  @state() settingTopic = false;
 
-  @property({type: Array})
-  _assignee?: AccountInfo[];
+  // 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;
+  private restApiService = getAppContext().restApiService;
 
-  @property({type: Array, computed: '_computeParents(change, revision)'})
-  _currentParents: ParentCommitInfo[] = [];
+  private readonly reporting = getAppContext().reportingService;
 
-  @property({type: Object})
-  _CHANGE_ROLE = ChangeRole;
+  private readonly flagsService = getAppContext().flagsService;
 
-  @property({type: Object})
-  _SECTION = Metadata;
+  constructor() {
+    super();
+    this.queryTopic = (input: string) => this.getTopicSuggestions(input);
+  }
 
-  @property({type: Boolean})
-  _showAllSections = false;
+  static override styles = [
+    sharedStyles,
+    fontStyles,
+    changeMetadataStyles,
+    css`
+      :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;
+      }
+      gr-vote-chip {
+        --gr-vote-chip-width: 14px;
+        --gr-vote-chip-height: 14px;
+      }
+    `,
+  ];
 
-  @property({type: Object})
-  queryTopic?: AutocompleteQuery;
+  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>`;
+  }
 
-  @property({type: Boolean})
-  _isSubmitRequirementsUiEnabled = 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>`;
+  }
 
-  @property({type: Object})
-  repoConfig?: ConfigInfo;
+  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> `;
+  }
 
-  restApiService = appContext.restApiService;
+  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 readonly reporting = appContext.reportingService;
+  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>`;
+  }
 
-  private readonly flagsService = appContext.flagsService;
+  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>`;
+  }
 
-  override ready() {
-    super.ready();
-    this.queryTopic = (input: string) => this._getTopicSuggestions(input);
-    this._isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-      KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+  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>`
     );
   }
 
-  @observe('change.labels')
-  _labelsChanged(labels?: LabelNameToInfoMap) {
-    this.labels = {...labels};
+  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>`;
   }
 
-  @observe('change')
-  _changeChanged(change?: ParsedChangeInfo) {
-    this._assignee = change?.assignee ? [change.assignee] : [];
-    this._settingTopic = false;
+  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>`;
   }
 
-  @observe('_assignee.*')
-  _assigneeChanged(
-    assigneeRecord: ElementPropertyDeepChange<GrChangeMetadata, '_assignee'>
-  ) {
-    if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
-      return;
-    }
-    const assignee = assigneeRecord.base;
-    if (assignee?.length) {
-      const acct = assignee[0];
-      if (
-        !acct._account_id ||
-        (this.change.assignee &&
-          acct._account_id === this.change.assignee._account_id)
-      ) {
-        return;
-      }
-      this.set(['change', 'assignee'], acct);
-      this.restApiService.setAssignee(this.change._number, acct._account_id);
+  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
+            ></gr-editable-label>
+          `
+        )}
+      </span>
+    </section>`;
+  }
+
+  private renderSubmitRequirements() {
+    if (this.showNewSubmitRequirements()) {
+      return html`<div class="separatedSection">
+        <gr-submit-requirements
+          .change=${this.change}
+          .account=${this.account}
+          .mutable=${this.mutable}
+        ></gr-submit-requirements>
+      </div>`;
     } else {
-      if (!this.change.assignee) {
-        return;
-      }
-      this.set(['change', 'assignee'], undefined);
-      this.restApiService.deleteAssignee(this.change._number);
+      return html` <div class="oldSeparatedSection">
+        <gr-change-requirements
+          .change=${this.change}
+          .account=${this.account}
+          .mutable=${this.mutable}
+        ></gr-change-requirements>
+      </div>`;
     }
   }
 
-  _computeHideStrategy(change?: ParsedChangeInfo) {
-    return !changeIsOpen(change);
+  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') ||
+      changedProperties.has('repoConfig')
+    ) {
+      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;
-  }
-
-  _isAssigneeEnabled(serverConfig?: ServerInfo) {
-    return !!serverConfig?.change?.enable_assignee;
-  }
-
-  _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
-  ) {
-    const hasTopic = !!changeRecord?.base?.topic;
-    return !hasTopic && !settingTopic;
+  // private but used in test
+  showAddTopic() {
+    const hasTopic = !!this.change?.topic;
+    return !hasTopic && !this.settingTopic && this.topicReadOnly === false;
   }
 
-  _showTopicChip(
-    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
-    settingTopic?: boolean
-  ) {
-    const hasTopic = !!changeRecord?.base?.topic;
-    return hasTopic && !settingTopic;
+  // private but used in test
+  showTopicChip() {
+    const hasTopic = !!this.change?.topic;
+    return hasTopic && !this.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;
   }
 
-  _computeAssigneeReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return !mutable || !change?.actions?.assignee?.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,
-    repoConfig?: ConfigInfo
-  ): 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;
 
-    if (!this.isEnabledSignedPushOnRepo(repoConfig)) {
+    if (!this.isEnabledSignedPushOnRepo()) {
       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',
@@ -451,13 +910,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
           ),
@@ -466,7 +925,7 @@
         return {
           class: 'trusted',
           icon: 'gr-icons:check',
-          message: this._problems(
+          message: this.problems(
             'Push certificate is valid and key is trusted',
             key
           ),
@@ -480,10 +939,10 @@
   }
 
   // private but used in test
-  isEnabledSignedPushOnRepo(repoConfig?: ConfigInfo) {
-    if (!repoConfig?.enable_signed_push) return false;
+  isEnabledSignedPushOnRepo() {
+    if (!this.repoConfig?.enable_signed_push) return false;
 
-    const enableSignedPush = repoConfig.enable_signed_push;
+    const enableSignedPush = this.repoConfig.enable_signed_push;
     return (
       (enableSignedPush.configured_value ===
         InheritedBooleanInfoConfiguredValue.INHERIT &&
@@ -493,7 +952,7 @@
     );
   }
 
-  _problems(msg: string, key: GpgKeyInfo) {
+  private problems(msg: string, key: GpgKeyInfo) {
     if (!key?.problems || key.problems.length === 0) {
       return msg;
     }
@@ -501,16 +960,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,
@@ -521,7 +981,7 @@
     );
   }
 
-  _computeCherryPickOfUrl(
+  private computeCherryPickOfUrl(
     change?: NumericChangeId,
     patchset?: PatchSetNum,
     project?: RepoName
@@ -532,147 +992,151 @@
     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
-  ) {
+  private computeDisplayState(section: Metadata, account?: AccountDetailInfo) {
+    // special case for Topic - show always for owners, others when set
+    if (section === Metadata.TOPIC) {
+      if (
+        this.showAllSections ||
+        isOwner(this.change, account) ||
+        isSectionSet(section, this.change)
+      ) {
+        return '';
+      } else {
+        return 'hideDisplay';
+      }
+    }
     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;
     }
@@ -680,7 +1144,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;
     }
@@ -688,7 +1152,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
       )
@@ -699,48 +1163,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
@@ -750,20 +1198,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 =>
@@ -777,14 +1214,28 @@
       );
   }
 
-  _showNewSubmitRequirements(change?: ParsedChangeInfo) {
-    if (!this._isSubmitRequirementsUiEnabled) return false;
-    return (change?.submit_requirements ?? []).length > 0;
+  private showNewSubmitRequirements() {
+    return showNewSubmitRequirements(this.flagsService, this.change);
   }
 
-  _showNewSubmitRequirementWarning(change?: ParsedChangeInfo) {
-    if (!this._isSubmitRequirementsUiEnabled) return false;
-    return (change?.submit_requirements ?? []).length === 0;
+  private computeVoteForRole(role: ChangeRole) {
+    const reviewer = this.getNonOwnerRole(role);
+    if (reviewer && isAccount(reviewer)) {
+      return this.computeVote(reviewer);
+    } else {
+      return;
+    }
+  }
+
+  private computeVote(reviewer: AccountInfo): ApprovalInfo | undefined {
+    const codeReviewLabel = this.computeCodeReviewLabel();
+    if (!codeReviewLabel || !isDetailedLabelInfo(codeReviewLabel)) return;
+    return getApprovalInfo(codeReviewLabel, reviewer);
+  }
+
+  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 c81c094..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ /dev/null
@@ -1,532 +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;
-    }
-    #externalStyle {
-      display: block;
-    }
-    .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;
-    }
-    .separatedSection {
-      margin-top: var(--spacing-l);
-      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;
-    }
-    .submit-requirement-error {
-      color: var(--deemphasized-text-color);
-      padding-left: var(--metadata-horizontal-padding);
-    }
-  </style>
-  <gr-external-style id="externalStyle" name="change-metadata">
-    <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
-        ></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
-        ></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]]"
-        ></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]]"
-        ></gr-account-chip>
-      </span>
-    </section>
-    <template is="dom-if" if="[[_isAssigneeEnabled(serverConfig)]]">
-      <section
-        class$="assignee [[_computeDisplayState(_showAllSections, change, _SECTION.ASSIGNEE)]]"
-      >
-        <span class="title">Assignee</span>
-        <span class="value">
-          <gr-account-list
-            id="assigneeValue"
-            placeholder="Set assignee..."
-            max-count="1"
-            accounts="{{_assignee}}"
-            readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
-            suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
-          >
-          </gr-account-list>
-        </span>
-      </section>
-    </template>
-    <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>
-    <section
-      class$="topic [[_computeDisplayState(_showAllSections, change, _SECTION.TOPIC)]]"
-    >
-      <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)]]">
-          <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="[[!_topicReadOnly]]"
-            autocomplete="true"
-            query="[[queryTopic]]"
-          ></gr-editable-label>
-        </template>
-      </span>
-    </section>
-    <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>
-    <div class="separatedSection">
-      <template is="dom-if" if="[[_showNewSubmitRequirements(change)]]">
-        <gr-submit-requirements
-          change="[[change]]"
-          account="[[account]]"
-          mutable="[[_mutable]]"
-        ></gr-submit-requirements>
-      </template>
-      <template is="dom-if" if="[[!_showNewSubmitRequirements(change)]]">
-        <gr-change-requirements
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[_mutable]]"
-        ></gr-change-requirements>
-      </template>
-      <template is="dom-if" if="[[_showNewSubmitRequirementWarning(change)]]">
-        <div class="submit-requirement-error">
-          New Submit Requirements don't work on this change.
-        </div>
-      </template>
-    </div>
-    <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>
-  </gr-external-style>
-`;
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 31908bb..a000445 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,18 +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 {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
-import {GrChangeMetadata} from './gr-change-metadata';
+import {ChangeRole, GrChangeMetadata} from './gr-change-metadata';
 import {
   createServerInfo,
   createUserConfig,
   createParsedChange,
   createAccountWithId,
-  createRequirement,
   createCommitInfoWithRequiredCommit,
   createWebLinkInfo,
   createGerritInfo,
@@ -35,13 +32,11 @@
   createCommit,
   createRevision,
   createAccountDetailWithId,
-  createChangeConfig,
   createConfig,
 } from '../../../test/test-data-generators';
 import {
   ChangeStatus,
   SubmitType,
-  RequirementStatus,
   GpgKeyInfoStatus,
   InheritedBooleanInfoConfiguredValue,
 } from '../../../constants/constants';
@@ -53,221 +48,246 @@
   RevisionInfo,
   ParentCommitInfo,
   TopicName,
-  ElementPropertyDeepChange,
   PatchSetNum,
   NumericChangeId,
   LabelValueToDescriptionMap,
   Hashtag,
+  CommitInfo,
 } from '../../../types/common';
-import {SinonStubbedMember} from 'sinon';
-import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 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');
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 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="oldSeparatedSection">
+      <gr-change-requirements></gr-change-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(),
@@ -280,25 +300,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(),
@@ -307,14 +326,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(),
@@ -323,14 +342,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(() => {
@@ -364,95 +383,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));
       });
     });
   });
@@ -504,11 +513,9 @@
           problems: ['No public keys found for key ID E5E20E52'],
         },
       };
-      const result = element._computePushCertificateValidation(
-        serverConfig,
-        change,
-        element.repoConfig
-      );
+      element.change = change;
+      element.serverConfig = serverConfig;
+      const result = element.computePushCertificateValidation();
       assert.equal(
         result?.message,
         'Push certificate is invalid:\n' +
@@ -525,11 +532,9 @@
           status: GpgKeyInfoStatus.TRUSTED,
         },
       };
-      const result = element._computePushCertificateValidation(
-        serverConfig,
-        change,
-        element.repoConfig
-      );
+      element.change = change;
+      element.serverConfig = serverConfig;
+      const result = element.computePushCertificateValidation();
       assert.equal(
         result?.message,
         'Push certificate is valid and key is trusted'
@@ -539,12 +544,10 @@
     });
 
     test('Push Certificate Validation is missing test', () => {
-      change!.revisions.rev1! = createRevision(1);
-      const result = element._computePushCertificateValidation(
-        serverConfig,
-        change,
-        element.repoConfig
-      );
+      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'
@@ -553,14 +556,11 @@
       assert.equal(result?.class, 'help');
     });
 
-    test('_computePushCertificateValidation returns undefined', () => {
+    test('computePushCertificateValidation returns undefined', () => {
+      element.change = change;
       delete serverConfig!.receive!.enable_signed_push;
-      const result = element._computePushCertificateValidation(
-        serverConfig,
-        change,
-        element.repoConfig
-      );
-      assert.isUndefined(result);
+      element.serverConfig = serverConfig;
+      assert.isUndefined(element.computePushCertificateValidation());
     });
 
     test('isEnabledSignedPushOnRepo', () => {
@@ -575,21 +575,21 @@
       element.repoConfig!.enable_signed_push!.configured_value =
         InheritedBooleanInfoConfiguredValue.INHERIT;
       element.repoConfig!.enable_signed_push!.inherited_value = true;
-      assert.isTrue(element.isEnabledSignedPushOnRepo(element.repoConfig));
+      assert.isTrue(element.isEnabledSignedPushOnRepo());
 
       element.repoConfig!.enable_signed_push!.inherited_value = false;
-      assert.isFalse(element.isEnabledSignedPushOnRepo(element.repoConfig));
+      assert.isFalse(element.isEnabledSignedPushOnRepo());
 
       element.repoConfig!.enable_signed_push!.configured_value =
         InheritedBooleanInfoConfiguredValue.TRUE;
-      assert.isTrue(element.isEnabledSignedPushOnRepo(element.repoConfig));
+      assert.isTrue(element.isEnabledSignedPushOnRepo());
 
       element.repoConfig = undefined;
-      assert.isFalse(element.isEnabledSignedPushOnRepo(element.repoConfig));
+      assert.isFalse(element.isEnabledSignedPushOnRepo());
     });
   });
 
-  test('_computeParents', () => {
+  test('computeParents', () => {
     const parents: ParentCommitInfo[] = [
       {...createCommit(), commit: '123' as CommitId, subject: 'abc'},
     ];
@@ -597,7 +597,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(),
@@ -605,22 +607,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(),
@@ -637,91 +642,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));
-    assert.isTrue(element._showAddTopic(changeRecord, false));
-    assert.isFalse(element._showAddTopic(changeRecord, true));
-    changeRecord.base!.topic = 'foo' as TopicName;
-    assert.isFalse(element._showAddTopic(changeRecord, true));
-    assert.isFalse(element._showAddTopic(changeRecord, false));
+  test('showAddTopic', () => {
+    const change = createParsedChange();
+    element.change = undefined;
+    element.settingTopic = false;
+    element.topicReadOnly = false;
+    assert.isTrue(element.showAddTopic());
+    // do not show for 'readonly'
+    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', () => {
@@ -746,22 +776,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'));
@@ -772,7 +808,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'));
@@ -799,40 +835,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'));
@@ -840,8 +893,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,
@@ -854,65 +907,7 @@
         },
         removable_reviewers: [],
       };
-      flush();
-    });
-
-    suite('assignee field', () => {
-      const dummyAccount = createAccountWithId();
-      const change: ParsedChangeInfo = {
-        ...createParsedChange(),
-        actions: {
-          assignee: {enabled: false},
-        },
-        assignee: dummyAccount,
-      };
-      let deleteStub: SinonStubbedMember<RestApiService['deleteAssignee']>;
-      let setStub: SinonStubbedMember<RestApiService['setAssignee']>;
-
-      setup(() => {
-        deleteStub = stubRestApi('deleteAssignee');
-        setStub = stubRestApi('setAssignee');
-        element.serverConfig = {
-          ...createServerInfo(),
-          change: {
-            ...createChangeConfig(),
-            enable_assignee: true,
-          },
-        };
-      });
-
-      test('changing change recomputes _assignee', () => {
-        assert.isFalse(!!element._assignee?.length);
-        const change = element.change;
-        change!.assignee = dummyAccount;
-        element._changeChanged(change);
-        assert.deepEqual(element?._assignee?.[0], dummyAccount);
-      });
-
-      test('modifying _assignee calls API', () => {
-        assert.isFalse(!!element._assignee?.length);
-        element.set('_assignee', [dummyAccount]);
-        assert.isTrue(setStub.calledOnce);
-        assert.deepEqual(element.change!.assignee, dummyAccount);
-        element.set('_assignee', [dummyAccount]);
-        assert.isTrue(setStub.calledOnce);
-        element.set('_assignee', []);
-        assert.isTrue(deleteStub.calledOnce);
-        assert.equal(element.change!.assignee, undefined);
-        element.set('_assignee', []);
-        assert.isTrue(deleteStub.calledOnce);
-      });
-
-      test('_computeAssigneeReadOnly', () => {
-        let mutable = false;
-        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-        mutable = true;
-        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-        change.actions!.assignee!.enabled = true;
-        assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
-        mutable = false;
-        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-      });
+      await element.updateComplete;
     });
 
     test('changing topic', () => {
@@ -920,7 +915,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(
@@ -938,7 +933,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();
@@ -954,13 +949,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],
@@ -978,7 +974,7 @@
       ...createParsedChange(),
       actions: {topic: {enabled: true}},
     };
-    await flush();
+    await element.updateComplete;
 
     const label = element.shadowRoot!.querySelector(
       '.topicEditableLabel'
@@ -986,38 +982,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;
-      pluginApi.install(
+      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
index 725bb24..821e1ce 100644
--- 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
@@ -33,7 +33,7 @@
   LabelInfo,
 } from '../../../types/common';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {labelCompare} from '../../../utils/label-util';
 import {Interaction} from '../../../constants/reporting';
 
@@ -85,7 +85,7 @@
   @property({type: Boolean})
   _showOptionalLabels = true;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   _computeShowWip(change: ChangeInfo) {
     return change.work_in_progress;
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
index 6991c03..8161592 100644
--- 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
@@ -154,7 +154,6 @@
           mutable="[[mutable]]"
           label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
-          showAlwaysOldUI
         ></gr-label-info>
       </div>
     </section>
@@ -205,7 +204,6 @@
           mutable="[[mutable]]"
           label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
-          showAlwaysOldUI
         ></gr-label-info>
       </div>
     </section>
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 ad8f72f..f5893ac 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
@@ -15,37 +15,31 @@
  * limitations under the License.
  */
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators';
 import {subscribe} from '../../lit/subscription-controller';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {
-  allRunsLatestPatchsetLatestAttempt$,
-  aPluginHasRegistered$,
   CheckResult,
   CheckRun,
   ErrorMessages,
-  errorMessagesLatest$,
-  loginCallbackLatest$,
-  someProvidersAreLoadingFirstTime$,
-  topLevelActionsLatest$,
-} from '../../../services/checks/checks-model';
-import {Action, Category, Link, RunStatus} from '../../../api/checks';
+} from '../../../models/checks/checks-model';
+import {Action, Category, RunStatus} from '../../../api/checks';
 import {fireShowPrimaryTab} from '../../../utils/event-util';
 import '../../shared/gr-avatar/gr-avatar';
 import '../../checks/gr-checks-action';
 import {
-  firstPrimaryLink,
+  compareByWorstCategory,
   getResultsOf,
   hasCompletedWithoutResults,
   hasResults,
   hasResultsOf,
   iconFor,
-  isRunning,
-  isRunningOrHasCompleted,
+  isRunningOrScheduled,
+  isRunningScheduledOrCompleted,
   isStatus,
   labelFor,
-} from '../../../services/checks/checks-util';
+} from '../../../models/checks/checks-util';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
   CommentThread,
@@ -65,6 +59,12 @@
 import {modifierPressed} from '../../../utils/dom-util';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {resolve} from '../../../models/dependency';
+import {checksModelToken} from '../../../models/checks/checks-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {Interaction} from '../../../constants/reporting';
+import {roleDetails} from '../../../utils/change-util';
 
 export enum SummaryChipStyles {
   INFO = 'info',
@@ -93,7 +93,7 @@
   @property()
   category?: CommentTabState;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   static override get styles() {
     return [
@@ -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>`;
   }
@@ -182,7 +182,9 @@
   text = '';
 
   @property()
-  links: Link[] = [];
+  links: string[] = [];
+
+  private readonly reporting = getAppContext().reportingService;
 
   static override get styles() {
     return [
@@ -214,7 +216,7 @@
           display: none;
         }
         .checksChip.hoverFullLength .text {
-          max-width: 400px;
+          max-width: 500px;
         }
         :host(:hover) .checksChip.hoverFullLength {
           display: inline-block;
@@ -232,6 +234,9 @@
           height: var(--line-height-small);
           vertical-align: top;
         }
+        .checksChip a iron-icon.launch {
+          color: var(--link-color);
+        }
         .checksChip.error {
           color: var(--error-foreground);
           border-color: var(--error-foreground);
@@ -289,18 +294,22 @@
         .checksChip.check-circle-outline iron-icon {
           color: var(--success-foreground);
         }
-        .checksChip.timelapse {
+        .checksChip.timelapse,
+        .checksChip.scheduled {
           border-color: var(--gray-foreground);
           background: var(--gray-background);
         }
-        .checksChip.timelapse:hover {
+        .checksChip.timelapse:hover,
+        .checksChip.scheduled:hover {
           background: var(--gray-background-hover);
           box-shadow: var(--elevation-level-1);
         }
-        .checksChip.timelapse:focus-within {
+        .checksChip.timelapse:focus-within,
+        .checksChip.scheduled:focus-within {
           background: var(--gray-background-focus);
         }
-        .checksChip.timelapse iron-icon {
+        .checksChip.timelapse iron-icon,
+        .checksChip.scheduled iron-icon {
           color: var(--gray-foreground);
         }
       `,
@@ -333,10 +342,10 @@
 
   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="text">${this.text}</div>
+      <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>
     `;
   }
@@ -345,10 +354,10 @@
     return this.links.map(
       link => html`
         <a
-          href="${link.url}"
+          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>
@@ -364,6 +373,10 @@
   private onLinkClick(e: MouseEvent) {
     // Prevents onChipClick() from reacting to <a> link clicks.
     e.stopPropagation();
+    this.reporting.reportInteraction(Interaction.CHECKS_CHIP_LINK_CLICKED, {
+      text: this.text,
+      status: this.statusOrCategory,
+    });
   }
 }
 
@@ -375,49 +388,96 @@
 
 @customElement('gr-change-summary')
 export class GrChangeSummary extends LitElement {
-  @property({type: Object})
+  @state()
   changeComments?: ChangeComments;
 
-  @property({type: Array})
+  @state()
   commentThreads?: CommentThread[];
 
-  @property({type: Object})
+  @state()
   selfAccount?: AccountInfo;
 
-  @property()
+  @state()
   runs: CheckRun[] = [];
 
-  @property()
+  @state()
   showChecksSummary = false;
 
-  @property()
+  @state()
   someProvidersAreLoading = false;
 
-  @property()
+  @state()
   errorMessages: ErrorMessages = {};
 
-  @property()
+  @state()
   loginCallback?: () => void;
 
-  @property()
+  @state()
   actions: Action[] = [];
 
-  private showAllChips = new Map<RunStatus | Category, boolean>();
+  @state()
+  messages: string[] = [];
 
-  private checksService = appContext.checksService;
+  private readonly showAllChips = new Map<RunStatus | Category, boolean>();
 
-  constructor() {
-    super();
-    subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
-    subscribe(this, aPluginHasRegistered$, x => (this.showChecksSummary = x));
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly getChecksModel = resolve(this, checksModelToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly reporting = getAppContext().reportingService;
+
+  override connectedCallback() {
+    super.connectedCallback();
     subscribe(
       this,
-      someProvidersAreLoadingFirstTime$,
+      this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
+      x => (this.runs = x)
+    );
+    subscribe(
+      this,
+      this.getChecksModel().aPluginHasRegistered$,
+      x => (this.showChecksSummary = x)
+    );
+    subscribe(
+      this,
+      this.getChecksModel().someProvidersAreLoadingFirstTime$,
       x => (this.someProvidersAreLoading = x)
     );
-    subscribe(this, errorMessagesLatest$, x => (this.errorMessages = x));
-    subscribe(this, loginCallbackLatest$, x => (this.loginCallback = x));
-    subscribe(this, topLevelActionsLatest$, x => (this.actions = x));
+    subscribe(
+      this,
+      this.getChecksModel().errorMessagesLatest$,
+      x => (this.errorMessages = x)
+    );
+    subscribe(
+      this,
+      this.getChecksModel().loginCallbackLatest$,
+      x => (this.loginCallback = x)
+    );
+    subscribe(
+      this,
+      this.getChecksModel().topLevelActionsLatest$,
+      x => (this.actions = x)
+    );
+    subscribe(
+      this,
+      this.getChecksModel().topLevelMessagesLatest$,
+      x => (this.messages = x)
+    );
+    subscribe(
+      this,
+      this.getCommentsModel().changeComments$,
+      x => (this.changeComments = x)
+    );
+    subscribe(
+      this,
+      this.getCommentsModel().threads$,
+      x => (this.commentThreads = x)
+    );
+    subscribe(this, this.userModel.account$, x => (this.selfAccount = x));
   }
 
   static override get styles() {
@@ -518,10 +578,18 @@
         .actions #moreMessage {
           display: none;
         }
+        .summaryMessage {
+          line-height: var(--line-height-normal);
+          color: var(--primary-text-color);
+        }
       `,
     ];
   }
 
+  private renderSummaryMessage() {
+    return this.messages.map(m => html`<div class="summaryMessage">${m}</div>`);
+  }
+
   private renderActions() {
     const actions = this.actions ?? [];
     const summaryActions = actions.filter(a => a.summary).slice(0, 2);
@@ -544,11 +612,18 @@
 
   private renderAction(action?: Action) {
     if (!action) return;
-    return html`<gr-checks-action .action="${action}"></gr-checks-action>`;
+    return html`<gr-checks-action
+      context="summary"
+      .action=${action}
+    ></gr-checks-action>`;
   }
 
   private handleAction(e: CustomEvent<Action>) {
-    this.checksService.triggerAction(e.detail);
+    this.getChecksModel().triggerAction(
+      e.detail,
+      undefined,
+      'summary-dropdown'
+    );
   }
 
   private renderOverflow(items: DropdownLink[], disabledIds: string[] = []) {
@@ -559,9 +634,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>
@@ -579,7 +654,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>
@@ -600,7 +675,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>
     `;
@@ -609,7 +684,7 @@
   renderChecksZeroState() {
     if (Object.keys(this.errorMessages).length > 0) return;
     if (this.loginCallback) return;
-    if (this.runs.some(isRunningOrHasCompleted)) return;
+    if (this.runs.some(isRunningScheduledOrCompleted)) return;
     const msg = this.someProvidersAreLoading ? 'Loading results' : 'No results';
     return html`<span role="status" class="loading zeroState">${msg}</span>`;
   }
@@ -623,18 +698,19 @@
     if (category === Category.SUCCESS || category === Category.INFO) {
       return this.renderChecksChipsCollapsed(runs, category, count);
     }
-    return this.renderChecksChipsExpanded(runs, category, count);
+    return this.renderChecksChipsExpanded(runs, category);
   }
 
   renderChecksChipRunning() {
-    const runs = this.runs.filter(isRunning);
-    return this.renderChecksChipsExpanded(runs, RunStatus.RUNNING, () => []);
+    const runs = this.runs
+      .filter(isRunningOrScheduled)
+      .sort(compareByWorstCategory);
+    return this.renderChecksChipsExpanded(runs, RunStatus.RUNNING);
   }
 
   renderChecksChipsExpanded(
     runs: CheckRun[],
-    statusOrCategory: RunStatus | Category,
-    resultFilter: (run: CheckRun) => CheckResult[]
+    statusOrCategory: RunStatus | Category
   ) {
     if (runs.length === 0) return;
     const showAll = this.showAllChips.get(statusOrCategory) ?? false;
@@ -644,7 +720,7 @@
     return html`${runs
       .slice(0, count)
       .map(run =>
-        this.renderChecksChipDetailed(run, statusOrCategory, resultFilter)
+        this.renderChecksChipDetailed(run, statusOrCategory)
       )}${this.renderChecksChipPlusMore(statusOrCategory, more)}`;
   }
 
@@ -660,10 +736,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>`;
   }
 
@@ -678,38 +754,48 @@
       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>`;
   }
 
   private renderChecksChipDetailed(
     run: CheckRun,
-    statusOrCategory: RunStatus | Category,
-    resultFilter: (run: CheckRun) => CheckResult[]
+    statusOrCategory: RunStatus | Category
   ) {
-    const allPrimaryLinks = resultFilter(run)
-      .map(firstPrimaryLink)
-      .filter(notUndefined);
-    const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
+    const links = [];
+    if (run.statusLink) links.push(run.statusLink);
     const text = `${run.checkName}`;
     const tabState: ChecksTabState = {
       checkName: run.checkName,
       statusOrCategory,
     };
+    // Scheduled runs are rendered in the RUNNING section, but the icon of the
+    // chip must be the one for SCHEDULED.
+    if (
+      statusOrCategory === RunStatus.RUNNING &&
+      run.status === RunStatus.SCHEDULED
+    ) {
+      statusOrCategory = RunStatus.SCHEDULED;
+    }
     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>`;
   }
 
   private onChipClick(state: ChecksTabState) {
+    this.reporting.reportInteraction(Interaction.CHECKS_CHIP_CLICKED, {
+      statusOrCategory: state.statusOrCategory,
+      checkName: state.checkName,
+      ...roleDetails(this.getChangeModel().getChange(), this.selfAccount),
+    });
     fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
       checksTab: state,
     });
@@ -727,7 +813,7 @@
     const hasNonRunningChip = this.runs.some(
       run => hasCompletedWithoutResults(run) || hasResults(run)
     );
-    const hasRunningChip = this.runs.some(isRunning);
+    const hasRunningChip = this.runs.some(isRunningOrScheduled);
     return html`
       <div>
         <table>
@@ -748,9 +834,10 @@
                   : ''}${this.renderChecksChipRunning()}
                 <span
                   class="loadingSpin"
-                  ?hidden="${!this.someProvidersAreLoading}"
+                  ?hidden=${!this.someProvidersAreLoading}
                 ></span>
-                ${this.renderErrorMessages()}${this.renderChecksLogin()}${this.renderActions()}
+                ${this.renderErrorMessages()} ${this.renderChecksLogin()}
+                ${this.renderSummaryMessage()} ${this.renderActions()}
               </div>
             </td>
           </tr>
@@ -779,7 +866,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 41d0931..2af8e2b 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
@@ -14,13 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {BehaviorSubject, Subscription} from 'rxjs';
 import '@polymer/paper-tabs/paper-tabs';
 import '../../../styles/gr-a11y-styles';
+import '../../../styles/gr-paper-styles';
 import '../../../styles/shared-styles';
-import '../../diff/gr-comment-api/gr-comment-api';
 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';
@@ -42,8 +42,8 @@
 import '../gr-reply-dialog/gr-reply-dialog';
 import '../gr-thread-list/gr-thread-list';
 import '../../checks/gr-checks-tab';
+import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-view_html';
 import {
   KeyboardShortcutMixin,
@@ -61,19 +61,21 @@
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
-import {DiffViewMode} from '../../../api/diff';
 import {
   ChangeStatus,
   DefaultBase,
   PrimaryTab,
   SecondaryTab,
+  DiffViewMode,
 } from '../../../constants/constants';
 
 import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
+  findEdit,
+  findEditParentRevision,
   hasEditBasedOnCurrentPatchSet,
   hasEditPatchsetLoaded,
   PatchSet,
@@ -83,10 +85,8 @@
   changeIsMerged,
   changeIsOpen,
   changeStatuses,
-  isCc,
   isInvolved,
-  isOwner,
-  isReviewer,
+  roleDetails,
 } from '../../../utils/change-util';
 import {EventType as PluginEventType} from '../../../api/plugin';
 import {customElement, observe, property} from '@polymer/decorators';
@@ -107,7 +107,6 @@
   CommitId,
   CommitInfo,
   ConfigInfo,
-  EditInfo,
   EditPatchSetNum,
   LabelNameToInfoMap,
   NumericChangeId,
@@ -127,18 +126,19 @@
 import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
 import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
 import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
-  ChangeComments,
-  GrCommentApi,
-} from '../../diff/gr-comment-api/gr-comment-api';
-import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
+  assertIsDefined,
+  hasOwnProperty,
+  query,
+} from '../../../utils/common-util';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {
   CommentThread,
   isDraftThread,
   isRobot,
   isUnresolved,
-  UIDraft,
+  DraftInfo,
 } from '../../../utils/comment-util';
 import {
   PolymerDeepPropertyChange,
@@ -159,43 +159,49 @@
   ParsedChangeInfo,
 } from '../../../types/types';
 import {
+  ChecksTabState,
   CloseFixPreviewEvent,
   EditableContentSaveEvent,
   EventType,
   OpenFixPreviewEvent,
   ShowAlertEventDetail,
   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,
-  firePageError,
   fireReload,
   fireTitleChange,
 } from '../../../utils/event-util';
-import {GerritView, routerView$} from '../../../services/router/router-model';
-import {takeUntil} from 'rxjs/operators';
-import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
-import {Subject} from 'rxjs';
-import {debounce, DelayedTask, throttleWrap} from '../../../utils/async-util';
+import {GerritView} from '../../../services/router/router-model';
+import {
+  debounce,
+  DelayedTask,
+  throttleWrap,
+  until,
+} from '../../../utils/async-util';
 import {Interaction, Timing} from '../../../constants/reporting';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {
-  changeComments$,
-  drafts$,
-} from '../../../services/comments/comments-model';
-import {
   getAddedByReason,
   getRemovedByReason,
   hasAttention,
 } from '../../../utils/attention-set-util';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {LoadingStatus} from '../../../models/change/change-model';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {checksModelToken} from '../../../models/checks/checks-model';
+import {changeModelToken} from '../../../models/change/change-model';
 
 const CHANGE_ID_ERROR = {
   MISMATCH: 'mismatch',
@@ -230,7 +236,6 @@
 
 export interface GrChangeView {
   $: {
-    commentAPI: GrCommentApi;
     applyFixDialog: GrApplyFixDialog;
     fileList: GrFileList & Element;
     fileListHeader: GrFileListHeader;
@@ -240,7 +245,6 @@
     downloadOverlay: GrOverlay;
     downloadDialog: GrDownloadDialog;
     replyOverlay: GrOverlay;
-    replyDialog: GrReplyDialog;
     mainContent: HTMLDivElement;
     changeStar: GrChangeStar;
     actions: GrChangeActions;
@@ -255,7 +259,7 @@
 export type ChangeViewPatchRange = Partial<PatchRange>;
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
+const base = KeyboardShortcutMixin(DIPolymerElement);
 
 @customElement('gr-change-view')
 export class GrChangeView extends base {
@@ -281,19 +285,13 @@
    * @event show-auth-required
    */
 
-  private readonly reporting = appContext.reportingService;
-
-  private readonly jsAPI = appContext.jsApiService;
-
-  private readonly changeService = appContext.changeService;
-
   /**
    * URL params passed from the router.
    */
   @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})
@@ -344,7 +342,7 @@
   _canStartReview?: boolean;
 
   @property({type: Object, observer: '_changeChanged'})
-  _change?: ChangeInfo | ParsedChangeInfo;
+  _change?: ParsedChangeInfo;
 
   @property({type: Object, computed: '_getRevisionInfo(_change)'})
   _revisionInfo?: RevisionInfoClass;
@@ -365,7 +363,7 @@
   _changeNum?: NumericChangeId;
 
   @property({type: Object})
-  _diffDrafts?: {[path: string]: UIDraft[]} = {};
+  _diffDrafts?: {[path: string]: DraftInfo[]} = {};
 
   @property({type: Boolean})
   _editingCommitMessage = false;
@@ -393,9 +391,6 @@
   @property({type: Object})
   _messages = NO_ROBOT_COMMENTS_THREADS_MSG;
 
-  @property({type: Number})
-  _lineHeight?: number;
-
   @property({
     type: String,
     computed:
@@ -415,6 +410,9 @@
   @property({type: Object})
   _selectedRevision?: RevisionInfo | EditRevisionInfo;
 
+  /**
+   * <gr-change-actions> populates this via two-way data binding.
+   */
   @property({type: Object})
   _currentRevisionActions?: ActionNameToActionInfoMap;
 
@@ -457,10 +455,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,
@@ -532,7 +526,7 @@
   _activeTabs: string[] = [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG];
 
   @property({type: Boolean})
-  unresolvedOnly = false;
+  unresolvedOnly = true;
 
   @property({type: Boolean})
   _showAllRobotComments = false;
@@ -557,20 +551,16 @@
   @property({type: String})
   scrollCommentId?: UrlEncodedCommentId;
 
+  /** Just reflects the `opened` prop of the overlay. */
+  @property({type: Boolean})
+  replyOverlayOpened = false;
+
   @property({
     type: Array,
     computed: '_computeResolveWeblinks(_change, _commitInfo, _serverConfig)',
   })
   resolveWeblinks?: GeneratedWebLink[];
 
-  restApiService = appContext.restApiService;
-
-  private readonly commentsService = appContext.commentsService;
-
-  private readonly shortcuts = appContext.shortcutsService;
-
-  private replyDialogResizeObserver?: ResizeObserver;
-
   override keyboardShortcuts(): ShortcutListener[] {
     return [
       listen(Shortcut.SEND_REPLY, _ => {}), // docOnly
@@ -617,7 +607,29 @@
     ];
   }
 
-  disconnected$ = new Subject();
+  // Accessed in tests.
+  readonly reporting = getAppContext().reportingService;
+
+  readonly jsAPI = getAppContext().jsApiService;
+
+  private readonly getChecksModel = resolve(this, checksModelToken);
+
+  readonly restApiService = getAppContext().restApiService;
+
+  // Private but used in tests.
+  readonly userModel = getAppContext().userModel;
+
+  // Private but used in tests.
+  readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly routerModel = getAppContext().routerModel;
+
+  // Private but used in tests.
+  readonly getCommentsModel = resolve(this, commentsModelToken);
+
+  private readonly shortcuts = getAppContext().shortcutsService;
+
+  private subscriptions: Subscription[] = [];
 
   private replyRefitTask?: DelayedTask;
 
@@ -625,23 +637,25 @@
 
   private lastStarredTimestamp?: number;
 
-  override ready() {
-    super.ready();
-    aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
-      this._showChecksTab = b;
-    });
-    routerView$.pipe(takeUntil(this.disconnected$)).subscribe(view => {
-      this.isViewCurrent = view === GerritView.CHANGE;
-    });
-    drafts$.pipe(takeUntil(this.disconnected$)).subscribe(drafts => {
-      this._diffDrafts = {...drafts};
-    });
-    changeComments$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(changeComments => {
-        this._changeComments = changeComments;
-      });
-  }
+  private diffViewMode?: DiffViewMode;
+
+  /**
+   * If the user comes back to the change page we want to remember the scroll
+   * position when we re-render the page as is.
+   */
+  private scrollPosition?: number;
+
+  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;
 
   constructor() {
     super();
@@ -655,38 +669,90 @@
       '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() {
+    this.subscriptions.push(
+      this.getChecksModel().aPluginHasRegistered$.subscribe(b => {
+        this._showChecksTab = b;
+      })
+    );
+    this.subscriptions.push(
+      this.routerModel.routerView$.subscribe(view => {
+        this.isViewCurrent = view === GerritView.CHANGE;
+      })
+    );
+    this.subscriptions.push(
+      this.routerModel.routerPatchNum$.subscribe(patchNum => {
+        this.routerPatchNum = patchNum;
+      })
+    );
+    this.subscriptions.push(
+      this.getCommentsModel().drafts$.subscribe(drafts => {
+        this._diffDrafts = {...drafts};
+      })
+    );
+    this.subscriptions.push(
+      this.userModel.preferenceDiffViewMode$.subscribe(diffViewMode => {
+        this.diffViewMode = diffViewMode;
+      })
+    );
+    this.subscriptions.push(
+      this.getCommentsModel().changeComments$.subscribe(changeComments => {
+        this._changeComments = changeComments;
+      })
+    );
+    this.subscriptions.push(
+      this.getChangeModel().change$.subscribe(change => {
+        // The change view is tied to a specific change number, so don't update
+        // _change to undefined.
+        if (change) this._change = change;
+      })
+    );
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
-      this._handleToggleChangeStar()
-    );
-    this._getServerConfig().then(config => {
-      this._serverConfig = config;
-      this._replyDisabled = false;
-    });
+    this.firstConnectedCallback();
+    this.connected$.next(true);
 
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this.restApiService.getAccount().then(acct => {
-          this._account = acct;
-        });
-      }
-      this._setDiffViewMode();
-    });
+    // 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);
+  }
 
-    this.replyDialogResizeObserver = new ResizeObserver(() =>
-      this.$.replyOverlay.center()
-    );
-    this.replyDialogResizeObserver.observe(this.$.replyDialog);
+  /**
+   * 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()
@@ -704,40 +770,41 @@
       })
       .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);
+    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;
+        });
+      }
     });
   }
 
   override disconnectedCallback() {
-    this.disconnected$.next();
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
     document.removeEventListener(
       'visibilitychange',
       this.handleVisibilityChange
     );
+    document.removeEventListener('scroll', this.handleScroll);
     this.replyRefitTask?.cancel();
     this.scrollTask?.cancel();
 
     if (this._updateCheckTimerHandle) {
       this._cancelUpdateCheckTimer();
     }
+    this.connected$.next(false);
     super.disconnectedCallback();
   }
 
@@ -749,23 +816,14 @@
     return this.shadowRoot!.querySelector<GrThreadList>('gr-thread-list');
   }
 
-  _setDiffViewMode(opt_reset?: boolean) {
-    if (!opt_reset && this.viewState.diffViewMode) {
-      return;
-    }
-
-    return this._getPreferences()
-      .then(prefs => {
-        if (!this.viewState.diffMode && prefs) {
-          this.set('viewState.diffMode', prefs.default_diff_view);
-        }
-      })
-      .then(() => {
-        if (!this.viewState.diffMode) {
-          this.set('viewState.diffMode', 'SIDE_BY_SIDE');
-        }
-      });
-  }
+  private readonly handleScroll = () => {
+    if (!this.isViewCurrent) return;
+    this.scrollTask = debounce(
+      this.scrollTask,
+      () => (this.scrollPosition = document.documentElement.scrollTop),
+      150
+    );
+  };
 
   _onOpenFixPreview(e: OpenFixPreviewEvent) {
     this.$.applyFixDialog.open(e);
@@ -776,10 +834,12 @@
   }
 
   _handleToggleDiffMode() {
-    if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
+    if (this.diffViewMode === DiffViewMode.SIDE_BY_SIDE) {
+      this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
+      this.userModel.updatePreferences({
+        diff_view: DiffViewMode.SIDE_BY_SIDE,
+      });
     }
   }
 
@@ -868,9 +928,16 @@
         this._selectedTabPluginHeader = '';
       }
     }
-    this._tabState = e.detail.tabState;
+    if (e.detail.tabState) this._tabState = e.detail.tabState;
   }
 
+  /**
+   * Currently there is a bug in this code where this.unresolvedOnly is only
+   * assigned the correct value when _onPaperTabClick is triggered which is
+   * only triggered when user explicitly clicks on the tab however the comments
+   * tab can also be opened via the url in which case the correct value to
+   * unresolvedOnly is never assigned.
+   */
   _onPaperTabClick(e: MouseEvent) {
     let target = e.target as HTMLElement | null;
     let tabName: string | undefined;
@@ -882,11 +949,12 @@
     } while (target);
 
     if (tabName === PrimaryTab.COMMENT_THREADS) {
-      // Show unresolved threads by default only if they are present
+      // Show unresolved threads by default
+      // Show resolved threads only if no unresolved threads exist
       const hasUnresolvedThreads =
         (this._commentThreads ?? []).filter(thread => isUnresolved(thread))
           .length > 0;
-      if (hasUnresolvedThreads) this.unresolvedOnly = true;
+      if (!hasUnresolvedThreads) this.unresolvedOnly = false;
     }
 
     this.reporting.reportInteraction(Interaction.SHOW_TAB, {
@@ -895,10 +963,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, '');
 
@@ -1017,8 +1095,8 @@
       .sort((a, b) => (b.value as number) - (a.value as number));
   }
 
-  _handleCurrentRevisionUpdate(currentRevision: RevisionInfo) {
-    this._currentRobotCommentsPatchSet = currentRevision._number;
+  _handleCurrentRevisionUpdate(currentRevision?: RevisionInfo) {
+    this._currentRobotCommentsPatchSet = currentRevision?._number;
   }
 
   _handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) {
@@ -1076,7 +1154,7 @@
 
   _handleReplyTap(e: MouseEvent) {
     e.preventDefault();
-    this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+    this._openReplyDialog(FocusTarget.ANY);
   }
 
   onReplyOverlayCanceled() {
@@ -1120,8 +1198,7 @@
         .split('\n')
         .map(line => '> ' + line)
         .join('\n') + '\n\n';
-    this.$.replyDialog.quote = quoteStr;
-    this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
+    this._openReplyDialog(FocusTarget.BODY, quoteStr);
   }
 
   _handleHideBackgroundContent() {
@@ -1158,9 +1235,9 @@
   }
 
   _handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
-    let target = this.$.replyDialog.FocusTarget.REVIEWERS;
+    let target = FocusTarget.REVIEWERS;
     if (e.detail.value && e.detail.value.ccsOnly) {
-      target = this.$.replyDialog.FocusTarget.CCS;
+      target = FocusTarget.CCS;
     }
     this._openReplyDialog(target);
   }
@@ -1199,6 +1276,24 @@
     return this._changeNum !== this.params?.changeNum;
   }
 
+  hasPatchRangeChanged(value: AppElementChangeViewParams) {
+    if (!this._patchRange) return false;
+    if (this._patchRange.basePatchNum !== value.basePatchNum) return true;
+    return this.hasPatchNumChanged(value);
+  }
+
+  hasPatchNumChanged(value: AppElementChangeViewParams) {
+    if (!this._patchRange) return false;
+    if (value.patchNum !== undefined) {
+      return this._patchRange.patchNum !== value.patchNum;
+    } else {
+      // value.patchNum === undefined specifies the latest patchset
+      return (
+        this._patchRange.patchNum !== computeLatestPatchNum(this._allPatchSets)
+      );
+    }
+  }
+
   _paramsChanged(value: AppElementChangeViewParams) {
     if (value.view !== GerritView.CHANGE) {
       this._initialLoadComplete = false;
@@ -1222,40 +1317,73 @@
     if (value.basePatchNum === undefined)
       value.basePatchNum = ParentPatchSetNum;
 
-    const patchChanged =
-      this._patchRange &&
-      value.patchNum !== undefined &&
-      (this._patchRange.patchNum !== value.patchNum ||
-        this._patchRange.basePatchNum !== value.basePatchNum);
+    const patchChanged = this.hasPatchRangeChanged(value);
+    let patchNumChanged = this.hasPatchNumChanged(value);
 
-    let rightPatchNumChanged =
-      this._patchRange &&
-      value.patchNum !== undefined &&
-      this._patchRange.patchNum !== value.patchNum;
-
-    const patchRange: ChangeViewPatchRange = {
+    this._patchRange = {
       patchNum: value.patchNum,
       basePatchNum: value.basePatchNum,
     };
-
-    this.$.fileList.collapseAllDiffs();
-    this._patchRange = patchRange;
     this.scrollCommentId = value.commentId;
 
     const patchKnown =
-      !patchRange.patchNum ||
-      (this._allPatchSets ?? []).some(ps => ps.num === patchRange.patchNum);
+      !this._patchRange.patchNum ||
+      (this._allPatchSets ?? []).some(
+        ps => ps.num === this._patchRange!.patchNum
+      );
+    // _allPatchsets does not know value.patchNum so force a reload.
+    const forceReload = value.forceReload || !patchKnown;
 
-    // If the change has already been loaded and the parameter change is only
-    // in the patch range, then don't do a full reload.
-    if (this._changeNum !== undefined && patchChanged && patchKnown) {
-      if (!patchRange.patchNum) {
-        patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
-        rightPatchNumChanged = true;
+    // If changeNum is defined that means the change has already been
+    // rendered once before so a full reload is not required.
+    if (this._changeNum !== undefined && !forceReload) {
+      if (!this._patchRange.patchNum) {
+        this._patchRange = {
+          ...this._patchRange,
+          patchNum: computeLatestPatchNum(this._allPatchSets),
+        };
+        patchNumChanged = true;
       }
-      this._reloadPatchNumDependentResources(rightPatchNumChanged).then(() => {
-        this._sendShowChangeEvent();
-      });
+      if (patchChanged) {
+        // We need to collapse all diffs when params change so that a non
+        // existing diff is not requested. See Issue 125270 for more details.
+        this.$.fileList.collapseAllDiffs();
+        this._reloadPatchNumDependentResources(patchNumChanged).then(() => {
+          this._sendShowChangeEvent();
+        });
+      }
+
+      // If there is no change in patchset or changeNum, such as when user goes
+      // to the diff view and then comes back to change page then there is no
+      // need to reload anything and we render the change view component as is.
+      document.documentElement.scrollTop = this.scrollPosition ?? 0;
+      this.reporting.reportInteraction('change-view-re-rendered');
+      this.updateTitle(this._change);
+      // We still need to check if post load tasks need to be done such as when
+      // user wants to open the reply dialog when in the diff page, the change
+      // page should open the reply dialog
+      this._performPostLoadTasks();
+      return;
+    }
+
+    // We need to collapse all diffs when params change so that a non existing
+    // diff is not requested. See Issue 125270 for more details.
+    this.$.fileList.collapseAllDiffs();
+
+    // If the change was loaded before, then we are firing a 'reload' event
+    // instead of calling `loadData()` directly for two reasons:
+    // 1. We want to avoid code such as `this._initialLoadComplete = false` that
+    //    is only relevant for the initial load of a change.
+    // 2. We have to somehow trigger the change-model reloading. Otherwise
+    //    this._change is not updated.
+    if (this._changeNum) {
+      if (!this._patchRange?.patchNum) {
+        this._patchRange = {
+          basePatchNum: ParentPatchSetNum,
+          patchNum: computeLatestPatchNum(this._allPatchSets),
+        };
+      }
+      fireReload(this);
       return;
     }
 
@@ -1274,16 +1402,24 @@
 
   _initActiveTabs(params?: AppElementChangeViewParams) {
     let primaryTab = PrimaryTab.FILES;
-    if (params && params.queryMap && params.queryMap.has('tab')) {
-      primaryTab = params.queryMap.get('tab') as PrimaryTab;
-    } else if (params && 'commentId' in params) {
+    if (params?.tab) {
+      primaryTab = params?.tab as PrimaryTab;
+    } else if (params?.commentId) {
       primaryTab = PrimaryTab.COMMENT_THREADS;
     }
+    const detail: SwitchTabEventDetail = {
+      tab: primaryTab,
+    };
+    if (primaryTab === PrimaryTab.CHECKS) {
+      const state: ChecksTabState = {};
+      detail.tabState = {checksTab: state};
+      if (params?.filter) state.filter = params?.filter;
+      if (params?.select) state.select = params?.select;
+      if (params?.attempt) state.attempt = params?.attempt;
+    }
     this._setActivePrimaryTab(
-      new CustomEvent('initActiveTab', {
-        detail: {
-          tab: primaryTab,
-        },
+      new CustomEvent(EventType.SHOW_PRIMARY_TAB, {
+        detail,
       })
     );
   }
@@ -1301,7 +1437,6 @@
   _performPostLoadTasks() {
     this._maybeShowReplyDialog();
     this._maybeShowRevertDialog();
-    this._maybeShowDownloadDialog();
 
     this._sendShowChangeEvent();
 
@@ -1351,13 +1486,12 @@
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
     const hash = PREFIX + e.detail.id;
-    const url = GerritNav.getUrlForChange(
-      this._change,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum,
-      this._editMode,
-      hash
-    );
+    const url = GerritNav.getUrlForChange(this._change, {
+      patchNum: this._patchRange.patchNum,
+      basePatchNum: this._patchRange.basePatchNum,
+      isEdit: this._editMode,
+      messageHash: hash,
+    });
     history.replaceState(null, '', url);
   }
 
@@ -1411,32 +1545,34 @@
       }
 
       if (this.viewState.showReplyDialog) {
-        this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+        this._openReplyDialog(FocusTarget.ANY);
         this.set('viewState.showReplyDialog', false);
+        fire(this, 'view-state-change-view-changed', {
+          value: this.viewState as ChangeViewState,
+        });
       }
     });
   }
 
-  _maybeShowDownloadDialog() {
-    if (this.viewState.showDownloadDialog) {
-      this._handleOpenDownloadDialog();
-      this.set('viewState.showDownloadDialog', false);
-    }
-  }
-
   _resetFileListViewState() {
     this.set('viewState.selectedFileIndex', 0);
     if (
       !!this.viewState.changeNum &&
       this.viewState.changeNum !== this._changeNum
     ) {
-      // Reset the diff mode to null when navigating from one change to
-      // another, so that the user's preference is restored.
-      this._setDiffViewMode(true);
       this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
     }
     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) {
+    if (!change) return;
+    const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
+    fireTitleChange(this, title);
   }
 
   _changeChanged(change?: ChangeInfo | ParsedChangeInfo) {
@@ -1454,9 +1590,7 @@
     );
 
     this.set('_patchRange.basePatchNum', parent);
-
-    const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
-    fireTitleChange(this, title);
+    this.updateTitle(change);
   }
 
   /**
@@ -1490,8 +1624,12 @@
     return 'PARENT';
   }
 
-  _computeChangeUrl(change: ChangeInfo) {
-    return GerritNav.getUrlForChange(change);
+  // Polymer was converting true to "true"(type string) automatically hence
+  // forceReload is of type string instead of boolean.
+  _computeChangeUrl(change: ChangeInfo, forceReload?: string) {
+    return GerritNav.getUrlForChange(change, {
+      forceReload: !!forceReload,
+    });
   }
 
   _computeChangeIdClass(displayChangeId: string) {
@@ -1542,7 +1680,7 @@
   }
 
   _computeReplyButtonLabel(
-    drafts?: {[path: string]: UIDraft[]},
+    drafts?: {[path: string]: DraftInfo[]},
     canStartReview?: boolean
   ) {
     if (drafts === undefined || canStartReview === undefined) {
@@ -1567,7 +1705,7 @@
         fireEvent(this, 'show-auth-required');
         return;
       }
-      this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+      this._openReplyDialog(FocusTarget.ANY);
     });
   }
 
@@ -1597,7 +1735,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,
@@ -1623,7 +1761,9 @@
       fireAlert(this, 'Base is already selected.');
       return;
     }
-    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+    GerritNav.navigateToChange(this._change, {
+      patchNum: this._patchRange.patchNum,
+    });
   }
 
   _handleDiffBaseAgainstLeft() {
@@ -1634,7 +1774,9 @@
       fireAlert(this, 'Left is already base.');
       return;
     }
-    GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
+    GerritNav.navigateToChange(this._change, {
+      patchNum: this._patchRange.basePatchNum,
+    });
   }
 
   _handleDiffAgainstLatest() {
@@ -1646,11 +1788,10 @@
       fireAlert(this, 'Latest is already selected.');
       return;
     }
-    GerritNav.navigateToChange(
-      this._change,
-      latestPatchNum,
-      this._patchRange.basePatchNum
-    );
+    GerritNav.navigateToChange(this._change, {
+      patchNum: latestPatchNum,
+      basePatchNum: this._patchRange.basePatchNum,
+    });
   }
 
   _handleDiffRightAgainstLatest() {
@@ -1662,11 +1803,10 @@
       fireAlert(this, 'Right is already latest.');
       return;
     }
-    GerritNav.navigateToChange(
-      this._change,
-      latestPatchNum,
-      this._patchRange.patchNum as BasePatchSetNum
-    );
+    GerritNav.navigateToChange(this._change, {
+      patchNum: latestPatchNum,
+      basePatchNum: this._patchRange.patchNum as BasePatchSetNum,
+    });
   }
 
   _handleDiffBaseAgainstLatest() {
@@ -1681,7 +1821,7 @@
       fireAlert(this, 'Already diffing base against latest.');
       return;
     }
-    GerritNav.navigateToChange(this._change, latestPatchNum);
+    GerritNav.navigateToChange(this._change, {patchNum: latestPatchNum});
   }
 
   _handleToggleChangeStar() {
@@ -1752,21 +1892,22 @@
     });
   }
 
-  _openReplyDialog(section?: FocusTarget) {
+  _openReplyDialog(focusTarget?: FocusTarget, quote?: string) {
     if (!this._change) return;
-    this.$.replyOverlay.open().finally(() => {
+    const overlay = this.$.replyOverlay;
+    overlay.open().finally(async () => {
       // the following code should be executed no matter open succeed or not
+      const dialog = query<GrReplyDialog>(this, '#replyDialog');
+      assertIsDefined(dialog, 'reply dialog');
       this._resetReplyOverlayFocusStops();
-      this.$.replyDialog.open(section);
+      dialog.open(focusTarget, quote);
+      const observer = new ResizeObserver(() => overlay.center());
+      observer.observe(dialog);
     });
     fireDialogChange(this, {opened: true});
     this._changeViewAriaHidden = true;
   }
 
-  _handleGetChangeDetailError(response?: Response | null) {
-    firePageError(response);
-  }
-
   _getLoggedIn() {
     return this.restApiService.getLoggedIn();
   }
@@ -1799,9 +1940,12 @@
    * Utility function to make the necessary modifications to a change in the
    * case an edit exists.
    */
-  _processEdit(change: ParsedChangeInfo, edit?: EditInfo | false) {
+  _processEdit(change: ParsedChangeInfo) {
+    const revisions = Object.values(change.revisions || {});
+    const editRev = findEdit(revisions);
+    const editParentRev = findEditParentRevision(revisions);
     if (
-      !edit &&
+      !editRev &&
       this._patchRange?.patchNum === EditPatchSetNum &&
       changeIsOpen(change)
     ) {
@@ -1811,49 +1955,37 @@
     }
 
     if (
-      !edit &&
+      !editRev &&
       (changeIsMerged(change) || changeIsAbandoned(change)) &&
       this._editMode
     ) {
       fireAlert(
         this,
-        'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.'
+        'Change edits cannot be created if change is merged or abandoned. Redirecting to non edit mode.'
       );
       fireReload(this, true);
       return;
     }
 
-    if (!edit) return;
+    if (!editRev) return;
+    assertIsDefined(this._patchRange, '_patchRange');
+    assertIsDefined(editRev.commit.commit, 'editRev.commit.commit');
+    assertIsDefined(editParentRev, 'editParentRev');
 
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-
-    if (!edit.commit.commit) throw new Error('undefined edit.commit.commit');
-    const changeWithEdit = change;
-    if (changeWithEdit.revisions)
-      changeWithEdit.revisions[edit.commit.commit] = {
-        _number: EditPatchSetNum,
-        basePatchNum: edit.base_patch_set_number,
-        commit: edit.commit,
-        fetch: edit.fetch,
-      };
-
-    // If the edit is based on the most recent patchset, load it by
-    // default, unless another patch set to load was specified in the URL.
-    if (
-      !this._patchRange.patchNum &&
-      changeWithEdit.current_revision === edit.base_revision
-    ) {
-      changeWithEdit.current_revision = edit.commit.commit;
+    const latestPsNum = computeLatestPatchNum(computeAllPatchSets(change));
+    // If the change was loaded without a specific patchset, then this normally
+    // means that the *latest* patchset should be loaded. But if there is an
+    // active edit, then automatically switch to that edit as the current
+    // patchset.
+    // TODO: This goes together with `change.current_revision` being set, which
+    // is under change-model control. `_patchRange.patchNum` should eventually
+    // also be model managed, so we can reconcile these two code snippets into
+    // one location.
+    if (!this.routerPatchNum && latestPsNum === editParentRev._number) {
       this.set('_patchRange.patchNum', EditPatchSetNum);
-      // Because edits are fibbed as revisions and added to the revisions
-      // array, and revision actions are always derived from the 'latest'
-      // patch set, we must copy over actions from the patch set base.
-      // Context: Issue 7243
-      if (changeWithEdit.revisions) {
-        changeWithEdit.revisions[edit.commit.commit].actions =
-          changeWithEdit.revisions[edit.base_revision].actions;
-      }
+      // The file list is not reactive (yet) with regards to patch range
+      // changes, so we have to actively trigger it.
+      this._reloadPatchNumDependentResources();
     }
   }
 
@@ -1883,81 +2015,85 @@
     });
   }
 
-  _getChangeDetail() {
-    if (!this._changeNum)
-      throw new Error('missing required changeNum property');
-    const detailCompletes = this.restApiService.getChangeDetail(
-      this._changeNum,
-      r => this._handleGetChangeDetailError(r)
+  private async untilModelLoaded() {
+    // NOTE: Wait until this page is connected before determining whether the
+    // model is loaded.  This can happen when params are changed when setting up
+    // this view. It's unclear whether this issue is related to Polymer
+    // specifically.
+    if (!this.isConnected) {
+      await until(this.connected$, connected => connected);
+    }
+    await until(
+      this.getChangeModel().changeLoadingStatus$,
+      status => status === LoadingStatus.LOADED
     );
-    const editCompletes = this._getEdit();
+  }
+
+  /**
+   * Process edits
+   * Check if a revert of this change has been submitted
+   * Calculate selected revision
+   */
+  // private but used in tests
+  async performPostChangeLoadTasks() {
+    assertIsDefined(this._changeNum, '_changeNum');
+
     const prefCompletes = this._getPreferences();
+    await this.untilModelLoaded();
 
-    return Promise.all([detailCompletes, editCompletes, prefCompletes]).then(
-      ([change, edit, prefs]) => {
-        this._prefs = prefs;
+    this._prefs = await prefCompletes;
 
-        if (!change) {
-          return false;
-        }
-        this._processEdit(change, edit);
-        // Issue 4190: Coalesce missing topics to null.
-        // TODO(TS): code needs second thought,
-        // it might be that nulls were assigned to trigger some bindings
-        if (!change.topic) {
-          change.topic = null as unknown as undefined;
-        }
-        if (!change.reviewer_updates) {
-          change.reviewer_updates = null as unknown as undefined;
-        }
-        const latestRevisionSha = this._getLatestRevisionSHA(change);
-        if (!latestRevisionSha)
-          throw new Error('Could not find latest Revision Sha');
-        const currentRevision = change.revisions[latestRevisionSha];
-        if (currentRevision.commit && currentRevision.commit.message) {
-          this._latestCommitMessage = this._prepareCommitMsgForLinkify(
-            currentRevision.commit.message
-          );
-        } else {
-          this._latestCommitMessage = null;
-        }
+    if (!this._change) return false;
 
-        const lineHeight = getComputedStyle(this).lineHeight;
+    this._processEdit(this._change);
+    // Issue 4190: Coalesce missing topics to null.
+    // TODO(TS): code needs second thought,
+    // it might be that nulls were assigned to trigger some bindings
+    if (!this._change.topic) {
+      this._change.topic = null as unknown as undefined;
+    }
+    if (!this._change.reviewer_updates) {
+      this._change.reviewer_updates = null as unknown as undefined;
+    }
+    const latestRevisionSha = this._getLatestRevisionSHA(this._change);
+    if (!latestRevisionSha)
+      throw new Error('Could not find latest Revision Sha');
+    const currentRevision = this._change.revisions[latestRevisionSha];
+    if (currentRevision.commit && currentRevision.commit.message) {
+      this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+        currentRevision.commit.message
+      );
+    } else {
+      this._latestCommitMessage = null;
+    }
 
-        // Slice returns a number as a string, convert to an int.
-        this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
-
-        this.changeService.updateChange(change);
-        this._change = change;
-        this.computeRevertSubmitted(change);
-        if (
-          !this._patchRange ||
-          !this._patchRange.patchNum ||
-          this._patchRange.patchNum === currentRevision._number
-        ) {
-          // CommitInfo.commit is optional, and may need patching.
-          if (currentRevision.commit && !currentRevision.commit.commit) {
-            currentRevision.commit.commit = latestRevisionSha as CommitId;
-          }
-          this._commitInfo = currentRevision.commit;
-          this._selectedRevision = currentRevision;
-          // TODO: Fetch and process files.
-        } else {
-          if (!this._change?.revisions || !this._patchRange) return false;
-          this._selectedRevision = Object.values(this._change.revisions).find(
-            revision => {
-              // edit patchset is a special one
-              const thePatchNum = this._patchRange!.patchNum;
-              if (thePatchNum === 'edit') {
-                return revision._number === thePatchNum;
-              }
-              return revision._number === Number(`${thePatchNum}`);
-            }
-          );
-        }
-        return true;
+    this.computeRevertSubmitted(this._change);
+    if (
+      !this._patchRange ||
+      !this._patchRange.patchNum ||
+      this._patchRange.patchNum === currentRevision._number
+    ) {
+      // CommitInfo.commit is optional, and may need patching.
+      if (currentRevision.commit && !currentRevision.commit.commit) {
+        currentRevision.commit.commit = latestRevisionSha as CommitId;
       }
-    );
+      this._commitInfo = currentRevision.commit;
+      this._selectedRevision = currentRevision;
+      // TODO: Fetch and process files.
+    } else {
+      if (!this._change?.revisions || !this._patchRange) return false;
+      this._selectedRevision = Object.values(this._change.revisions).find(
+        revision => {
+          // edit patchset is a special one
+          const thePatchNum = this._patchRange!.patchNum;
+          if (thePatchNum === 'edit') {
+            return revision._number === thePatchNum;
+          }
+          return revision._number === Number(`${thePatchNum}`);
+        }
+      );
+    }
+    return true;
   }
 
   _isSubmitEnabled(revisionActions: ActionNameToActionInfoMap) {
@@ -1976,12 +2112,6 @@
     }
   }
 
-  _getEdit() {
-    if (!this._changeNum)
-      return Promise.reject(new Error('missing required changeNum property'));
-    return this.restApiService.getChangeEdit(this._changeNum, true);
-  }
-
   _getLatestCommitMessage() {
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
@@ -2013,56 +2143,17 @@
     return latestRev;
   }
 
-  _getCommitInfo() {
-    if (!this._changeNum)
-      throw new Error('missing required _changeNum property');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    if (this._patchRange.patchNum === undefined)
-      throw new Error('missing required patchNum property');
-
-    // We only call _getEdit if the patchset number is an edit.
-    // We have to do this to ensure we can tell if an edit
-    // exists or not.
-    // This safely works even if a edit does not exist.
-    if (this._patchRange!.patchNum! === EditPatchSetNum) {
-      return this._getEdit().then(edit => {
-        if (!edit) {
-          return Promise.resolve();
-        }
-
-        return this._getChangeCommitInfo();
-      });
-    }
-
-    return this._getChangeCommitInfo();
-  }
-
-  _getChangeCommitInfo() {
+  // visible for testing
+  loadAndSetCommitInfo() {
+    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;
       });
   }
 
-  /**
-   * Fetches a new changeComment object, and data for all types of comments
-   * (comments, robot comments, draft comments) is requested.
-   */
-  _reloadComments() {
-    // We are resetting all comment related properties, because we want to avoid
-    // a new change being loaded and then paired with outdated comments.
-    this._changeComments = undefined;
-    this._commentThreads = undefined;
-    this._draftCommentThreads = undefined;
-    this._robotCommentThreads = undefined;
-    if (!this._changeNum)
-      throw new Error('missing required changeNum property');
-
-    this.commentsService.loadAll(this._changeNum, this._patchRange?.patchNum);
-  }
-
   @observe('_changeComments')
   changeCommentsChanged(comments?: ChangeComments) {
     if (!comments) return;
@@ -2088,16 +2179,18 @@
    *
    * @param isLocationChange Reloads the related changes
    * when true and ends reporting events that started on location change.
-   * @param clearPatchset Reloads the related changes
-   * ignoring any patchset choice made.
+   * @param clearPatchset Reloads the change ignoring any patchset
+   * choice made.
    * @return A promise that resolves when the core data has loaded.
    * Some non-core data loading may still be in-flight when the core data
    * promise resolves.
    */
-  loadData(isLocationChange?: boolean, clearPatchset?: boolean): Promise<void> {
+  loadData(isLocationChange?: boolean, clearPatchset?: boolean) {
     if (this.isChangeObsolete()) return Promise.resolve();
     if (clearPatchset && this._change) {
-      GerritNav.navigateToChange(this._change);
+      GerritNav.navigateToChange(this._change, {
+        forceReload: true,
+      });
       return Promise.resolve();
     }
     this._loading = true;
@@ -2109,36 +2202,23 @@
 
     // Resolves when the change detail and the edit patch set (if available)
     // are loaded.
-    const detailCompletes = this._getChangeDetail();
+    const detailCompletes = this.untilModelLoaded();
     allDataPromises.push(detailCompletes);
 
     // Resolves when the loading flag is set to false, meaning that some
     // change content may start appearing.
-    const loadingFlagSet = detailCompletes
-      .then(() => {
-        this._loading = false;
-        fireEvent(this, 'change-details-loaded');
-      })
-      .then(() => {
-        this.reporting.timeEnd(Timing.CHANGE_RELOAD);
-        if (isLocationChange) {
-          this.reporting.changeDisplayed({
-            isOwner: isOwner(this._change, this._account),
-            isReviewer: isReviewer(this._change, this._account),
-            isCc: isCc(this._change, this._account),
-          });
-        }
-      });
+    const loadingFlagSet = detailCompletes.then(() => {
+      this._loading = false;
+      this.performPostChangeLoadTasks();
+    });
 
     // Resolves when the project config has successfully loaded.
-    const projectConfigLoaded = detailCompletes.then(success => {
-      if (!success) return Promise.resolve();
+    const projectConfigLoaded = detailCompletes.then(() => {
+      if (!this._change) return Promise.resolve();
       return this._getProjectConfig();
     });
     allDataPromises.push(projectConfigLoaded);
 
-    this._reloadComments();
-
     let coreDataPromise;
 
     // If the patch number is specified
@@ -2150,17 +2230,7 @@
 
       // Promise resolves when the change detail and patch dependent resources
       // have loaded.
-      const detailAndPatchResourcesLoaded = Promise.all([
-        patchResourcesLoaded,
-        loadingFlagSet,
-      ]);
-
-      // _getChangeDetail triggers reload of change actions already.
-
-      // The core data is loaded when mergeability is known.
-      coreDataPromise = detailAndPatchResourcesLoaded.then(() =>
-        this._getMergeability()
-      );
+      coreDataPromise = Promise.all([patchResourcesLoaded, loadingFlagSet]);
     } else {
       // Resolves when the file list has loaded.
       const fileListReload = loadingFlagSet.then(() =>
@@ -2177,37 +2247,47 @@
       });
       allDataPromises.push(latestCommitMessageLoaded);
 
-      // Core data is loaded when mergeability has been loaded.
-      coreDataPromise = loadingFlagSet.then(() => this._getMergeability());
+      coreDataPromise = loadingFlagSet;
     }
+    const mergeabilityLoaded = coreDataPromise.then(() =>
+      this._getMergeability()
+    );
+    allDataPromises.push(mergeabilityLoaded);
 
-    allDataPromises.push(coreDataPromise);
+    coreDataPromise.then(() => {
+      fireEvent(this, 'change-details-loaded');
+      this.reporting.timeEnd(Timing.CHANGE_RELOAD);
+      if (isLocationChange) {
+        this.reporting.changeDisplayed(
+          roleDetails(this._change, this._account)
+        );
+      }
+    });
 
     if (isLocationChange) {
       this._editingCommitMessage = false;
-      const relatedChangesLoaded = coreDataPromise.then(() => {
-        let relatedChangesPromise:
-          | Promise<RelatedChangesInfo | undefined>
-          | undefined;
-        const patchNum = this._computeLatestPatchNum(this._allPatchSets);
-        if (this._change && patchNum) {
-          relatedChangesPromise = this.restApiService
-            .getRelatedChanges(this._change._number, patchNum)
-            .then(response => {
-              if (this._change && response) {
-                this.hasParent = this._calculateHasParent(
-                  this._change.change_id,
-                  response.changes
-                );
-              }
-              return response;
-            });
-        }
-        // TODO: use returned Promise
-        this.getRelatedChangesList()?.reload(relatedChangesPromise);
-      });
-      allDataPromises.push(relatedChangesLoaded);
     }
+    const relatedChangesLoaded = coreDataPromise.then(() => {
+      let relatedChangesPromise:
+        | Promise<RelatedChangesInfo | undefined>
+        | undefined;
+      const patchNum = this._computeLatestPatchNum(this._allPatchSets);
+      if (this._change && patchNum) {
+        relatedChangesPromise = this.restApiService
+          .getRelatedChanges(this._change._number, patchNum)
+          .then(response => {
+            if (this._change && response) {
+              this.hasParent = this._calculateHasParent(
+                this._change.change_id,
+                response.changes
+              );
+            }
+            return response;
+          });
+      }
+      return this.getRelatedChangesList()?.reload(relatedChangesPromise);
+    });
+    allDataPromises.push(relatedChangesLoaded);
 
     Promise.all(allDataPromises).then(() => {
       // Loading of commments data is no longer part of this reporting
@@ -2239,17 +2319,24 @@
    * Kicks off requests for resources that rely on the patch range
    * (`this._patchRange`) being defined.
    */
-  _reloadPatchNumDependentResources(rightPatchNumChanged?: boolean) {
+  _reloadPatchNumDependentResources(patchNumChanged?: boolean) {
     assertIsDefined(this._changeNum, '_changeNum');
     if (!this._patchRange?.patchNum) throw new Error('missing patchNum');
-    const promises = [this._getCommitInfo(), this.$.fileList.reload()];
-    if (rightPatchNumChanged)
+    const promises = [this.loadAndSetCommitInfo(), this.$.fileList.reload()];
+    if (patchNumChanged) {
       promises.push(
-        this.$.commentAPI.reloadPortedComments(
+        this.getCommentsModel().reloadPortedComments(
           this._changeNum,
           this._patchRange?.patchNum
         )
       );
+      promises.push(
+        this.getCommentsModel().reloadPortedDrafts(
+          this._changeNum,
+          this._patchRange?.patchNum
+        )
+      );
+    }
     return Promise.all(promises);
   }
 
@@ -2349,60 +2436,61 @@
     }
 
     this._updateCheckTimerHandle = window.setTimeout(() => {
-      if (!this.isViewCurrent) {
+      if (!this.isViewCurrent || !this._change) {
         this._startUpdateCheckTimer();
         return;
       }
-      assertIsDefined(this._change, '_change');
       const change = this._change;
-      this.changeService.fetchChangeUpdates(change).then(result => {
-        let toastMessage = null;
-        if (!result.isLatest) {
-          toastMessage = ReloadToastMessage.NEWER_REVISION;
-        } else if (result.newStatus === ChangeStatus.MERGED) {
-          toastMessage = ReloadToastMessage.MERGED;
-        } else if (result.newStatus === ChangeStatus.ABANDONED) {
-          toastMessage = ReloadToastMessage.ABANDONED;
-        } else if (result.newStatus === ChangeStatus.NEW) {
-          toastMessage = ReloadToastMessage.RESTORED;
-        } else if (result.newMessages) {
-          toastMessage = ReloadToastMessage.NEW_MESSAGE;
-          if (result.newMessages.author?.name) {
-            toastMessage += ` from ${result.newMessages.author.name}`;
+      this.getChangeModel()
+        .fetchChangeUpdates(change)
+        .then(result => {
+          let toastMessage = null;
+          if (!result.isLatest) {
+            toastMessage = ReloadToastMessage.NEWER_REVISION;
+          } else if (result.newStatus === ChangeStatus.MERGED) {
+            toastMessage = ReloadToastMessage.MERGED;
+          } else if (result.newStatus === ChangeStatus.ABANDONED) {
+            toastMessage = ReloadToastMessage.ABANDONED;
+          } else if (result.newStatus === ChangeStatus.NEW) {
+            toastMessage = ReloadToastMessage.RESTORED;
+          } else if (result.newMessages) {
+            toastMessage = ReloadToastMessage.NEW_MESSAGE;
+            if (result.newMessages.author?.name) {
+              toastMessage += ` from ${result.newMessages.author.name}`;
+            }
           }
-        }
 
-        // We have to make sure that the update is still relevant for the user.
-        // Since starting to fetch the change update the user may have sent a
-        // reply, or the change might have been reloaded, or it could be in the
-        // process of being reloaded.
-        const changeWasReloaded = change !== this._change;
-        if (
-          !toastMessage ||
-          this._loading ||
-          changeWasReloaded ||
-          !this.isViewCurrent
-        ) {
-          this._startUpdateCheckTimer();
-          return;
-        }
+          // We have to make sure that the update is still relevant for the user.
+          // Since starting to fetch the change update the user may have sent a
+          // reply, or the change might have been reloaded, or it could be in the
+          // process of being reloaded.
+          const changeWasReloaded = change !== this._change;
+          if (
+            !toastMessage ||
+            this._loading ||
+            changeWasReloaded ||
+            !this.isViewCurrent
+          ) {
+            this._startUpdateCheckTimer();
+            return;
+          }
 
-        this._cancelUpdateCheckTimer();
-        this.dispatchEvent(
-          new CustomEvent<ShowAlertEventDetail>('show-alert', {
-            detail: {
-              message: toastMessage,
-              // Persist this alert.
-              dismissOnNavigation: true,
-              showDismiss: true,
-              action: 'Reload',
-              callback: () => fireReload(this, true),
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
-      });
+          this._cancelUpdateCheckTimer();
+          this.dispatchEvent(
+            new CustomEvent<ShowAlertEventDetail>('show-alert', {
+              detail: {
+                message: toastMessage,
+                // Persist this alert.
+                dismissOnNavigation: true,
+                showDismiss: true,
+                action: 'Reload',
+                callback: () => fireReload(this, true),
+              },
+              composed: true,
+              bubbles: true,
+            })
+          );
+        });
     }, this._serverConfig.change.update_delay * 1000);
   }
 
@@ -2493,8 +2581,8 @@
   }
 
   @observe('_patchRange.patchNum')
-  _patchNumChanged(patchNumStr: PatchSetNum) {
-    if (!this._selectedRevision) {
+  _patchNumChanged(patchNumStr?: PatchSetNum) {
+    if (!this._selectedRevision || !patchNumStr) {
       return;
     }
     assertIsDefined(this._change, '_change');
@@ -2527,7 +2615,7 @@
     );
 
     if (editInfo) {
-      GerritNav.navigateToChange(this._change, EditPatchSetNum);
+      GerritNav.navigateToChange(this._change, {patchNum: EditPatchSetNum});
       return;
     }
 
@@ -2541,21 +2629,31 @@
     ) {
       patchNum = this._patchRange.patchNum;
     }
-    GerritNav.navigateToChange(this._change, patchNum, undefined, true);
+    GerritNav.navigateToChange(this._change, {
+      patchNum,
+      isEdit: true,
+      forceReload: true,
+    });
   }
 
   _handleStopEditTap() {
     assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
-    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+    GerritNav.navigateToChange(this._change, {
+      patchNum: this._patchRange.patchNum,
+      forceReload: true,
+    });
   }
 
   _resetReplyOverlayFocusStops() {
-    this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
+    const dialog = query<GrReplyDialog>(this, '#replyDialog');
+    const focusStops = dialog?.getFocusStops();
+    if (!focusStops) return;
+    this.$.replyOverlay.setFocusStops(focusStops);
   }
 
-  _handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
+  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
     if (e.detail.starred) {
       this.reporting.reportInteraction('change-starred-from-change-view');
       this.lastStarredTimestamp = Date.now();
@@ -2630,9 +2728,19 @@
   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 9386778..b4d961d 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
@@ -20,6 +20,9 @@
   <style include="gr-a11y-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">
     .container:not(.loading) {
       background-color: var(--background-color-tertiary);
@@ -312,13 +315,9 @@
     }
   </style>
   <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
-  <!-- TODO(taoalpha): remove on-show-checks-table,
-    Gerrit should not have any thing too special for a plugin,
-    replace with a generic event: show-primary-tab. -->
   <div
     id="mainContent"
     class="container"
-    on-show-checks-table="_setActivePrimaryTab"
     hidden$="{{_loading}}"
     aria-hidden="[[_changeViewAriaHidden]]"
   >
@@ -340,7 +339,7 @@
           </div>
           <gr-change-star
             id="changeStar"
-            change="{{_change}}"
+            change="[[_change]]"
             on-toggle-star="_handleToggleStar"
             hidden$="[[!_loggedIn]]"
           ></gr-change-star>
@@ -348,7 +347,7 @@
           <a
             class="changeNumber"
             aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
-            href$="[[_computeChangeUrl(_change)]]"
+            href$="[[_computeChangeUrl(_change, 'forceReload')]]"
             >[[_change._number]]</a
           >
           <span class="changeNumberColon">:&nbsp;</span>
@@ -368,8 +367,7 @@
             change="[[_change]]"
             disable-edit="[[disableEdit]]"
             has-parent="[[hasParent]]"
-            actions="[[_change.actions]]"
-            revision-actions="{{_currentRevisionActions}}"
+            revision-actions="[[_currentRevisionActions]]"
             account="[[_account]]"
             change-num="[[_changeNum]]"
             change-status="[[_change.status]]"
@@ -385,7 +383,7 @@
             on-stop-edit-tap="_handleStopEditTap"
             on-download-tap="_handleOpenDownloadDialog"
             on-included-tap="_handleOpenIncludedInDialog"
-            comment-threads="[[_commentThreads]]"
+            on-revision-actions-changed="_handleRevisionActionsChanged"
           ></gr-change-actions>
         </div>
         <!-- end commit actions -->
@@ -428,8 +426,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]]"
@@ -457,12 +457,7 @@
                 </div>
               </div>
               <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
-              <gr-change-summary
-                change-comments="[[_changeComments]]"
-                comment-threads="[[_commentThreads]]"
-                self-account="[[_account]]"
-              >
-              </gr-change-summary>
+              <gr-change-summary></gr-change-summary>
               <gr-endpoint-decorator name="commit-container">
                 <gr-endpoint-param name="change" value="[[_change]]">
                 </gr-endpoint-param>
@@ -546,7 +541,6 @@
           change="[[_change]]"
           change-num="[[_changeNum]]"
           revision-info="[[_revisionInfo]]"
-          change-comments="[[_changeComments]]"
           commit-info="[[_commitInfo]]"
           change-url="[[_computeChangeUrl(_change)]]"
           edit-mode="[[_editMode]]"
@@ -554,7 +548,6 @@
           server-config="[[_serverConfig]]"
           shown-file-count="[[_shownFileCount]]"
           diff-prefs="[[_diffPrefs]]"
-          diff-view-mode="{{viewState.diffMode}}"
           patch-num="{{_patchRange.patchNum}}"
           base-patch-num="{{_patchRange.basePatchNum}}"
           files-expanded="[[_filesExpanded]]"
@@ -572,7 +565,6 @@
           change="[[_change]]"
           change-num="[[_changeNum]]"
           patch-range="{{_patchRange}}"
-          change-comments="[[_changeComments]]"
           selected-index="{{viewState.selectedFileIndex}}"
           diff-view-mode="[[viewState.diffMode]]"
           edit-mode="[[_editMode]]"
@@ -592,11 +584,7 @@
         <h3 class="assistive-tech-only">Comments</h3>
         <gr-thread-list
           threads="[[_commentThreads]]"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          logged-in="[[_loggedIn]]"
-          account="[[_account]]"
-          comment-tab-state="[[_tabState.commentTab]]"
+          comment-tab-state="[[_tabState]]"
           only-show-robot-comments-with-human-reply=""
           unresolved-only="[[unresolvedOnly]]"
           scroll-comment-id="[[scrollCommentId]]"
@@ -608,10 +596,7 @@
         if="[[_isTabActive(_constants.PrimaryTab.CHECKS, _activeTabs)]]"
       >
         <h3 class="assistive-tech-only">Checks</h3>
-        <gr-checks-tab
-          id="checksTab"
-          tab-state="[[_tabState.checksTab]]"
-        ></gr-checks-tab>
+        <gr-checks-tab id="checksTab" tab-state="[[_tabState]]"></gr-checks-tab>
       </template>
       <template
         is="dom-if"
@@ -624,14 +609,7 @@
           value="[[_currentRobotCommentsPatchSet]]"
         >
         </gr-dropdown-list>
-        <gr-thread-list
-          threads="[[_robotCommentThreads]]"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          logged-in="[[_loggedIn]]"
-          hide-dropdown
-          empty-thread-msg="[[_messages.NO_ROBOT_COMMENTS_THREADS_MSG]]"
-        >
+        <gr-thread-list threads="[[_robotCommentThreads]]" hide-dropdown>
         </gr-thread-list>
         <template is="dom-if" if="[[_showRobotCommentsButton]]">
           <gr-button
@@ -674,14 +652,9 @@
       <h2 class="assistive-tech-only">Change Log</h2>
       <gr-messages-list
         class="hideOnMobileOverlay"
-        change="[[_change]]"
-        change-num="[[_changeNum]]"
         labels="[[_change.labels]]"
         messages="[[_change.messages]]"
         reviewer-updates="[[_change.reviewer_updates]]"
-        change-comments="[[_changeComments]]"
-        project-name="[[_change.project]]"
-        show-reply-buttons="[[_loggedIn]]"
         on-message-anchor-tap="_handleMessageAnchorTap"
         on-reply="_handleMessageReply"
       ></gr-messages-list>
@@ -717,24 +690,26 @@
     no-cancel-on-esc-key=""
     scroll-action="lock"
     with-backdrop=""
+    opened="{{replyOverlayOpened}}"
     on-iron-overlay-canceled="onReplyOverlayCanceled"
   >
-    <gr-reply-dialog
-      id="replyDialog"
-      change="{{_change}}"
-      patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
-      permitted-labels="[[_change.permitted_labels]]"
-      draft-comment-threads="[[_draftCommentThreads]]"
-      project-config="[[_projectConfig]]"
-      server-config="[[_serverConfig]]"
-      can-be-started="[[_canStartReview]]"
-      on-send="_handleReplySent"
-      on-cancel="_handleReplyCancel"
-      on-autogrow="_handleReplyAutogrow"
-      on-send-disabled-changed="_resetReplyOverlayFocusStops"
-      hidden$="[[!_loggedIn]]"
-    >
-    </gr-reply-dialog>
+    <template is="dom-if" if="[[replyOverlayOpened]]">
+      <gr-reply-dialog
+        id="replyDialog"
+        change="{{_change}}"
+        patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+        permitted-labels="[[_change.permitted_labels]]"
+        draft-comment-threads="[[_draftCommentThreads]]"
+        project-config="[[_projectConfig]]"
+        server-config="[[_serverConfig]]"
+        can-be-started="[[_canStartReview]]"
+        on-send="_handleReplySent"
+        on-cancel="_handleReplyCancel"
+        on-autogrow="_handleReplyAutogrow"
+        on-send-disabled-changed="_resetReplyOverlayFocusStops"
+        hidden$="[[!_loggedIn]]"
+      >
+      </gr-reply-dialog>
+    </template>
   </gr-overlay>
-  <gr-comment-api id="commentAPI"></gr-comment-api>
 `;
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 5ef23b8..3ec70c1 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
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma';
 import '../../edit/gr-edit-constants';
+import '../gr-thread-list/gr-thread-list';
 import './gr-change-view';
 import {
   ChangeStatus,
@@ -26,16 +27,21 @@
   HttpMethod,
   MessageTag,
   PrimaryTab,
+  createDefaultPreferences,
 } from '../../../constants/constants';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
 import {EventType, PluginApi} from '../../../api/plugin';
-
-import 'lodash/lodash';
-import {mockPromise, stubRestApi} from '../../../test/test-utils';
+import {
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+  stubUsers,
+  waitQueryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
 import {
   createAppElementChangeViewParams,
   createApproval,
@@ -55,6 +61,7 @@
   createChangeViewChange,
   createRelatedChangeAndCommitInfo,
   createAccountDetailWithId,
+  createParsedChange,
 } from '../../../test/test-data-generators';
 import {ChangeViewPatchRange, GrChangeView} from './gr-change-view';
 import {
@@ -64,10 +71,7 @@
   ChangeId,
   ChangeInfo,
   CommitId,
-  CommitInfo,
-  EditInfo,
   EditPatchSetNum,
-  GitRef,
   NumericChangeId,
   ParentPatchSetNum,
   PatchRange,
@@ -77,6 +81,7 @@
   RevisionInfo,
   RevisionPatchSetNum,
   RobotId,
+  RobotCommentInfo,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
@@ -88,14 +93,17 @@
 import {AppElementChangeViewParams} from '../../gr-app-types';
 import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {CommentThread, UIRobot} from '../../../utils/comment-util';
+import {CommentThread} from '../../../utils/comment-util';
 import {GerritView} from '../../../services/router/router-model';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
-import {appContext} from '../../../services/app-context';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
+import {LoadingStatus} from '../../../models/change/change-model';
+import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
+import {GrThreadList} from '../gr-thread-list/gr-thread-list';
 
-const pluginApi = _testOnly_initGerritPluginApi();
 const fixture = fixtureFromElement('gr-change-view');
 
 suite('gr-change-view tests', () => {
@@ -149,8 +157,6 @@
           message: 'draft',
           unresolved: false,
           __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
           patch_set: 2 as PatchSetNum,
         },
       ],
@@ -225,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',
@@ -253,8 +260,6 @@
           message: 'resolved draft',
           unresolved: false,
           __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
           patch_set: 2 as PatchSetNum,
         },
       ],
@@ -352,7 +357,7 @@
     element._changeNum = TEST_NUMERIC_CHANGE_ID;
     sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
     getPluginLoader().loadPlugins([]);
-    pluginApi.install(
+    window.Gerrit.install(
       plugin => {
         plugin.registerDynamicCustomComponent(
           'change-view-tab-header',
@@ -385,7 +390,7 @@
       new CustomEvent('message-anchor-tap', {detail: {id: 'a12345'}})
     );
 
-    assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+    assert.equal(getUrlStub.lastCall.args[1]!.messageHash, '#message-a12345');
     assert.isTrue(replaceStateStub.called);
   });
 
@@ -402,7 +407,7 @@
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[0], element._change);
-    assert.equal(args[1], 3 as PatchSetNum);
+    assert.equal(args[1]!.patchNum, 3 as PatchSetNum);
   });
 
   test('_handleDiffAgainstLatest', () => {
@@ -418,8 +423,8 @@
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[0], element._change);
-    assert.equal(args[1], 10 as PatchSetNum);
-    assert.equal(args[2], 1 as BasePatchSetNum);
+    assert.equal(args[1]!.patchNum, 10 as PatchSetNum);
+    assert.equal(args[1]!.basePatchNum, 1 as BasePatchSetNum);
   });
 
   test('_handleDiffBaseAgainstLeft', () => {
@@ -435,7 +440,7 @@
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[0], element._change);
-    assert.equal(args[1], 1 as PatchSetNum);
+    assert.equal(args[1]!.patchNum, 1 as PatchSetNum);
   });
 
   test('_handleDiffRightAgainstLatest', () => {
@@ -450,8 +455,8 @@
     element._handleDiffRightAgainstLatest();
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[1], 10 as PatchSetNum);
-    assert.equal(args[2], 3 as BasePatchSetNum);
+    assert.equal(args[1]!.patchNum, 10 as PatchSetNum);
+    assert.equal(args[1]!.basePatchNum, 3 as BasePatchSetNum);
   });
 
   test('_handleDiffBaseAgainstLatest', () => {
@@ -466,8 +471,8 @@
     element._handleDiffBaseAgainstLatest();
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[1], 10 as PatchSetNum);
-    assert.isNotOk(args[2]);
+    assert.equal(args[1]!.patchNum, 10 as PatchSetNum);
+    assert.isNotOk(args[1]!.basePatchNum);
   });
 
   test('toggle attention set status', async () => {
@@ -545,13 +550,12 @@
 
     test('param change should switch primary tab correctly', async () => {
       assert.equal(element._activeTabs[0], PrimaryTab.FILES);
-      const queryMap = new Map<string, string>();
-      queryMap.set('tab', PrimaryTab.FINDINGS);
       // view is required
+      element._changeNum = undefined;
       element.params = {
         ...createAppElementChangeViewParams(),
         ...element.params,
-        queryMap,
+        tab: PrimaryTab.FINDINGS,
       };
       await flush();
       assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
@@ -559,13 +563,11 @@
 
     test('invalid param change should not switch primary tab', async () => {
       assert.equal(element._activeTabs[0], PrimaryTab.FILES);
-      const queryMap = new Map<string, string>();
-      queryMap.set('tab', 'random');
       // view is required
       element.params = {
         ...createAppElementChangeViewParams(),
         ...element.params,
-        queryMap,
+        tab: 'random',
       };
       await flush();
       assert.equal(element._activeTabs[0], PrimaryTab.FILES);
@@ -660,15 +662,6 @@
         messages: createChangeMessages(1),
       };
       element._change.labels = {};
-      stubRestApi('getChangeDetail').callsFake(() =>
-        Promise.resolve({
-          ...createChangeViewChange(),
-          // element has latest info
-          revisions: createRevisions(1),
-          messages: createChangeMessages(1),
-          current_revision: 'rev1' as CommitId,
-        })
-      );
 
       const openSpy = sinon.spy(element, '_openReplyDialog');
 
@@ -678,9 +671,7 @@
       element.$.replyOverlay.close();
       assert.isFalse(element.$.replyOverlay.opened);
       assert(
-        openSpy.lastCall.calledWithExactly(
-          element.$.replyDialog.FocusTarget.ANY
-        ),
+        openSpy.lastCall.calledWithExactly(FocusTarget.ANY),
         '_openReplyDialog should have been passed ANY'
       );
       assert.equal(openSpy.callCount, 1);
@@ -702,7 +693,8 @@
         },
       };
       const handlerSpy = sinon.spy(element, '_handleHideBackgroundContent');
-      element.$.replyDialog.dispatchEvent(
+      const overlay = queryAndAssert<GrOverlay>(element, '#replyOverlay');
+      overlay.dispatchEvent(
         new CustomEvent('fullscreen-overlay-opened', {
           composed: true,
           bubbles: true,
@@ -729,7 +721,8 @@
         },
       };
       const handlerSpy = sinon.spy(element, '_handleShowBackgroundContent');
-      element.$.replyDialog.dispatchEvent(
+      const overlay = queryAndAssert<GrOverlay>(element, '#replyOverlay');
+      overlay.dispatchEvent(
         new CustomEvent('fullscreen-overlay-closed', {
           composed: true,
           bubbles: true,
@@ -803,20 +796,30 @@
       assert.isTrue(stub.called);
     });
 
-    test('m should toggle diff mode', () => {
-      const setModeStub = sinon.stub(
-        element.$.fileListHeader,
-        'setDiffViewMode'
+    test('m should toggle diff mode', async () => {
+      const updatePreferencesStub = stubUsers('updatePreferences');
+      await flush();
+
+      const prefs = {
+        ...createDefaultPreferences(),
+        diff_view: DiffViewMode.SIDE_BY_SIDE,
+      };
+      element.userModel.setPreferences(prefs);
+      element._handleToggleDiffMode();
+      assert.isTrue(
+        updatePreferencesStub.calledWith({diff_view: DiffViewMode.UNIFIED})
       );
-      flush();
 
-      element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
+      const newPrefs = {
+        ...createDefaultPreferences(),
+        diff_view: DiffViewMode.UNIFIED,
+      };
+      element.userModel.setPreferences(newPrefs);
+      await flush();
       element._handleToggleDiffMode();
-      assert.isTrue(setModeStub.calledWith(DiffViewMode.UNIFIED));
-
-      element.viewState.diffMode = DiffViewMode.UNIFIED;
-      element._handleToggleDiffMode();
-      assert.isTrue(setModeStub.calledWith(DiffViewMode.SIDE_BY_SIDE));
+      assert.isTrue(
+        updatePreferencesStub.calledWith({diff_view: DiffViewMode.SIDE_BY_SIDE})
+      );
     });
   });
 
@@ -856,7 +859,43 @@
     });
   });
 
-  suite('Findings comment tab', () => {
+  suite('Comments tab', () => {
+    setup(async () => {
+      element._changeNum = TEST_NUMERIC_CHANGE_ID;
+      element._change = {
+        ...createChangeViewChange(),
+        revisions: {
+          rev2: createRevision(2),
+          rev1: createRevision(1),
+          rev13: createRevision(13),
+          rev3: createRevision(3),
+          rev4: createRevision(4),
+        },
+        current_revision: 'rev4' as CommitId,
+      };
+      element._commentThreads = THREADS;
+      await flush();
+      const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
+      tap(paperTabs.querySelectorAll('paper-tab')[1]);
+      await flush();
+    });
+
+    test('commentId overrides unresolveOnly default', async () => {
+      const threadList = queryAndAssert<GrThreadList>(
+        element,
+        'gr-thread-list'
+      );
+      assert.isTrue(element.unresolvedOnly);
+      assert.isNotOk(element.scrollCommentId);
+      assert.isTrue(threadList.unresolvedOnly);
+
+      element.scrollCommentId = 'abcd' as UrlEncodedCommentId;
+      await flush();
+      assert.isFalse(threadList.unresolvedOnly);
+    });
+  });
+
+  suite('Findings robot-comment tab', () => {
     setup(async () => {
       element._changeNum = TEST_NUMERIC_CHANGE_ID;
       element._change = {
@@ -902,11 +941,13 @@
     test('only robot comments are rendered', () => {
       assert.equal(element._robotCommentThreads!.length, 2);
       assert.equal(
-        (element._robotCommentThreads![0].comments[0] as UIRobot).robot_id,
+        (element._robotCommentThreads![0].comments[0] as RobotCommentInfo)
+          .robot_id,
         'rc1'
       );
       assert.equal(
-        (element._robotCommentThreads![1].comments[0] as UIRobot).robot_id,
+        (element._robotCommentThreads![1].comments[0] as RobotCommentInfo)
+          .robot_id,
         'rc2'
       );
     });
@@ -1006,7 +1047,7 @@
   suite('ChangeStatus revert', () => {
     test('do not show any chip if no revert created', async () => {
       const change = {
-        ...createChange(),
+        ...createParsedChange(),
         messages: createChangeMessages(2),
       };
       const getChangeStub = stubRestApi('getChange');
@@ -1036,7 +1077,7 @@
 
     test('do not show any chip if all reverts are abandoned', async () => {
       const change = {
-        ...createChange(),
+        ...createParsedChange(),
         messages: createChangeMessages(2),
       };
       change.messages[0].message = 'Created a revert of this change as 12345';
@@ -1074,7 +1115,7 @@
 
     test('show revert created if no revert is merged', async () => {
       const change = {
-        ...createChange(),
+        ...createParsedChange(),
         messages: createChangeMessages(2),
       };
       change.messages[0].message = 'Created a revert of this change as 12345';
@@ -1110,7 +1151,7 @@
 
     test('show revert submitted if revert is merged', async () => {
       const change = {
-        ...createChange(),
+        ...createParsedChange(),
         messages: createChangeMessages(2),
       };
       change.messages[0].message = 'Created a revert of this change as 12345';
@@ -1241,7 +1282,6 @@
       ...createChangeViewChange(),
       labels: {},
     } as ParsedChangeInfo;
-    stubRestApi('getChangeDetail').returns(Promise.resolve(change));
     element._changeNum = undefined;
     element._patchRange = {
       basePatchNum: ParentPatchSetNum,
@@ -1278,52 +1318,6 @@
     assert.equal(element._numFilesShown, 200);
   });
 
-  test('_setDiffViewMode is called with reset when new change is loaded', () => {
-    const setDiffViewModeStub = sinon.stub(element, '_setDiffViewMode');
-    element.viewState = {changeNum: 1 as NumericChangeId};
-    element._changeNum = 2 as NumericChangeId;
-    element._resetFileListViewState();
-    assert.isTrue(setDiffViewModeStub.calledWithExactly(true));
-  });
-
-  test('diffViewMode is propagated from file list header', () => {
-    element.viewState = {diffMode: DiffViewMode.UNIFIED};
-    element.$.fileListHeader.diffViewMode = DiffViewMode.SIDE_BY_SIDE;
-    assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
-  });
-
-  test('diffMode defaults to side by side without preferences', async () => {
-    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
-    // No user prefs or diff view mode set.
-
-    await element._setDiffViewMode()!;
-    assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
-  });
-
-  test('diffMode defaults to preference when not already set', async () => {
-    stubRestApi('getPreferences').returns(
-      Promise.resolve({
-        ...createPreferences(),
-        default_diff_view: DiffViewMode.UNIFIED,
-      })
-    );
-
-    await element._setDiffViewMode()!;
-    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
-  });
-
-  test('existing diffMode overrides preference', async () => {
-    element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
-    stubRestApi('getPreferences').returns(
-      Promise.resolve({
-        ...createPreferences(),
-        default_diff_view: DiffViewMode.UNIFIED,
-      })
-    );
-    await element._setDiffViewMode()!;
-    assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
-  });
-
   test('don’t reload entire page when patchRange changes', async () => {
     const reloadStub = sinon
       .stub(element, 'loadData')
@@ -1333,12 +1327,12 @@
       .callsFake(() => Promise.resolve([undefined, undefined, undefined]));
     flush();
     const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
-
     const value: AppElementChangeViewParams = {
       ...createAppElementChangeViewParams(),
       view: GerritView.CHANGE,
       patchNum: 1 as RevisionPatchSetNum,
     };
+    element._changeNum = undefined;
     element.params = value;
     await flush();
     assert.isTrue(reloadStub.calledOnce);
@@ -1363,13 +1357,17 @@
 
   test('reload ported comments when patchNum changes', async () => {
     sinon.stub(element, 'loadData').callsFake(() => Promise.resolve());
-    sinon.stub(element, '_getCommitInfo');
+    sinon.stub(element, 'loadAndSetCommitInfo');
     sinon.stub(element.$.fileList, 'reload');
     flush();
     const reloadPortedCommentsStub = sinon.stub(
-      element.$.commentAPI,
+      element.getCommentsModel(),
       'reloadPortedComments'
     );
+    const reloadPortedDraftsStub = sinon.stub(
+      element.getCommentsModel(),
+      'reloadPortedDrafts'
+    );
     sinon.stub(element.$.fileList, 'collapseAllDiffs');
 
     const value: AppElementChangeViewParams = {
@@ -1394,9 +1392,10 @@
     element.params = {...value};
     await flush();
     assert.isTrue(reloadPortedCommentsStub.calledOnce);
+    assert.isTrue(reloadPortedDraftsStub.calledOnce);
   });
 
-  test('reload entire page when patchRange doesnt change', async () => {
+  test('do not reload entire page when patchRange doesnt change', async () => {
     const reloadStub = sinon
       .stub(element, 'loadData')
       .callsFake(() => Promise.resolve());
@@ -1404,13 +1403,32 @@
     const value: AppElementChangeViewParams =
       createAppElementChangeViewParams();
     element.params = value;
+    // change already loaded
+    assert.isOk(element._changeNum);
     await flush();
-    assert.isTrue(reloadStub.calledOnce);
+    assert.isFalse(reloadStub.calledOnce);
     element._initialLoadComplete = true;
     element.params = {...value};
     await flush();
-    assert.isTrue(reloadStub.calledTwice);
-    assert.isTrue(collapseStub.calledTwice);
+    assert.isFalse(reloadStub.calledTwice);
+    assert.isFalse(collapseStub.calledTwice);
+  });
+
+  test('forceReload updates the change', async () => {
+    const getChangeStub = stubRestApi('getChangeDetail').returns(
+      Promise.resolve(createParsedChange())
+    );
+    const loadDataStub = sinon
+      .stub(element, 'loadData')
+      .callsFake(() => Promise.resolve());
+    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
+    element.params = {...createAppElementChangeViewParams(), forceReload: true};
+    await flush();
+    assert.isTrue(getChangeStub.called);
+    assert.isTrue(loadDataStub.called);
+    assert.isTrue(collapseStub.called);
+    // patchNum is set by changeChanged, so this verifies that _change was set.
+    assert.isOk(element._patchRange?.patchNum);
   });
 
   test('do not handle new change numbers', async () => {
@@ -1429,15 +1447,27 @@
     assert.isTrue(recreateSpy.calledOnce);
   });
 
-  test('related changes are not updated after other action', async () => {
-    sinon.stub(element, 'loadData').callsFake(() => Promise.resolve());
+  test('related changes are updated when loadData is called', async () => {
     await flush();
     const relatedChanges = element.shadowRoot!.querySelector(
       '#relatedChanges'
     ) as GrRelatedChangesList;
-    sinon.stub(relatedChanges, 'reload');
+    const reloadStub = sinon.stub(relatedChanges, 'reload');
+    stubRestApi('getMergeable').returns(
+      Promise.resolve({...createMergeable(), mergeable: true})
+    );
+
+    element.params = createAppElementChangeViewParams();
+    element.getChangeModel().setState({
+      loadingStatus: LoadingStatus.LOADED,
+      change: {
+        ...createChangeViewChange(),
+      },
+    });
+
     await element.loadData(true);
     assert.isFalse(navigateToChangeStub.called);
+    assert.isTrue(reloadStub.called);
   });
 
   test('_computeCopyTextForTitle', () => {
@@ -1499,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(
@@ -1511,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');
   });
@@ -1618,69 +1648,34 @@
 
   test('topic is coalesced to null', async () => {
     sinon.stub(element, '_changeChanged');
-    stubRestApi('getChangeDetail').returns(
-      Promise.resolve({
+    element.getChangeModel().setState({
+      loadingStatus: LoadingStatus.LOADED,
+      change: {
         ...createChangeViewChange(),
         labels: {},
         current_revision: 'foo' as CommitId,
         revisions: {foo: createRevision()},
-      })
-    );
+      },
+    });
 
-    await element._getChangeDetail();
+    await element.performPostChangeLoadTasks();
     assert.isNull(element._change!.topic);
   });
 
   test('commit sha is populated from getChangeDetail', async () => {
     sinon.stub(element, '_changeChanged');
-    stubRestApi('getChangeDetail').callsFake(() =>
-      Promise.resolve({
+    element.getChangeModel().setState({
+      loadingStatus: LoadingStatus.LOADED,
+      change: {
         ...createChangeViewChange(),
         labels: {},
         current_revision: 'foo' as CommitId,
         revisions: {foo: createRevision()},
-      })
-    );
-
-    await element._getChangeDetail();
-    assert.equal('foo', element._commitInfo!.commit);
-  });
-
-  test('edit is added to change', () => {
-    sinon.stub(element, '_changeChanged');
-    const changeRevision = createRevision();
-    stubRestApi('getChangeDetail').callsFake(() =>
-      Promise.resolve({
-        ...createChangeViewChange(),
-        labels: {},
-        current_revision: 'foo' as CommitId,
-        revisions: {foo: {...changeRevision}},
-      })
-    );
-    const editCommit: CommitInfo = {
-      ...createCommit(),
-      commit: 'bar' as CommitId,
-    };
-    sinon.stub(element, '_getEdit').callsFake(() =>
-      Promise.resolve({
-        base_patch_set_number: 1 as BasePatchSetNum,
-        commit: {...editCommit},
-        base_revision: 'abc',
-        ref: 'some/ref' as GitRef,
-      })
-    );
-    element._patchRange = {};
-
-    return element._getChangeDetail().then(() => {
-      const revs = element._change!.revisions!;
-      assert.equal(Object.keys(revs).length, 2);
-      assert.deepEqual(revs['foo'], changeRevision);
-      assert.deepEqual(revs['bar'], {
-        ...createEditRevision(),
-        commit: editCommit,
-        fetch: undefined,
-      });
+      },
     });
+
+    await element.performPostChangeLoadTasks();
+    assert.equal('foo', element._commitInfo!.commit);
   });
 
   test('_getBasePatchNum', () => {
@@ -1732,9 +1727,7 @@
     const openStub = sinon.stub(element, '_openReplyDialog');
     tap(element.$.replyBtn);
     assert(
-      openStub.lastCall.calledWithExactly(
-        element.$.replyDialog.FocusTarget.ANY
-      ),
+      openStub.lastCall.calledWithExactly(FocusTarget.ANY),
       '_openReplyDialog should have been passed ANY'
     );
     assert.equal(openStub.callCount, 1);
@@ -1753,18 +1746,12 @@
           bubbles: true,
         })
       );
-      assert(
-        openStub.lastCall.calledWithExactly(
-          element.$.replyDialog.FocusTarget.BODY
-        ),
-        '_openReplyDialog should have been passed BODY'
-      );
-      assert.equal(openStub.callCount, 1);
+      assert.isTrue(openStub.calledOnce);
+      assert.equal(openStub.lastCall.args[0], FocusTarget.BODY);
     }
   );
 
   test('reply dialog focus can be controlled', () => {
-    const FocusTarget = element.$.replyDialog.FocusTarget;
     const openStub = sinon.stub(element, '_openReplyDialog');
 
     const e = new CustomEvent('show-reply-dialog', {
@@ -1840,22 +1827,14 @@
 
   suite('reply dialog tests', () => {
     setup(() => {
-      sinon.stub(element.$.replyDialog, '_draftChanged');
       element._change = {
         ...createChangeViewChange(),
-        revisions: createRevisions(1),
+        // element has latest info
+        revisions: {rev1: createRevision()},
         messages: createChangeMessages(1),
+        current_revision: 'rev1' as CommitId,
+        labels: {},
       };
-      element._change.labels = {};
-      stubRestApi('getChangeDetail').callsFake(() =>
-        Promise.resolve({
-          ...createChangeViewChange(),
-          // element has latest info
-          revisions: {rev1: createRevision()},
-          messages: createChangeMessages(1),
-          current_revision: 'rev1' as CommitId,
-        })
-      );
     });
 
     test('show reply dialog on open-reply-dialog event', async () => {
@@ -1871,52 +1850,19 @@
       assert.isTrue(openReplyDialogStub.calledOnce);
     });
 
-    test('reply from comment adds quote text', () => {
+    test('reply from comment adds quote text', async () => {
       const e = new CustomEvent('', {
         detail: {message: {message: 'quote text'}},
       });
       element._handleMessageReply(e);
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-    });
-
-    test('reply from comment replaces quote text', () => {
-      element.$.replyDialog.draft = '> old quote text\n\n some draft text';
-      element.$.replyDialog.quote = '> old quote text\n\n';
-      const e = new CustomEvent('', {
-        detail: {message: {message: 'quote text'}},
-      });
-      element._handleMessageReply(e);
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-    });
-
-    test('reply from same comment preserves quote text', () => {
-      element.$.replyDialog.draft = '> quote text\n\n some draft text';
-      element.$.replyDialog.quote = '> quote text\n\n';
-      const e = new CustomEvent('', {
-        detail: {message: {message: 'quote text'}},
-      });
-      element._handleMessageReply(e);
-      assert.equal(
-        element.$.replyDialog.draft,
-        '> quote text\n\n some draft text'
+      const dialog = await waitQueryAndAssert<GrReplyDialog>(
+        element,
+        '#replyDialog'
       );
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-    });
-
-    test('reply from top of page contains previous draft', () => {
-      const div = document.createElement('div');
-      element.$.replyDialog.draft = '> quote text\n\n some draft text';
-      element.$.replyDialog.quote = '> quote text\n\n';
-      const e = {
-        target: div,
-        preventDefault: sinon.spy(),
-      } as unknown as MouseEvent;
-      element._handleReplyTap(e);
-      assert.equal(
-        element.$.replyDialog.draft,
-        '> quote text\n\n some draft text'
-      );
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+      const openSpy = sinon.spy(dialog, 'open');
+      await flush();
+      await waitUntil(() => openSpy.called && !!openSpy.lastCall.args[1]);
+      assert.equal(openSpy.lastCall.args[1], '> quote text\n\n');
     });
   });
 
@@ -1990,53 +1936,36 @@
       ...createChangeViewChange(),
       current_revision: 'foo' as CommitId,
       revisions: {
-        foo: {...createRevision(), actions: {cherrypick: {enabled: true}}},
+        foo: {...createRevision()},
       },
     };
-    let mockChange;
 
-    // With no edit, mockChange should be unmodified.
-    element._processEdit((mockChange = _.cloneDeep(change)), false);
-    assert.deepEqual(mockChange, change);
+    // With no edit, nothing happens.
+    element._processEdit(change);
+    assert.equal(element._patchRange.patchNum, undefined);
 
-    const editCommit: CommitInfo = {
-      ...createCommit(),
-      commit: 'bar' as CommitId,
-    };
-    // When edit is not based on the latest PS, current_revision should be
-    // unmodified.
-    const edit: EditInfo = {
-      ref: 'ref/test/abc' as GitRef,
-      base_revision: 'abc',
-      base_patch_set_number: 1 as BasePatchSetNum,
-      commit: {...editCommit},
+    change.revisions['bar'] = {
+      _number: EditPatchSetNum,
+      basePatchNum: 1 as BasePatchSetNum,
+      commit: {
+        ...createCommit(),
+        commit: 'bar' as CommitId,
+      },
       fetch: {},
     };
-    element._processEdit((mockChange = _.cloneDeep(change)), edit);
-    assert.notDeepEqual(mockChange, change);
-    assert.equal(mockChange.revisions.bar._number, EditPatchSetNum);
-    assert.equal(mockChange.current_revision, change.current_revision);
-    assert.deepEqual(mockChange.revisions.bar.commit, editCommit);
-    assert.notOk(mockChange.revisions.bar.actions);
 
-    edit.base_revision = 'foo';
-    element._processEdit((mockChange = _.cloneDeep(change)), edit);
-    assert.notDeepEqual(mockChange, change);
-    assert.equal(mockChange.current_revision, 'bar');
-    assert.deepEqual(
-      mockChange.revisions.bar.actions,
-      mockChange.revisions.foo.actions
-    );
+    // When edit is set, but not patchNum, then switch to edit ps.
+    element._processEdit(change);
+    assert.equal(element._patchRange.patchNum, EditPatchSetNum);
 
-    // If _patchRange.patchNum is defined, do not load edit.
+    // When edit is set, but patchNum as well, then keep patchNum.
     element._patchRange.patchNum = 5 as RevisionPatchSetNum;
-    change.current_revision = 'baz' as CommitId;
-    element._processEdit((mockChange = _.cloneDeep(change)), edit);
+    element.routerPatchNum = 5 as RevisionPatchSetNum;
+    element._processEdit(change);
     assert.equal(element._patchRange.patchNum, 5 as RevisionPatchSetNum);
-    assert.notOk(mockChange.revisions.bar.actions);
   });
 
-  test('file-action-tap handling', () => {
+  test('file-action-tap handling', async () => {
     element._patchRange = {
       basePatchNum: ParentPatchSetNum,
       patchNum: 1 as RevisionPatchSetNum,
@@ -2047,10 +1976,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');
@@ -2118,8 +2049,9 @@
   test('_selectedRevision updates when patchNum is changed', () => {
     const revision1: RevisionInfo = createRevision(1);
     const revision2: RevisionInfo = createRevision(2);
-    stubRestApi('getChangeDetail').returns(
-      Promise.resolve({
+    element.getChangeModel().setState({
+      loadingStatus: LoadingStatus.LOADED,
+      change: {
         ...createChangeViewChange(),
         revisions: {
           aaa: revision1,
@@ -2128,14 +2060,14 @@
         labels: {},
         actions: {},
         current_revision: 'bbb' as CommitId,
-      })
-    );
-    sinon.stub(element, '_getEdit').returns(Promise.resolve(false));
+      },
+    });
+
     sinon
       .stub(element, '_getPreferences')
       .returns(Promise.resolve(createPreferences()));
     element._patchRange = {patchNum: 2 as RevisionPatchSetNum};
-    return element._getChangeDetail().then(() => {
+    return element.performPostChangeLoadTasks().then(() => {
       assert.strictEqual(element._selectedRevision, revision2);
 
       element.set('_patchRange.patchNum', '1');
@@ -2143,12 +2075,13 @@
     });
   });
 
-  test('_selectedRevision is assigned when patchNum is edit', () => {
+  test('_selectedRevision is assigned when patchNum is edit', async () => {
     const revision1 = createRevision(1);
     const revision2 = createRevision(2);
     const revision3 = createEditRevision();
-    stubRestApi('getChangeDetail').returns(
-      Promise.resolve({
+    element.getChangeModel().setState({
+      loadingStatus: LoadingStatus.LOADED,
+      change: {
         ...createChangeViewChange(),
         revisions: {
           aaa: revision1,
@@ -2158,16 +2091,14 @@
         labels: {},
         actions: {},
         current_revision: 'ccc' as CommitId,
-      })
-    );
-    sinon.stub(element, '_getEdit').returns(Promise.resolve(undefined));
+      },
+    });
     sinon
       .stub(element, '_getPreferences')
       .returns(Promise.resolve(createPreferences()));
     element._patchRange = {patchNum: EditPatchSetNum};
-    return element._getChangeDetail().then(() => {
-      assert.strictEqual(element._selectedRevision, revision3);
-    });
+    await element.performPostChangeLoadTasks();
+    assert.strictEqual(element._selectedRevision, revision3);
   });
 
   test('_sendShowChangeEvent', () => {
@@ -2175,7 +2106,7 @@
     element._change = {...change};
     element._patchRange = {patchNum: 4 as RevisionPatchSetNum};
     element._mergeable = true;
-    const showStub = sinon.stub(appContext.jsApiService, 'handleEvent');
+    const showStub = sinon.stub(element.jsAPI, 'handleEvent');
     element._sendShowChangeEvent();
     assert.isTrue(showStub.calledOnce);
     assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
@@ -2186,6 +2117,39 @@
     });
   });
 
+  test('patch range changed', () => {
+    element._patchRange = undefined;
+    element._change = createChangeViewChange();
+    element._change.revisions = createRevisions(4);
+    element._change.current_revision = '1' as CommitId;
+    element._change = {...element._change};
+
+    const params = createAppElementChangeViewParams();
+
+    assert.isFalse(element.hasPatchRangeChanged(params));
+    assert.isFalse(element.hasPatchNumChanged(params));
+
+    params.basePatchNum = ParentPatchSetNum;
+    // undefined means navigate to latest patchset
+    params.patchNum = undefined;
+
+    element._patchRange = {
+      patchNum: 2 as RevisionPatchSetNum,
+      basePatchNum: ParentPatchSetNum,
+    };
+
+    assert.isTrue(element.hasPatchRangeChanged(params));
+    assert.isTrue(element.hasPatchNumChanged(params));
+
+    element._patchRange = {
+      patchNum: 4 as RevisionPatchSetNum,
+      basePatchNum: ParentPatchSetNum,
+    };
+
+    assert.isFalse(element.hasPatchRangeChanged(params));
+    assert.isFalse(element.hasPatchNumChanged(params));
+  });
+
   suite('_handleEditTap', () => {
     let fireEdit: () => void;
 
@@ -2205,7 +2169,7 @@
       const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
         assert.equal(args.length, 2);
-        assert.equal(args[1], EditPatchSetNum); // patchNum
+        assert.equal(args[1]!.patchNum, EditPatchSetNum); // patchNum
         promise.resolve();
       });
 
@@ -2221,9 +2185,9 @@
     test('no edit exists in revisions, non-latest patchset', async () => {
       const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-        assert.equal(args.length, 4);
-        assert.equal(args[1], 1 as PatchSetNum); // patchNum
-        assert.equal(args[3], true); // opt_isEdit
+        assert.equal(args.length, 2);
+        assert.equal(args[1]!.patchNum, 1 as PatchSetNum); // patchNum
+        assert.equal(args[1]!.isEdit, true); // opt_isEdit
         promise.resolve();
       });
 
@@ -2238,10 +2202,10 @@
     test('no edit exists in revisions, latest patchset', async () => {
       const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-        assert.equal(args.length, 4);
+        assert.equal(args.length, 2);
         // No patch should be specified when patchNum == latest.
-        assert.isNotOk(args[1]); // patchNum
-        assert.equal(args[3], true); // opt_isEdit
+        assert.isNotOk(args[1]!.patchNum); // patchNum
+        assert.equal(args[1]!.isEdit, true); // opt_isEdit
         promise.resolve();
       });
 
@@ -2258,12 +2222,12 @@
     element._change = {
       ...createChangeViewChange(),
     };
-    sinon.stub(element.$.metadata, '_computeLabelNames');
+    sinon.stub(element.$.metadata, 'computeLabelNames');
     navigateToChangeStub.restore();
     const promise = mockPromise();
     sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
       assert.equal(args.length, 2);
-      assert.equal(args[1], 1 as PatchSetNum); // patchNum
+      assert.equal(args[1]!.patchNum, 1 as PatchSetNum); // patchNum
       promise.resolve();
     });
 
@@ -2279,7 +2243,11 @@
       element._change = {...createChangeViewChange(), labels: {}};
       element._selectedRevision = createRevision();
       const promise = mockPromise();
-      pluginApi.install(promise.resolve, '0.1', 'http://some/plugins/url.js');
+      window.Gerrit.install(
+        promise.resolve,
+        '0.1',
+        'http://some/plugins/url.js'
+      );
       await flush();
       const plugin: PluginApi = (await promise) as PluginApi;
       const hookEl = await plugin
@@ -2327,17 +2295,19 @@
     });
   });
 
-  test('_handleToggleStar called when star is tapped', () => {
+  test('_handleToggleStar called when star is tapped', async () => {
     element._change = {
       ...createChangeViewChange(),
       owner: {_account_id: 1 as AccountId},
       starred: false,
     };
     element._loggedIn = true;
-    const stub = sinon.stub(element, '_handleToggleStar');
-    flush();
+    await flush();
 
-    tap(element.$.changeStar.shadowRoot!.querySelector('button')!);
+    const stub = sinon.stub(element, '_handleToggleStar');
+
+    const changeStar = queryAndAssert<GrChangeStar>(element, '#changeStar');
+    tap(queryAndAssert<HTMLButtonElement>(changeStar, 'button')!);
     assert.isTrue(stub.called);
   });
 
@@ -2347,7 +2317,9 @@
         basePatchNum: ParentPatchSetNum,
         patchNum: 1 as RevisionPatchSetNum,
       };
-      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(false));
+      sinon
+        .stub(element, 'performPostChangeLoadTasks')
+        .returns(Promise.resolve(false));
       sinon.stub(element, '_getProjectConfig').returns(Promise.resolve());
       sinon.stub(element, '_getMergeability').returns(Promise.resolve());
       sinon.stub(element, '_getLatestCommitMessage').returns(Promise.resolve());
@@ -2358,11 +2330,11 @@
 
     test("don't report changeDisplayed on reply", async () => {
       const changeDisplayStub = sinon.stub(
-        appContext.reportingService,
+        element.reporting,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        appContext.reportingService,
+        element.reporting,
         'changeFullyLoaded'
       );
       element._handleReplySent();
@@ -2373,18 +2345,29 @@
 
     test('report changeDisplayed on _paramsChanged', async () => {
       const changeDisplayStub = sinon.stub(
-        appContext.reportingService,
+        element.reporting,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        appContext.reportingService,
+        element.reporting,
         'changeFullyLoaded'
       );
+      // reset so reload is triggered
+      element._changeNum = undefined;
       element.params = {
         ...createAppElementChangeViewParams(),
         changeNum: TEST_NUMERIC_CHANGE_ID,
         project: TEST_PROJECT_NAME,
       };
+      element.getChangeModel().setState({
+        loadingStatus: LoadingStatus.LOADED,
+        change: {
+          ...createChangeViewChange(),
+          labels: {},
+          current_revision: 'foo' as CommitId,
+          revisions: {foo: createRevision()},
+        },
+      });
       await flush();
       assert.isTrue(changeDisplayStub.called);
       assert.isTrue(changeFullyLoadedStub.called);
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-abandon-dialog/gr-confirm-abandon-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
index df537e0..323c439 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
@@ -16,18 +16,13 @@
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-abandon-dialog_html';
-import {customElement, property} from '@polymer/decorators';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
-
-export interface GrConfirmAbandonDialog {
-  $: {
-    messageInput: IronAutogrowTextareaElement;
-  };
-}
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, property, query} from 'lit/decorators';
+import {assertIsDefined} from '../../../utils/common-util';
+import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -36,11 +31,7 @@
 }
 
 @customElement('gr-confirm-abandon-dialog')
-export class GrConfirmAbandonDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmAbandonDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -53,6 +44,8 @@
    * @event cancel
    */
 
+  @query('#messageInput') private messageInput?: IronAutogrowTextareaElement;
+
   @property({type: String})
   message = '';
 
@@ -69,27 +62,90 @@
     super.connectedCallback();
     this.cleanups.push(
       addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
-        this._confirm()
+        this.confirm()
       )
     );
     this.cleanups.push(
       addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
-        this._confirm()
+        this.confirm()
       )
     );
   }
 
-  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%;
+        }
+        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="Abandon"
+        @confirm=${(e: Event) => {
+          this.handleConfirmTap(e);
+        }}
+        @cancel=${(e: Event) => {
+          this.handleCancelTap(e);
+        }}
+      >
+        <div class="header" slot="header">Abandon Change</div>
+        <div class="main" slot="main">
+          <label for="messageInput">Abandon Message</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.handleBindValueChanged(e);
+            }}
+          ></iron-autogrow-textarea>
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  resetFocus() {
+    assertIsDefined(this.messageInput, 'messageInput');
+    this.messageInput.textarea.focus();
+  }
+
+  // private but used in test
+  handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this._confirm();
+    this.confirm();
   }
 
-  _confirm() {
+  // private but used in test
+  confirm() {
     this.dispatchEvent(
       new CustomEvent('confirm', {
         detail: {reason: this.message},
@@ -99,7 +155,8 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  // private but used in test
+  handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -109,4 +166,8 @@
       })
     );
   }
+
+  private handleBindValueChanged(e: BindValueChangeEvent) {
+    this.message = e.detail.value;
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.ts
deleted file mode 100644
index 7c1b725..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.ts
+++ /dev/null
@@ -1,62 +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%;
-    }
-    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="Abandon"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Abandon Change</div>
-    <div class="main" slot="main">
-      <label for="messageInput">Abandon Message</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/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts
index 08dda14..a737b08 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts
@@ -26,15 +26,16 @@
 suite('gr-confirm-abandon-dialog tests', () => {
   let element: GrConfirmAbandonDialog;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
-  test('_handleConfirmTap', () => {
+  test('handleConfirmTap', () => {
     const confirmHandler = sinon.stub();
     element.addEventListener('confirm', confirmHandler);
-    const confirmTapSpy = sinon.spy(element, '_handleConfirmTap');
-    const confirmSpy = sinon.spy(element, '_confirm');
+    const confirmTapSpy = sinon.spy(element, 'handleConfirmTap');
+    const confirmSpy = sinon.spy(element, 'confirm');
     queryAndAssert<GrDialog>(element, 'gr-dialog').dispatchEvent(
       new CustomEvent('confirm', {
         composed: true,
@@ -48,10 +49,10 @@
     assert.isTrue(confirmSpy.calledOnce);
   });
 
-  test('_handleCancelTap', () => {
+  test('handleCancelTap', () => {
     const cancelHandler = sinon.stub();
     element.addEventListener('cancel', cancelHandler);
-    const cancelTapSpy = sinon.spy(element, '_handleCancelTap');
+    const cancelTapSpy = sinon.spy(element, 'handleCancelTap');
     queryAndAssert<GrDialog>(element, 'gr-dialog').dispatchEvent(
       new CustomEvent('cancel', {
         composed: true,
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 b061043..f8103fd 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,10 +19,8 @@
 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 {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {
   ChangeInfo,
   BranchName,
@@ -30,12 +28,23 @@
   CommitId,
   ChangeInfoId,
 } from '../../../types/common';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {customElement, property, observe} from '@polymer/decorators';
-import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {HttpMethod, ChangeStatus} from '../../../constants/constants';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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 {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;
@@ -44,15 +53,7 @@
   TOPIC,
 }
 
-// These values are directly displayed in the dialog to show progress of change
-enum ProgressStatus {
-  RUNNING = 'RUNNING',
-  FAILED = 'FAILED',
-  NOT_STARTED = 'NOT STARTED',
-  SUCCESSFUL = 'SUCCESSFUL',
-}
-
-type Statuses = {[changeId: string]: Status};
+export type Statuses = {[changeId: string]: Status};
 
 interface Status {
   status: ProgressStatus;
@@ -65,18 +66,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.
    *
@@ -113,40 +104,287 @@
   @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;
 
-  @property({type: Object})
-  reporting: ReportingService;
+  @query('#branchInput')
+  branchInput!: GrTypedAutocomplete<BranchName>;
 
   private selectedChangeIds = new Set<ChangeInfoId>();
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly reporting = getAppContext().reportingService;
 
   constructor() {
     super();
-    this._statuses = {};
-    this.reporting = appContext.reportingService;
-    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[]) {
@@ -163,63 +401,60 @@
 
   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) {
-    const changeId = ((dom(e) as EventApi).localTarget as HTMLElement).dataset[
-      'item'
-    ]! as ChangeInfoId;
+  private toggleChangeSelected(e: Event) {
+    const changeId = (e.target as HTMLElement).dataset['item']! as ChangeInfoId;
     if (this.selectedChangeIds.has(changeId))
       this.selectedChangeIds.delete(changeId);
     else this.selectedChangeIds.add(changeId);
     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;
@@ -227,24 +462,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,
@@ -261,64 +496,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)
     );
@@ -327,7 +552,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 = {
@@ -338,7 +563,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;
@@ -353,7 +578,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) {
@@ -365,12 +590,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
@@ -382,7 +607,7 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -394,10 +619,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.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
deleted file mode 100644
index 3df997f8..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
+++ /dev/null
@@ -1,212 +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-confirm-cherrypick-dialog.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-confirm-cherrypick-dialog');
-
-const CHERRY_PICK_TYPES = {
-  SINGLE_CHANGE: 1,
-  TOPIC: 2,
-};
-suite('gr-confirm-cherrypick-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    stubRestApi('getRepoBranches').callsFake(input => {
-      if (input.startsWith('test')) {
-        return Promise.resolve([
-          {
-            ref: 'refs/heads/test-branch',
-            revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-            can_delete: true,
-          },
-        ]);
-      } else {
-        return Promise.resolve([]);
-      }
-    });
-    element = basicFixture.instantiate();
-    element.project = 'test-project';
-  });
-
-  test('with message missing newline', () => {
-    element.changeStatus = 'MERGED';
-    element.commitMessage = 'message';
-    element.commitNum = '123';
-    element.branch = 'master';
-    flush();
-    const expectedMessage = 'message\n(cherry picked from commit 123)';
-    assert.equal(element.message, expectedMessage);
-  });
-
-  test('with merged change', () => {
-    element.changeStatus = 'MERGED';
-    element.commitMessage = 'message\n';
-    element.commitNum = '123';
-    element.branch = 'master';
-    flush();
-    const expectedMessage = 'message\n(cherry picked from commit 123)';
-    assert.equal(element.message, expectedMessage);
-  });
-
-  test('with unmerged change', () => {
-    element.changeStatus = 'OPEN';
-    element.commitMessage = 'message\n';
-    element.commitNum = '123';
-    element.branch = 'master';
-    flush();
-    const expectedMessage = 'message\n';
-    assert.equal(element.message, expectedMessage);
-  });
-
-  test('with updated commit message', () => {
-    element.changeStatus = 'OPEN';
-    element.commitMessage = 'message\n';
-    element.commitNum = '123';
-    element.branch = 'master';
-    const myNewMessage = 'updated commit message';
-    element.message = myNewMessage;
-    flush();
-    assert.equal(element.message, myNewMessage);
-  });
-
-  test('_getProjectBranchesSuggestions empty', async () => {
-    const branches = await element._getProjectBranchesSuggestions('asdf');
-    assert.isEmpty(branches);
-  });
-
-  suite('cherry pick topic', () => {
-    const changes = [
-      {
-        id: '1234',
-        change_id: '12345678901234', topic: 'T', subject: 'random',
-        project: 'A',
-        _number: 1,
-        revisions: {
-          a: {_number: 1},
-        },
-        current_revision: 'a',
-      },
-      {
-        id: '5678',
-        change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
-        project: 'B',
-        _number: 2,
-        revisions: {
-          a: {_number: 1},
-        },
-        current_revision: 'a',
-      },
-    ];
-    setup(async () => {
-      element.updateChanges(changes);
-      element._cherryPickType = CHERRY_PICK_TYPES.TOPIC;
-      await flush();
-    });
-
-    test('cherry pick topic submit', async () => {
-      element.branch = 'master';
-      await flush();
-      const executeChangeActionStub = stubRestApi(
-          'executeChangeAction').returns(Promise.resolve([]));
-      MockInteractions.tap(element.shadowRoot.
-          querySelector('gr-dialog').confirmButton);
-      await flush();
-      const args = executeChangeActionStub.args[0];
-      assert.equal(args[0], 1);
-      assert.equal(args[1], 'POST');
-      assert.equal(args[2], '/cherrypick');
-      assert.equal(args[4].destination, 'master');
-      assert.isTrue(args[4].allow_conflicts);
-      assert.isTrue(args[4].allow_empty);
-    });
-
-    test('deselecting a change removes it from being cherry picked',
-        async () => {
-          const duplicateChangesStub = sinon.stub(element,
-              'containsDuplicateProject');
-          element.branch = 'master';
-          await flush();
-          const executeChangeActionStub = stubRestApi(
-              'executeChangeAction').returns(Promise.resolve([]));
-          const checkboxes = element.shadowRoot.querySelectorAll(
-              'input[type="checkbox"]');
-          assert.equal(checkboxes.length, 2);
-          assert.isTrue(checkboxes[0].checked);
-          MockInteractions.tap(checkboxes[0]);
-          MockInteractions.tap(element.shadowRoot.
-              querySelector('gr-dialog').confirmButton);
-          await flush();
-          assert.equal(executeChangeActionStub.callCount, 1);
-          assert.isTrue(duplicateChangesStub.called);
-        });
-
-    test('deselecting all change shows error message', async () => {
-      element.branch = 'master';
-      await flush();
-      const executeChangeActionStub = stubRestApi(
-          'executeChangeAction').returns(Promise.resolve([]));
-      const checkboxes = element.shadowRoot.querySelectorAll(
-          'input[type="checkbox"]');
-      assert.equal(checkboxes.length, 2);
-      MockInteractions.tap(checkboxes[0]);
-      MockInteractions.tap(checkboxes[1]);
-      MockInteractions.tap(element.shadowRoot.
-          querySelector('gr-dialog').confirmButton);
-      await flush();
-      assert.equal(executeChangeActionStub.callCount, 0);
-      assert.equal(element.shadowRoot.querySelector('.error-message').innerText
-          , 'No change selected');
-    });
-
-    test('_computeStatusClass', () => {
-      assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'RUNNING'},
-      }), '');
-      assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'FAILED'}}
-      ), 'error');
-    });
-
-    test('submit button is blocked while cherry picks is running', async () => {
-      const confirmButton = element.shadowRoot.querySelector('gr-dialog')
-          .confirmButton;
-      assert.isTrue(confirmButton.hasAttribute('disabled'));
-      element.branch = 'b';
-      await flush();
-      assert.isFalse(confirmButton.hasAttribute('disabled'));
-      element.updateStatus(changes[0], {status: 'RUNNING'});
-      await flush();
-      assert.isTrue(confirmButton.hasAttribute('disabled'));
-    });
-  });
-
-  test('resetFocus', () => {
-    const focusStub = sinon.stub(element.$.branchInput, 'focus');
-    element.resetFocus();
-    assert.isTrue(focusStub.called);
-  });
-
-  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-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
new file mode 100644
index 0000000..45e13b2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
@@ -0,0 +1,262 @@
+/**
+ * @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 './gr-confirm-cherrypick-dialog';
+import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrConfirmCherrypickDialog} from './gr-confirm-cherrypick-dialog';
+import {
+  BranchName,
+  ChangeId,
+  ChangeInfo,
+  ChangeInfoId,
+  ChangeStatus,
+  CommitId,
+  GitRef,
+  HttpMethod,
+  NumericChangeId,
+  RepoName,
+  TopicName,
+} from '../../../api/rest-api';
+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';
+import {ProgressStatus} from '../../../constants/constants';
+import {fixture, html} from '@open-wc/testing-helpers';
+
+const CHERRY_PICK_TYPES = {
+  SINGLE_CHANGE: 1,
+  TOPIC: 2,
+};
+suite('gr-confirm-cherrypick-dialog tests', () => {
+  let element: GrConfirmCherrypickDialog;
+
+  setup(async () => {
+    stubRestApi('getRepoBranches').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            ref: 'refs/heads/test-branch' as GitRef,
+            revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+            can_delete: true,
+          },
+        ]);
+      } else {
+        return Promise.resolve([]);
+      }
+    });
+    element = await fixture(
+      html`<gr-confirm-cherrypick-dialog></gr-confirm-cherrypick-dialog>`
+    );
+    element.project = 'test-project' as RepoName;
+  });
+
+  test('with message missing newline', async () => {
+    element.changeStatus = ChangeStatus.MERGED;
+    element.commitMessage = 'message';
+    element.commitNum = '123' as CommitId;
+    element.branch = 'master' as BranchName;
+    await element.updateComplete;
+    const expectedMessage = 'message\n(cherry picked from commit 123)';
+    assert.equal(element.message, expectedMessage);
+  });
+
+  test('with merged change', async () => {
+    element.changeStatus = ChangeStatus.MERGED;
+    element.commitMessage = 'message\n';
+    element.commitNum = '123' as CommitId;
+    element.branch = 'master' as BranchName;
+    await element.updateComplete;
+    const expectedMessage = 'message\n(cherry picked from commit 123)';
+    assert.equal(element.message, expectedMessage);
+  });
+
+  test('with unmerged change', async () => {
+    element.changeStatus = ChangeStatus.NEW;
+    element.commitMessage = 'message\n';
+    element.commitNum = '123' as CommitId;
+    element.branch = 'master' as BranchName;
+    await element.updateComplete;
+
+    const expectedMessage = 'message\n';
+    assert.equal(element.message, expectedMessage);
+  });
+
+  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;
+    await element.updateComplete;
+
+    assert.equal(element.message, myNewMessage);
+  });
+
+  test('getProjectBranchesSuggestions empty', async () => {
+    const branches = await element.getProjectBranchesSuggestions('asdf');
+    assert.isEmpty(branches);
+  });
+
+  suite('cherry pick topic', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        id: '1234' as ChangeInfoId,
+        change_id: '12345678901234' as ChangeId,
+        topic: 'T' as TopicName,
+        subject: 'random',
+        project: 'A' as RepoName,
+        _number: 1 as NumericChangeId,
+        revisions: {
+          a: createRevision(),
+        },
+        current_revision: 'a' as CommitId,
+      },
+      {
+        ...createChange(),
+        id: '5678' as ChangeInfoId,
+        change_id: '23456' as ChangeId,
+        topic: 'T' as TopicName,
+        subject: 'a'.repeat(100),
+        project: 'B' as RepoName,
+        _number: 2 as NumericChangeId,
+        revisions: {
+          a: createRevision(),
+        },
+        current_revision: 'a' as CommitId,
+      },
+    ];
+    setup(async () => {
+      element.updateChanges(changes);
+      element.cherryPickType = CHERRY_PICK_TYPES.TOPIC;
+      await element.updateComplete;
+    });
+
+    test('cherry pick topic submit', async () => {
+      element.branch = 'master' as BranchName;
+      await element.updateComplete;
+      const executeChangeActionStub = stubRestApi(
+        'executeChangeAction'
+      ).returns(Promise.resolve(new Response()));
+      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);
+      assert.equal(args[2], '/cherrypick');
+      assert.equal((args[4] as any).destination, 'master');
+      assert.isTrue((args[4] as any).allow_conflicts);
+      assert.isTrue((args[4] as any).allow_empty);
+    });
+
+    test('deselecting a change removes it from being cherry picked', async () => {
+      const duplicateChangesStub = sinon.stub(
+        element,
+        'containsDuplicateProject'
+      );
+      element.branch = 'master' as BranchName;
+      await element.updateComplete;
+      const executeChangeActionStub = stubRestApi(
+        'executeChangeAction'
+      ).returns(Promise.resolve(new Response()));
+      const checkboxes = queryAll<HTMLInputElement>(
+        element,
+        'input[type="checkbox"]'
+      );
+      assert.equal(checkboxes.length, 2);
+      assert.isTrue(checkboxes[0].checked);
+      MockInteractions.tap(checkboxes[0]);
+      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 element.updateComplete;
+      const executeChangeActionStub = stubRestApi(
+        'executeChangeAction'
+      ).returns(Promise.resolve(new Response()));
+      const checkboxes = queryAll<HTMLInputElement>(
+        element,
+        'input[type="checkbox"]'
+      );
+      assert.equal(checkboxes.length, 2);
+      MockInteractions.tap(checkboxes[0]);
+      MockInteractions.tap(checkboxes[1]);
+      MockInteractions.tap(
+        queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!
+      );
+      await element.updateComplete;
+      assert.equal(executeChangeActionStub.callCount, 0);
+      assert.equal(
+        queryAndAssert<HTMLElement>(element, '.error-message').innerText,
+        'No change selected'
+      );
+    });
+
+    test('computeStatusClass', async () => {
+      assert.equal(
+        element.computeStatusClass(
+          {...createChange(), id: '1' as ChangeInfoId},
+          {1: {status: ProgressStatus.RUNNING}}
+        ),
+        ''
+      );
+      assert.equal(
+        element.computeStatusClass(
+          {...createChange(), id: '1' as ChangeInfoId},
+          {1: {status: ProgressStatus.FAILED}}
+        ),
+        'error'
+      );
+    });
+
+    test('submit button is blocked while cherry picks is running', async () => {
+      const confirmButton = queryAndAssert<GrDialog>(
+        element,
+        'gr-dialog'
+      ).confirmButton;
+      assert.isTrue(confirmButton!.hasAttribute('disabled'));
+      element.branch = 'b' as BranchName;
+      await element.updateComplete;
+      assert.isFalse(confirmButton!.hasAttribute('disabled'));
+      element.updateStatus(changes[0], {status: ProgressStatus.RUNNING});
+      await element.updateComplete;
+      assert.isTrue(confirmButton!.hasAttribute('disabled'));
+    });
+  });
+
+  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');
+    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 b3bbc8a..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 {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 '../../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 {BranchName, RepoName} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
-import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+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 = appContext.restApiService;
+  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 77b7717..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 {appContext} from '../../../services/app-context';
-import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {getAppContext} from '../../../services/app-context';
+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[];
 
-  private readonly restApiService = appContext.restApiService;
+  @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 b971039..8ea2bf5 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,17 +15,19 @@
  * 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 {appContext} from '../../../services/app-context';
+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;
+const INSERT_REASON_STRING = '<INSERT REASONING HERE>';
 
 // TODO(dhruvsri): clean up repeated definitions after moving to js modules
 export enum RevertType {
@@ -39,11 +41,7 @@
 }
 
 @customElement('gr-confirm-revert-dialog')
-export class GrConfirmRevertDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmRevertDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -58,57 +56,154 @@
 
   /* 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[] = [];
 
-  private readonly jsAPI = appContext.jsApiService;
+  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);
+      }
+    `,
+  ];
 
-  _computeIfSingleRevert(revertType: number) {
-    return revertType === RevertType.REVERT_SINGLE_CHANGE;
+  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>
+    `;
   }
 
-  _computeIfRevertSubmission(revertType: number) {
-    return revertType === RevertType.REVERT_SUBMISSION;
+  private readonly jsAPI = getAppContext().jsApiService;
+
+  private computeIfSingleRevert() {
+    return this.revertType === RevertType.REVERT_SINGLE_CHANGE;
   }
 
-  _modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
+  private computeIfRevertSubmission() {
+    return this.revertType === RevertType.REVERT_SUBMISSION;
+  }
+
+  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
@@ -124,22 +219,22 @@
 
     const message =
       `${revertTitle}\n\n${revertCommitText}\n\n` +
-      'Reason for revert: <INSERT REASONING HERE>\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
@@ -147,7 +242,7 @@
     return this.jsAPI.modifyRevertSubmissionMsg(change, msg, commitMessage);
   }
 
-  _populateRevertSubmissionMessage(
+  populateRevertSubmissionMessage(
     change: ChangeInfo,
     changes: ChangeInfo[],
     commitMessage: string
@@ -169,45 +264,52 @@
     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._showErrorMessage = true;
+    if (
+      this.message === this.originalRevertMessages[this.revertType] ||
+      this.message.includes(INSERT_REASON_STRING)
+    ) {
+      this.showErrorMessage = true;
       return;
     }
     const detail: ConfirmRevertEventDetail = {
-      revertType: this._revertType,
-      message: this._message,
+      revertType: this.revertType,
+      message: this.message,
     };
     this.dispatchEvent(
       new CustomEvent('confirm', {
@@ -218,12 +320,12 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
       new CustomEvent('cancel', {
-        detail: {revertType: this._revertType},
+        detail: {revertType: this.revertType},
         composed: true,
         bubbles: false,
       })
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 9d371d3..de9395f 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
@@ -20,14 +20,19 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../gr-thread-list/gr-thread-list';
-import {ChangeInfo, ActionInfo} from '../../../types/common';
+import {ActionInfo} from '../../../types/common';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {pluralize} from '../../../utils/string-util';
 import {CommentThread, isUnresolved} from '../../../utils/comment-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, query} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {ParsedChangeInfo} from '../../../types/types';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {resolve} from '../../../models/dependency';
 
 @customElement('gr-confirm-submit-dialog')
 export class GrConfirmSubmitDialog extends LitElement {
@@ -47,16 +52,20 @@
    */
 
   @property({type: Object})
-  change?: ChangeInfo;
-
-  @property({type: Object})
   action?: ActionInfo;
 
-  @property({type: Array})
-  commentThreads?: CommentThread[] = [];
+  @state()
+  change?: ParsedChangeInfo;
 
-  @property({type: Boolean})
-  _initialised = false;
+  @state()
+  unresolvedThreads: CommentThread[] = [];
+
+  @state()
+  initialised = false;
+
+  private getCommentsModel = resolve(this, commentsModelToken);
+
+  private getChangeModel = resolve(this, changeModelToken);
 
   static override get styles() {
     return [
@@ -84,6 +93,16 @@
     ];
   }
 
+  override connectedCallback() {
+    super.connectedCallback();
+    subscribe(this, this.getChangeModel().change$, x => (this.change = x));
+    subscribe(
+      this,
+      this.getCommentsModel().threads$,
+      x => (this.unresolvedThreads = x.filter(isUnresolved))
+    );
+  }
+
   private renderPrivate() {
     if (!this.change?.is_private) return '';
     return html`
@@ -99,21 +118,18 @@
   }
 
   private renderUnresolvedCommentCount() {
-    if (!this.change?.unresolved_comment_count) return '';
+    if (!this.unresolvedThreads?.length) return '';
     return html`
       <p>
         <iron-icon
           icon="gr-icons:warning"
           class="warningBeforeSubmit"
         ></iron-icon>
-        ${this._computeUnresolvedCommentsWarning(this.change)}
+        ${this.computeUnresolvedCommentsWarning()}
       </p>
       <gr-thread-list
         id="commentList"
-        .threads="${this._computeUnresolvedThreads(this.commentThreads)}"
-        .change="${this.change}"
-        .changeNum="${this.change?._number}"
-        logged-in
+        .threads=${this.unresolvedThreads}
         hide-dropdown
       >
       </gr-thread-list>
@@ -121,7 +137,7 @@
   }
 
   private renderChangeEdit() {
-    if (!this._computeHasChangeEdit(this.change)) return '';
+    if (!this.computeHasChangeEdit()) return '';
     return html`
       <iron-icon
         icon="gr-icons:warning"
@@ -133,7 +149,7 @@
   }
 
   private renderInitialised() {
-    if (!this._initialised) return '';
+    if (!this.initialised) return '';
     return html`
       <div class="header" slot="header">${this.action?.label}</div>
       <div class="main" slot="main">
@@ -143,11 +159,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>
@@ -159,48 +175,42 @@
       id="dialog"
       confirm-label="Continue"
       confirm-on-enter=""
-      @cancel=${this._handleCancelTap}
-      @confirm=${this._handleConfirmTap}
+      @cancel=${this.handleCancelTap}
+      @confirm=${this.handleConfirmTap}
     >
       ${this.renderInitialised()}
     </gr-dialog>`;
   }
 
   init() {
-    this._initialised = true;
+    this.initialised = true;
   }
 
   resetFocus() {
     this.dialog?.resetFocus();
   }
 
-  _computeHasChangeEdit(change?: ChangeInfo) {
-    return (
-      !!change &&
-      !!change.revisions &&
-      Object.values(change.revisions).some(rev => rev._number === 'edit')
+  // Private method, but visible for testing.
+  computeHasChangeEdit() {
+    return Object.values(this.change?.revisions ?? {}).some(
+      rev => rev._number === 'edit'
     );
   }
 
-  _computeUnresolvedThreads(commentThreads?: CommentThread[]) {
-    if (!commentThreads) return [];
-    return commentThreads.filter(thread => isUnresolved(thread));
-  }
-
-  _computeUnresolvedCommentsWarning(change?: ChangeInfo) {
-    if (!change) return '';
-    const unresolvedCount = change.unresolved_comment_count;
+  // Private method, but visible for testing.
+  computeUnresolvedCommentsWarning() {
+    const unresolvedCount = this.unresolvedThreads.length;
     if (!unresolvedCount) throw new Error('unresolved comments undefined or 0');
     return `Heads Up! ${pluralize(unresolvedCount, 'unresolved comment')}.`;
   }
 
-  _handleConfirmTap(e: Event) {
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
index e1823b1..9962351 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
@@ -16,10 +16,15 @@
  */
 
 import '../../../test/common-test-setup-karma';
-import {createChange, createRevision} from '../../../test/test-data-generators';
+import {
+  createParsedChange,
+  createRevision,
+  createThread,
+} from '../../../test/test-data-generators';
 import {queryAndAssert} from '../../../test/test-utils';
 import {PatchSetNum} from '../../../types/common';
 import {GrConfirmSubmitDialog} from './gr-confirm-submit-dialog';
+import './gr-confirm-submit-dialog';
 
 const basicFixture = fixtureFromElement('gr-confirm-submit-dialog');
 
@@ -28,17 +33,17 @@
 
   setup(() => {
     element = basicFixture.instantiate();
-    element._initialised = true;
+    element.initialised = true;
   });
 
   test('display', async () => {
     element.action = {label: 'my-label'};
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       subject: 'my-subject',
       revisions: {},
     };
-    await flush();
+    await element.updateComplete;
     const header = queryAndAssert(element, '.header');
     assert.equal(header.textContent!.trim(), 'my-label');
 
@@ -47,23 +52,24 @@
     assert.notEqual(message.textContent!.indexOf('my-subject'), -1);
   });
 
-  test('_computeUnresolvedCommentsWarning', () => {
-    const change = {...createChange(), unresolved_comment_count: 1};
+  test('computeUnresolvedCommentsWarning', () => {
+    element.change = {...createParsedChange()};
+    element.unresolvedThreads = [createThread()];
     assert.equal(
-      element._computeUnresolvedCommentsWarning(change),
+      element.computeUnresolvedCommentsWarning(),
       'Heads Up! 1 unresolved comment.'
     );
 
-    const change2 = {...createChange(), unresolved_comment_count: 2};
+    element.unresolvedThreads = [...element.unresolvedThreads, createThread()];
     assert.equal(
-      element._computeUnresolvedCommentsWarning(change2),
+      element.computeUnresolvedCommentsWarning(),
       'Heads Up! 2 unresolved comments.'
     );
   });
 
-  test('_computeHasChangeEdit', () => {
-    const change = {
-      ...createChange(),
+  test('computeHasChangeEdit', () => {
+    element.change = {
+      ...createParsedChange(),
       revisions: {
         d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
           ...createRevision(),
@@ -73,10 +79,10 @@
       unresolved_comment_count: 0,
     };
 
-    assert.isTrue(element._computeHasChangeEdit(change));
+    assert.isTrue(element.computeHasChangeEdit());
 
-    const change2 = {
-      ...createChange(),
+    element.change = {
+      ...createParsedChange(),
       revisions: {
         d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
           ...createRevision(),
@@ -84,6 +90,6 @@
         },
       },
     };
-    assert.isFalse(element._computeHasChangeEdit(change2));
+    assert.isFalse(element.computeHasChangeEdit());
   });
 });
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 92f4a87..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
@@ -14,46 +14,37 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-font-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-download-commands/gr-download-commands';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-download-dialog_html';
 import {changeBaseURL, getRevisionKey} from '../../../utils/change-util';
-import {customElement, property, computed, observe} from '@polymer/decorators';
-import {
-  ChangeInfo,
-  DownloadInfo,
-  PatchSetNum,
-  RevisionInfo,
-} from '../../../types/common';
+import {ChangeInfo, DownloadInfo, PatchSetNum} from '../../../types/common';
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {hasOwnProperty, queryAndAssert} from '../../../utils/common-util';
 import {GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
 import {addShortcut} from '../../../utils/dom-util';
-
-export interface GrDownloadDialog {
-  $: {
-    download: HTMLAnchorElement;
-    downloadCommands: GrDownloadCommands;
-    closeButton: GrButton;
-  };
-}
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state, query} from 'lit/decorators';
+import {assertIsDefined} from '../../../utils/common-util';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {BindValueChangeEvent} from '../../../types/events';
 
 @customElement('gr-download-dialog')
-export class GrDownloadDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDownloadDialog extends LitElement {
   /**
    * Fired when the user presses the close button.
    *
    * @event close
    */
 
+  @query('#download') protected download?: HTMLAnchorElement;
+
+  @query('#downloadCommands') protected downloadCommands?: GrDownloadCommands;
+
+  @query('#closeButton') protected closeButton?: GrButton;
+
   @property({type: Object})
   change: ChangeInfo | undefined;
 
@@ -63,8 +54,7 @@
   @property({type: String})
   patchNum: PatchSetNum | undefined;
 
-  @property({type: String})
-  _selectedScheme?: string;
+  @state() private selectedScheme?: string;
 
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
@@ -79,14 +69,159 @@
     super.connectedCallback();
     for (const key of ['1', '2', '3', '4', '5']) {
       this.cleanups.push(
-        addShortcut(this, {key}, e => this._handleNumberKey(e))
+        addShortcut(this, {key}, e => this.handleNumberKey(e))
       );
     }
   }
 
-  @computed('change', 'patchNum')
-  get _schemes() {
-    // Polymer 2: check for undefined
+  static override get styles() {
+    return [
+      fontStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          padding: var(--spacing-m) 0;
+        }
+        section {
+          display: flex;
+          padding: var(--spacing-m) var(--spacing-xl);
+        }
+        .flexContainer {
+          display: flex;
+          justify-content: space-between;
+          padding-top: var(--spacing-m);
+        }
+        .footer {
+          justify-content: flex-end;
+        }
+        .closeButtonContainer {
+          align-items: flex-end;
+          display: flex;
+          flex: 0;
+          justify-content: flex-end;
+        }
+        .patchFiles,
+        .archivesContainer {
+          padding-bottom: var(--spacing-m);
+        }
+        .patchFiles {
+          margin-right: var(--spacing-xxl);
+        }
+        .patchFiles a,
+        .archives a {
+          display: inline-block;
+          margin-right: var(--spacing-l);
+        }
+        .patchFiles a:last-of-type,
+        .archives a:last-of-type {
+          margin-right: 0;
+        }
+        gr-download-commands {
+          width: min(80vw, 1200px);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const revisions = this.change?.revisions;
+    return html`
+      <section>
+        <h3 class="heading-3">
+          Patch set ${this.patchNum} of
+          ${revisions ? Object.keys(revisions).length : 0}
+        </h3>
+      </section>
+      ${this.renderDownloadCommands()}
+      <section class="flexContainer">
+        ${this.renderPatchFiles()} ${this.renderArchives()}
+      </section>
+      <section class="footer">
+        <span class="closeButtonContainer">
+          <gr-button
+            id="closeButton"
+            link
+            @click=${(e: Event) => {
+              this.handleCloseTap(e);
+            }}
+            >Close</gr-button
+          >
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDownloadCommands() {
+    const cssClass = this.schemes.length ? '' : 'hidden';
+
+    return html`
+      <section class=${cssClass}>
+        <gr-download-commands
+          id="downloadCommands"
+          .commands=${this.computeDownloadCommands()}
+          .schemes=${this.schemes}
+          .selectedScheme=${this.selectedScheme}
+          show-keyboard-shortcut-tooltips
+          @selected-scheme-changed=${(e: BindValueChangeEvent) => {
+            this.selectedScheme = e.detail.value;
+          }}
+        ></gr-download-commands>
+      </section>
+    `;
+  }
+
+  private renderPatchFiles() {
+    if (this.computeHidePatchFile()) return;
+
+    return html`
+      <div class="patchFiles">
+        <label>Patch file</label>
+        <div>
+          <a id="download" .href=${this.computeDownloadLink()} download>
+            ${this.computeDownloadFilename()}
+          </a>
+          <a .href=${this.computeDownloadLink(true)} download>
+            ${this.computeDownloadFilename(true)}
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderArchives() {
+    if (!this.config?.archives.length) return;
+
+    return html`
+      <div class="archivesContainer">
+        <label>Archive</label>
+        <div id="archives" class="archives">
+          ${this.config.archives.map(format => this.renderArchivesLink(format))}
+        </div>
+      </div>
+    `;
+  }
+
+  private renderArchivesLink(format: string) {
+    return html`
+      <a .href=${this.computeArchiveDownloadLink(format)} download>
+        ${format}
+      </a>
+    `;
+  }
+
+  override firstUpdated(changedProperties: PropertyValues) {
+    super.firstUpdated(changedProperties);
+    if (!this.getAttribute('role')) this.setAttribute('role', 'dialog');
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('change') || changedProperties.has('patchNum')) {
+      this.schemesChanged();
+    }
+  }
+
+  get schemes() {
     if (this.change === undefined || this.patchNum === undefined) {
       return [];
     }
@@ -103,13 +238,9 @@
     return [];
   }
 
-  _handleNumberKey(e: KeyboardEvent) {
+  private handleNumberKey(e: KeyboardEvent) {
     const index = Number(e.key) - 1;
-    const commands = this._computeDownloadCommands(
-      this.change,
-      this.patchNum,
-      this._selectedScheme
-    );
+    const commands = this.computeDownloadCommands();
     if (index > commands.length) return;
     navigator.clipboard.writeText(commands[index].command).then(() => {
       fireAlert(this, `${commands[index].title} command copied to clipboard`);
@@ -117,41 +248,40 @@
     });
   }
 
-  override ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
-  }
-
   override focus() {
-    if (this._schemes.length) {
-      this.$.downloadCommands.focusOnCopy();
+    if (this.schemes.length) {
+      assertIsDefined(this.downloadCommands, 'downloadCommands');
+      this.updateComplete.then(() => this.downloadCommands!.focusOnCopy());
     } else {
-      this.$.download.focus();
+      assertIsDefined(this.download, 'download');
+      this.download.focus();
     }
   }
 
   getFocusStops(): GrOverlayStops {
+    assertIsDefined(this.downloadCommands, 'downloadCommands');
+    assertIsDefined(this.closeButton, 'closeButton');
+    const downloadTabs = queryAndAssert<PaperTabsElement>(
+      this.downloadCommands,
+      '#downloadTabs'
+    );
     return {
-      start: this.$.downloadCommands.$.downloadTabs,
-      end: this.$.closeButton,
+      start: downloadTabs,
+      end: this.closeButton,
     };
   }
 
-  _computeDownloadCommands(
-    change?: ChangeInfo,
-    patchNum?: PatchSetNum,
-    selectedScheme?: string
-  ) {
+  private computeDownloadCommands() {
     let commandObj;
-    if (!change || !selectedScheme) return [];
-    for (const rev of Object.values(change.revisions || {})) {
+    if (!this.change || !this.selectedScheme) return [];
+    for (const rev of Object.values(this.change.revisions || {})) {
       if (
-        rev._number === patchNum &&
+        rev._number === this.patchNum &&
         rev &&
         rev.fetch &&
-        hasOwnProperty(rev.fetch, selectedScheme)
+        hasOwnProperty(rev.fetch, this.selectedScheme)
       ) {
-        commandObj = rev.fetch[selectedScheme].commands;
+        commandObj = rev.fetch[this.selectedScheme].commands;
         break;
       }
     }
@@ -162,53 +292,35 @@
     return commands;
   }
 
-  _computeZipDownloadLink(change?: ChangeInfo, patchNum?: PatchSetNum) {
-    return this._computeDownloadLink(change, patchNum, true);
-  }
-
-  _computeZipDownloadFilename(change?: ChangeInfo, patchNum?: PatchSetNum) {
-    return this._computeDownloadFilename(change, patchNum, true);
-  }
-
-  _computeDownloadLink(
-    change?: ChangeInfo,
-    patchNum?: PatchSetNum,
-    zip?: boolean
-  ) {
-    // Polymer 2: check for undefined
-    if (change === undefined || patchNum === undefined) {
+  private computeDownloadLink(zip?: boolean) {
+    if (this.change === undefined || this.patchNum === undefined) {
       return '';
     }
     return (
-      changeBaseURL(change.project, change._number, patchNum) +
+      changeBaseURL(this.change.project, this.change._number, this.patchNum) +
       '/patch?' +
       (zip ? 'zip' : 'download')
     );
   }
 
-  _computeDownloadFilename(
-    change?: ChangeInfo,
-    patchNum?: PatchSetNum,
-    zip?: boolean
-  ) {
-    // Polymer 2: check for undefined
-    if (change === undefined || patchNum === undefined) {
+  private computeDownloadFilename(zip?: boolean) {
+    if (this.change === undefined || this.patchNum === undefined) {
       return '';
     }
 
-    const rev = getRevisionKey(change, patchNum) ?? '';
+    const rev = getRevisionKey(this.change, this.patchNum) ?? '';
     const shortRev = rev.substr(0, 7);
 
     return shortRev + '.diff.' + (zip ? 'zip' : 'base64');
   }
 
-  _computeHidePatchFile(change?: ChangeInfo, patchNum?: PatchSetNum) {
-    // Polymer 2: check for undefined
-    if (change === undefined || patchNum === undefined) {
+  // private but used in test
+  computeHidePatchFile() {
+    if (this.change === undefined || this.patchNum === undefined) {
       return false;
     }
-    for (const rev of Object.values(change.revisions || {})) {
-      if (rev._number === patchNum) {
+    for (const rev of Object.values(this.change.revisions || {})) {
+      if (rev._number === this.patchNum) {
         const parentLength =
           rev.commit && rev.commit.parents ? rev.commit.parents.length : 0;
         return parentLength === 0 || parentLength > 1;
@@ -217,52 +329,36 @@
     return false;
   }
 
-  _computeArchiveDownloadLink(
-    change?: ChangeInfo,
-    patchNum?: PatchSetNum,
-    format?: string
-  ) {
-    // Polymer 2: check for undefined
+  // private but used in test
+  computeArchiveDownloadLink(format?: string) {
     if (
-      change === undefined ||
-      patchNum === undefined ||
+      this.change === undefined ||
+      this.patchNum === undefined ||
       format === undefined
     ) {
       return '';
     }
     return (
-      changeBaseURL(change.project, change._number, patchNum) +
+      changeBaseURL(this.change.project, this.change._number, this.patchNum) +
       '/archive?format=' +
       format
     );
   }
 
-  _computePatchSetQuantity(revisions?: {[revisionId: string]: RevisionInfo}) {
-    if (!revisions) {
-      return 0;
-    }
-    return Object.keys(revisions).length;
-  }
-
-  _handleCloseTap(e: Event) {
+  private handleCloseTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     fireEvent(this, 'close');
   }
 
-  @observe('_schemes')
-  _schemesChanged(schemes: string[]) {
-    if (schemes.length === 0) {
+  private schemesChanged() {
+    if (this.schemes.length === 0) {
       return;
     }
-    if (!this._selectedScheme || !schemes.includes(this._selectedScheme)) {
-      this._selectedScheme = schemes.sort()[0];
+    if (!this.selectedScheme || !this.schemes.includes(this.selectedScheme)) {
+      this.selectedScheme = this.schemes.sort()[0];
     }
   }
-
-  _computeShowDownloadCommands(schemes: string[]) {
-    return schemes.length ? '' : 'hidden';
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
deleted file mode 100644
index 097fb0e..0000000
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_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="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-      padding: var(--spacing-m) 0;
-    }
-    section {
-      display: flex;
-      padding: var(--spacing-m) var(--spacing-xl);
-    }
-    .flexContainer {
-      display: flex;
-      justify-content: space-between;
-      padding-top: var(--spacing-m);
-    }
-    .footer {
-      justify-content: flex-end;
-    }
-    .closeButtonContainer {
-      align-items: flex-end;
-      display: flex;
-      flex: 0;
-      justify-content: flex-end;
-    }
-    .patchFiles,
-    .archivesContainer {
-      padding-bottom: var(--spacing-m);
-    }
-    .patchFiles {
-      margin-right: var(--spacing-xxl);
-    }
-    .patchFiles a,
-    .archives a {
-      display: inline-block;
-      margin-right: var(--spacing-l);
-    }
-    .patchFiles a:last-of-type,
-    .archives a:last-of-type {
-      margin-right: 0;
-    }
-    .hidden {
-      display: none;
-    }
-    gr-download-commands {
-      width: min(80vw, 1200px);
-    }
-  </style>
-  <section>
-    <h3 class="heading-3">
-      Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
-    </h3>
-  </section>
-  <section class$="[[_computeShowDownloadCommands(_schemes)]]">
-    <gr-download-commands
-      id="downloadCommands"
-      commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
-      schemes="[[_schemes]]"
-      selected-scheme="{{_selectedScheme}}"
-      show-keyboard-shortcut-tooltips
-    ></gr-download-commands>
-  </section>
-  <section class="flexContainer">
-    <div
-      class="patchFiles"
-      hidden="[[_computeHidePatchFile(change, patchNum)]]"
-    >
-      <label>Patch file</label>
-      <div>
-        <a
-          id="download"
-          href$="[[_computeDownloadLink(change, patchNum)]]"
-          download=""
-        >
-          [[_computeDownloadFilename(change, patchNum)]]
-        </a>
-        <a href$="[[_computeZipDownloadLink(change, patchNum)]]" download="">
-          [[_computeZipDownloadFilename(change, patchNum)]]
-        </a>
-      </div>
-    </div>
-    <div
-      class="archivesContainer"
-      hidden$="[[!config.archives.length]]"
-      hidden=""
-    >
-      <label>Archive</label>
-      <div id="archives" class="archives">
-        <template is="dom-repeat" items="[[config.archives]]" as="format">
-          <a
-            href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]"
-            download=""
-          >
-            [[format]]
-          </a>
-        </template>
-      </div>
-    </div>
-  </section>
-  <section class="footer">
-    <span class="closeButtonContainer">
-      <gr-button id="closeButton" link="" on-click="_handleCloseTap"
-        >Close</gr-button
-      >
-    </span>
-  </section>
-`;
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 f61bb68..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
@@ -22,7 +22,6 @@
   createCommit,
   createDownloadInfo,
   createRevision,
-  createRevisions,
 } from '../../../test/test-data-generators';
 import {
   CommitId,
@@ -30,8 +29,10 @@
   PatchSetNum,
   RepoName,
 } from '../../../types/common';
+import './gr-download-dialog';
 import {GrDownloadDialog} from './gr-download-dialog';
-import {mockPromise} from '../../../test/test-utils';
+import {mockPromise, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 
 const basicFixture = fixtureFromElement('gr-download-dialog');
 
@@ -103,37 +104,43 @@
   };
 }
 
-function getChangeObjectNoFetch() {
-  return {
-    ...createChange(),
-    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72' as CommitId,
-    revisions: createRevisions(1),
-  };
-}
-
 suite('gr-download-dialog', () => {
   let element: GrDownloadDialog;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
     element.patchNum = 1 as PatchSetNum;
     element.config = createDownloadInfo();
-    flush();
+    await element.updateComplete;
   });
 
   test('anchors use download attribute', () => {
-    const anchors = Array.from(element.root!.querySelectorAll('a'));
+    const anchors = Array.from(queryAll(element, 'a'));
     assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
   });
 
   suite('gr-download-dialog tests with no fetch options', () => {
-    setup(() => {
-      element.change = getChangeObjectNoFetch();
-      flush();
+    setup(async () => {
+      element.change = {
+        ...createChange(),
+        revisions: {
+          r1: {
+            ...createRevision(),
+            commit: {
+              ...createCommit(),
+              parents: [{commit: 'p1' as CommitId, subject: 'subject1'}],
+            },
+          },
+        },
+      };
+      await element.updateComplete;
     });
 
     test('focuses on first download link if no copy links', () => {
-      const focusStub = sinon.stub(element.$.download, 'focus');
+      const focusStub = sinon.stub(
+        queryAndAssert<HTMLAnchorElement>(element, '#download'),
+        'focus'
+      );
       element.focus();
       assert.isTrue(focusStub.called);
       focusStub.restore();
@@ -141,30 +148,31 @@
   });
 
   suite('gr-download-dialog with fetch options', () => {
-    setup(() => {
+    setup(async () => {
       element.change = getChangeObject();
-      flush();
+      await element.updateComplete;
     });
 
-    test('focuses on first copy link', () => {
-      const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
+    test('focuses on first copy link', async () => {
+      const focusStub = sinon.stub(
+        queryAndAssert<GrDownloadCommands>(element, '#downloadCommands'),
+        'focusOnCopy'
+      );
       element.focus();
-      flush();
+      await element.updateComplete;
       assert.isTrue(focusStub.called);
       focusStub.restore();
     });
 
     test('computed fields', () => {
+      element.change = {
+        ...createChange(),
+        project: 'test/project' as RepoName,
+        _number: 123 as NumericChangeId,
+      };
+      element.patchNum = 2 as PatchSetNum;
       assert.equal(
-        element._computeArchiveDownloadLink(
-          {
-            ...createChange(),
-            project: 'test/project' as RepoName,
-            _number: 123 as NumericChangeId,
-          },
-          2 as PatchSetNum,
-          'tgz'
-        ),
+        element.computeArchiveDownloadLink('tgz'),
         '/changes/test%2Fproject~123/revisions/2/archive?format=tgz'
       );
     });
@@ -174,31 +182,27 @@
       element.addEventListener('close', () => {
         closeCalled.resolve();
       });
-      const closeButton = element.shadowRoot!.querySelector(
+      const closeButton = queryAndAssert(
+        element,
         '.closeButtonContainer gr-button'
       );
-      tap(closeButton!);
+      tap(closeButton);
       await closeCalled;
     });
   });
 
-  test('_computeShowDownloadCommands', () => {
-    assert.equal(element._computeShowDownloadCommands([]), 'hidden');
-    assert.equal(element._computeShowDownloadCommands(['test']), '');
-  });
+  test('computeHidePatchFile', () => {
+    element.patchNum = 1 as PatchSetNum;
 
-  test('_computeHidePatchFile', () => {
-    const patchNum = 1 as PatchSetNum;
-
-    const changeWithNoParent = {
+    element.change = {
       ...createChange(),
       revisions: {
         r1: {...createRevision(), commit: createCommit()},
       },
     };
-    assert.isTrue(element._computeHidePatchFile(changeWithNoParent, patchNum));
+    assert.isTrue(element.computeHidePatchFile());
 
-    const changeWithOneParent = {
+    element.change = {
       ...createChange(),
       revisions: {
         r1: {
@@ -210,11 +214,9 @@
         },
       },
     };
-    assert.isFalse(
-      element._computeHidePatchFile(changeWithOneParent, patchNum)
-    );
+    assert.isFalse(element.computeHidePatchFile());
 
-    const changeWithMultipleParents = {
+    element.change = {
       ...createChange(),
       revisions: {
         r1: {
@@ -229,8 +231,6 @@
         },
       },
     };
-    assert.isTrue(
-      element._computeHidePatchFile(changeWithMultipleParents, patchNum)
-    );
+    assert.isTrue(element.computeHidePatchFile());
   });
 });
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 ae3eee5..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,20 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
+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';
 import '../../shared/gr-select/gr-select';
 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,
@@ -39,37 +36,21 @@
   BasePatchSetNum,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
-import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
-import {DiffViewMode} from '../../../constants/constants';
+import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fireEvent} from '../../../utils/event-util';
 import {
   Shortcut,
   ShortcutSection,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {appContext} from '../../../services/app-context';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-file-list-header': GrFileListHeader;
-  }
-}
-
-export interface GrFileListHeader {
-  $: {
-    modeSelect: GrDiffModeSelector;
-    expandBtn: GrButton;
-    collapseBtn: GrButton;
-  };
-}
+import {getAppContext} from '../../../services/app-context';
+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
    */
@@ -102,9 +83,6 @@
   changeUrl?: string;
 
   @property({type: Object})
-  changeComments?: ChangeComments;
-
-  @property({type: Object})
   commitInfo?: CommitInfo;
 
   @property({type: Boolean})
@@ -117,14 +95,11 @@
   serverConfig?: ServerInfo;
 
   @property({type: Number})
-  shownFileCount?: number;
+  shownFileCount = 0;
 
   @property({type: Object})
   diffPrefs?: DiffPreferencesInfo;
 
-  @property({type: String, notify: true})
-  diffViewMode?: DiffViewMode;
-
   @property({type: String})
   patchNum?: PatchSetNum;
 
@@ -134,29 +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;
 
-  private readonly shortcuts = appContext.shortcutsService;
+  @query('#modeSelect')
+  modeSelect?: GrDiffModeSelector;
 
-  setDiffViewMode(mode: DiffViewMode) {
-    this.$.modeSelect.setMode(mode);
+  @query('#expandBtn')
+  expandBtn?: GrButton;
+
+  @query('#collapseBtn')
+  collapseBtn?: GrButton;
+
+  private readonly shortcuts = getAppContext().shortcutsService;
+
+  // 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>
+    `;
   }
 
-  _expandAllDiffs() {
+  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');
@@ -168,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) ||
@@ -187,15 +409,15 @@
     ) {
       return;
     }
-    GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
+    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(
@@ -203,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 '';
@@ -215,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 73d0819..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ /dev/null
@@ -1,236 +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-comments="[[changeComments]]"
-          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"
-          mode="{{diffViewMode}}"
-          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.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
deleted file mode 100644
index 479a9a1..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
+++ /dev/null
@@ -1,203 +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-file-list-header.js';
-import {FilesExpandedState} from '../gr-file-list-constants.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import 'lodash/lodash.js';
-import {createRevisions} from '../../../test/test-data-generators.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-file-list-header');
-
-suite('gr-file-list-header tests', () => {
-  let element;
-
-  setup(() => {
-    stubRestApi('getConfig').returns(Promise.resolve({test: 'config'}));
-    stubRestApi('getAccount').returns(Promise.resolve(null));
-    element = basicFixture.instantiate();
-  });
-
-  teardown(async () => {
-    await flush();
-  });
-
-  test('Diff preferences hidden when no prefs', () => {
-    assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-    element.diffPrefs = {font_size: '12'};
-    element.loggedIn = true;
-    flush();
-    assert.isFalse(element.$.diffPrefsContainer.hidden);
-  });
-
-  test('expandAllDiffs called when expand button clicked', () => {
-    element.shownFileCount = 1;
-    flush();
-    sinon.stub(element, '_expandAllDiffs');
-    MockInteractions.tap(element.root.querySelector(
-        '#expandBtn'));
-    assert.isTrue(element._expandAllDiffs.called);
-  });
-
-  test('collapseAllDiffs called when collapse button clicked', () => {
-    element.shownFileCount = 1;
-    flush();
-    sinon.stub(element, '_collapseAllDiffs');
-    MockInteractions.tap(element.root.querySelector(
-        '#collapseBtn'));
-    assert.isTrue(element._collapseAllDiffs.called);
-  });
-
-  test('show/hide diffs disabled for large amounts of files', async () => {
-    const computeSpy = sinon.spy(element, '_fileListActionsVisible');
-    element._files = [];
-    element.changeNum = '42';
-    element.basePatchNum = 'PARENT';
-    element.patchNum = '2';
-    element.shownFileCount = 1;
-    await flush();
-    assert.isTrue(computeSpy.lastCall.returnValue);
-    _.times(element._maxFilesForBulkActions + 1, () => {
-      element.shownFileCount = element.shownFileCount + 1;
-    });
-    assert.isFalse(computeSpy.lastCall.returnValue);
-  });
-
-  test('fileViewActions are properly hidden', async () => {
-    const actions = element.shadowRoot
-        .querySelector('.fileViewActions');
-    assert.equal(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = FilesExpandedState.SOME;
-    await flush();
-    assert.notEqual(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = FilesExpandedState.ALL;
-    await flush();
-    assert.notEqual(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = FilesExpandedState.NONE;
-    await flush();
-    assert.equal(getComputedStyle(actions).display, 'none');
-  });
-
-  test('expand/collapse buttons are toggled correctly', async () => {
-    // Only the expand button should be visible in the initial state when
-    // NO files are expanded.
-    element.shownFileCount = 10;
-    await flush();
-    const expandBtn = element.shadowRoot.querySelector('#expandBtn');
-    const collapseBtn = element.shadowRoot.querySelector('#collapseBtn');
-    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-    assert.equal(getComputedStyle(collapseBtn).display, 'none');
-
-    // Both expand and collapse buttons should be visible when SOME files are
-    // expanded.
-    element.filesExpanded = FilesExpandedState.SOME;
-    await flush();
-    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();
-    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();
-    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-    assert.equal(getComputedStyle(collapseBtn).display, 'none');
-  });
-
-  test('navigateToChange called when range select changes', () => {
-    const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
-    element.change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev2: {_number: 2},
-        rev1: {_number: 1},
-        rev13: {_number: 13},
-        rev3: {_number: 3},
-      },
-      status: 'NEW',
-      labels: {},
-    };
-    element.basePatchNum = 1;
-    element.patchNum = 2;
-
-    element._handlePatchChange({detail: {basePatchNum: 1, patchNum: 3}});
-    assert.equal(navigateToChangeStub.callCount, 1);
-    assert.isTrue(navigateToChangeStub.lastCall
-        .calledWithExactly(element.change, 3, 1));
-  });
-
-  test('class is applied to file list on old patch set', () => {
-    const allPatchSets = [{num: 4}, {num: 2}, {num: 1}];
-    assert.equal(element._computePatchInfoClass(1, allPatchSets),
-        'patchInfoOldPatchSet');
-    assert.equal(element._computePatchInfoClass(2, allPatchSets),
-        'patchInfoOldPatchSet');
-    assert.equal(element._computePatchInfoClass(4, allPatchSets), '');
-  });
-
-  suite('editMode behavior', () => {
-    setup(() => {
-      element.loggedIn = true;
-      element.diffPrefs = {};
-    });
-
-    const isVisible = el => {
-      assert.ok(el);
-      return getComputedStyle(el).getPropertyValue('display') !== 'none';
-    };
-
-    test('patch specific elements', () => {
-      element.editMode = true;
-      element.allPatchSets = createRevisions(2);
-      flush();
-
-      assert.isFalse(isVisible(element.$.diffPrefsContainer));
-
-      element.editMode = false;
-      flush();
-
-      assert.isTrue(isVisible(element.$.diffPrefsContainer));
-    });
-
-    test('edit-controls visibility', () => {
-      element.editMode = false;
-      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.isNull(element.shadowRoot
-          .querySelector('#editControls'));
-
-      element.editMode = true;
-      flush();
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('#editControls').parentElement));
-
-      element.editMode = false;
-      flush();
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('#editControls').parentElement));
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..ac2b4d4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
@@ -0,0 +1,251 @@
+/**
+ * @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-file-list-header';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {createChange, createRevision} from '../../../test/test-data-generators';
+import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrFileListHeader} from './gr-file-list-header';
+import {
+  BasePatchSetNum,
+  ChangeId,
+  NumericChangeId,
+  PatchSetNum,
+} from '../../../types/common.js';
+import {ChangeInfo, ChangeStatus} from '../../../api/rest-api.js';
+import {PatchSet} from '../../../utils/patch-set-util';
+import {createDefaultDiffPrefs} from '../../../constants/constants.js';
+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(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 () => {
+    assert.isTrue(
+      queryAndAssert<HTMLElement>(element, '#diffPrefsContainer').hidden
+    );
+
+    element.diffPrefs = createDefaultDiffPrefs();
+    element.loggedIn = true;
+    await element.updateComplete;
+
+    assert.isFalse(
+      queryAndAssert<HTMLElement>(element, '#diffPrefsContainer').hidden
+    );
+  });
+
+  test('expandAllDiffs called when expand button clicked', async () => {
+    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 () => {
+    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 () => {
+    element.changeNum = 42 as NumericChangeId;
+    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    element.patchNum = '2' as PatchSetNum;
+    element.shownFileCount = 1;
+    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 element.updateComplete;
+    assert.notEqual(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = FilesExpandedState.ALL;
+    await element.updateComplete;
+    assert.notEqual(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = FilesExpandedState.NONE;
+    await element.updateComplete;
+    assert.equal(getComputedStyle(actions).display, 'none');
+  });
+
+  test('expand/collapse buttons are toggled correctly', async () => {
+    // Only the expand button should be visible in the initial state when
+    // NO files are expanded.
+    element.shownFileCount = 10;
+    await element.updateComplete;
+    const expandBtn = queryAndAssert(element, '#expandBtn');
+    const collapseBtn = queryAndAssert(element, '#collapseBtn');
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.equal(getComputedStyle(collapseBtn).display, 'none');
+
+    // Both expand and collapse buttons should be visible when SOME files are
+    // expanded.
+    element.filesExpanded = FilesExpandedState.SOME;
+    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 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 element.updateComplete;
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.equal(getComputedStyle(collapseBtn).display, 'none');
+  });
+
+  test('navigateToChange called when range select changes', async () => {
+    const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 2 as PatchSetNum;
+    await element.updateComplete;
+
+    element.handlePatchChange({
+      detail: {basePatchNum: 1, patchNum: 3},
+    } as CustomEvent);
+    await element.updateComplete;
+
+    assert.equal(navigateToChangeStub.callCount, 1);
+    assert.isTrue(
+      navigateToChangeStub.lastCall.calledWithExactly(change, {
+        patchNum: 3 as PatchSetNum,
+        basePatchNum: 1 as BasePatchSetNum,
+      })
+    );
+  });
+
+  test('class is applied to file list on old patch set', () => {
+    const allPatchSets: PatchSet[] = [
+      {num: 4 as PatchSetNum, desc: undefined, sha: ''},
+      {num: 2 as PatchSetNum, desc: undefined, sha: ''},
+      {num: 1 as PatchSetNum, desc: undefined, sha: ''},
+    ];
+    assert.equal(
+      element.computePatchInfoClass(1 as PatchSetNum, allPatchSets),
+      'patchInfoOldPatchSet'
+    );
+    assert.equal(
+      element.computePatchInfoClass(2 as PatchSetNum, allPatchSets),
+      'patchInfoOldPatchSet'
+    );
+    assert.equal(
+      element.computePatchInfoClass(4 as PatchSetNum, allPatchSets),
+      ''
+    );
+  });
+
+  suite('editMode behavior', () => {
+    setup(async () => {
+      element.loggedIn = true;
+      await element.updateComplete;
+    });
+
+    function isVisible(el: HTMLElement) {
+      assert.ok(el);
+      return getComputedStyle(el).getPropertyValue('display') !== 'none';
+    }
+
+    test('patch specific elements', async () => {
+      element.editMode = true;
+      element.allPatchSets = [
+        {num: 1 as PatchSetNum, desc: undefined, sha: ''},
+        {num: 2 as PatchSetNum, desc: undefined, sha: ''},
+        {num: 3 as PatchSetNum, desc: undefined, sha: ''},
+      ];
+      await element.updateComplete;
+
+      assert.isFalse(
+        isVisible(queryAndAssert<HTMLElement>(element, '#diffPrefsContainer'))
+      );
+
+      element.editMode = false;
+      await element.updateComplete;
+
+      assert.isTrue(
+        isVisible(queryAndAssert<HTMLElement>(element, '#diffPrefsContainer'))
+      );
+    });
+
+    test('edit-controls visibility', async () => {
+      element.editMode = false;
+      await element.updateComplete;
+
+      assert.isNotOk(query(element, '#editControls'));
+
+      element.editMode = true;
+      await element.updateComplete;
+
+      assert.isTrue(
+        isVisible(queryAndAssert<HTMLElement>(element, '#editControls'))
+      );
+
+      element.editMode = false;
+      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 f0d8935..237e126 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -14,11 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {Subscription} from 'rxjs';
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
-import '../../diff/gr-diff-cursor/gr-diff-cursor';
+import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import '../../diff/gr-diff-host/gr-diff-host';
-import '../../diff/gr-comment-api/gr-comment-api';
 import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
 import '../../shared/gr-button/gr-button';
@@ -30,7 +30,6 @@
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-file-status-chip/gr-file-status-chip';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-file-list_html';
 import {asyncForeach, debounce, DelayedTask} from '../../../utils/async-util';
 import {
@@ -43,7 +42,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {
   DiffViewMode,
   ScrollMode,
@@ -72,22 +71,24 @@
   FileNameToFileInfoMap,
   NumericChangeId,
   PatchRange,
+  RevisionPatchSetNum,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
 import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
-import {GrDiffCursor} from '../../diff/gr-diff-cursor/gr-diff-cursor';
+import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
 import {Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
-import {preferences$} from '../../../services/user/user-model';
-import {changeComments$} from '../../../services/comments/comments-model';
-import {Subject} from 'rxjs';
-import {takeUntil} from 'rxjs/operators';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {select} from '../../../utils/observable-util';
+import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {browserModelToken} from '../../../models/browser/browser-model';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -109,6 +110,7 @@
 interface ReviewedFileInfo extends FileInfo {
   isReviewed?: boolean;
 }
+
 export interface NormalizedFileInfo extends ReviewedFileInfo {
   __path: string;
 }
@@ -176,7 +178,7 @@
  */
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
+const base = KeyboardShortcutMixin(DIPolymerElement);
 
 @customElement('gr-file-list')
 export class GrFileList extends base {
@@ -221,7 +223,7 @@
   _loggedIn = false;
 
   @property({type: Array})
-  _reviewed?: string[] = [];
+  reviewed?: string[] = [];
 
   @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
   diffPrefs?: DiffPreferencesInfo;
@@ -312,11 +314,19 @@
   @property({type: Array})
   _dynamicPrependedContentEndpoints?: string[];
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  disconnected$ = new Subject();
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
+  private readonly getBrowserModel = resolve(this, browserModelToken);
+
+  private subscriptions: Subscription[] = [];
 
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
@@ -359,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?: GrDiffCursor;
 
   constructor() {
     super();
@@ -372,11 +384,27 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    changeComments$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(changeComments => {
+    this.subscriptions = [
+      this.getCommentsModel().changeComments$.subscribe(changeComments => {
         this.changeComments = changeComments;
-      });
+      }),
+      this.getBrowserModel().diffViewMode$.subscribe(
+        diffView => (this.diffViewMode = diffView)
+      ),
+      this.userModel.diffPreferences$.subscribe(diffPreferences => {
+        this.diffPrefs = diffPreferences;
+      }),
+      select(
+        this.userModel.preferences$,
+        prefs => !!prefs?.size_bar_in_change_table
+      ).subscribe(sizeBarInChangeTable => {
+        this._showSizeBars = sizeBarInChangeTable;
+      }),
+      this.getChangeModel().reviewedFiles$.subscribe(reviewedFiles => {
+        this.reviewed = reviewedFiles ?? [];
+      }),
+    ];
+
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -425,11 +453,16 @@
         shouldSuppress: true,
       })
     );
+    this.diffCursor = new GrDiffCursor();
+    this.diffCursor.replaceDiffs(this.diffs);
   }
 
   override disconnectedCallback() {
-    this.disconnected$.next();
-    this.diffCursor.dispose();
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+    this.diffCursor?.dispose();
     this.fileCursor.unsetCursor();
     this._cancelDiffs();
     this.loadingTask?.cancel();
@@ -448,7 +481,7 @@
     this._loading = true;
 
     this.collapseAllDiffs();
-    const promises = [];
+    const promises: Promise<boolean | void>[] = [];
 
     promises.push(
       this.restApiService
@@ -459,31 +492,9 @@
     );
 
     promises.push(
-      this._getLoggedIn()
-        .then(loggedIn => (this._loggedIn = loggedIn))
-        .then(loggedIn => {
-          if (!loggedIn) {
-            return;
-          }
-
-          return this._getReviewedFiles(changeNum, patchRange).then(
-            reviewed => {
-              this._reviewed = reviewed;
-            }
-          );
-        })
+      this._getLoggedIn().then(loggedIn => (this._loggedIn = loggedIn))
     );
 
-    promises.push(
-      this._getDiffPreferences().then(prefs => {
-        this.diffPrefs = prefs;
-      })
-    );
-
-    preferences$.pipe(takeUntil(this.disconnected$)).subscribe(prefs => {
-      this._showSizeBars = !!prefs?.size_bar_in_change_table;
-    });
-
     return Promise.all(promises).then(() => {
       this._loading = false;
       this._detectChromiteButler();
@@ -572,15 +583,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(
@@ -606,7 +610,6 @@
       return;
     }
     // Re-render all expanded diffs sequentially.
-    this.reporting.time(Timing.FILE_EXPAND_ALL);
     this._renderInOrder(
       this._expandedFiles,
       this.diffs,
@@ -642,7 +645,7 @@
       this._expandedFiles.length,
       this._files.length
     );
-    this.diffCursor.handleDiffUpdate();
+    this.diffCursor?.handleDiffUpdate();
   }
 
   /**
@@ -723,7 +726,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();
     }
@@ -743,7 +747,7 @@
       throw new Error('changeNum and patchRange must be set');
     }
 
-    return this.restApiService.saveFileReviewed(
+    return this.getChangeModel().setReviewedFilesStatus(
       this.changeNum,
       this.patchRange.patchNum,
       path,
@@ -768,8 +772,7 @@
     const paths = Object.keys(response).sort(specialFilePathCompare);
     const files: NormalizedFileInfo[] = [];
     for (let i = 0; i < paths.length; i++) {
-      // TODO(TS): make copy instead of as NormalizedFileInfo
-      const info = response[paths[i]] as NormalizedFileInfo;
+      const info = {...response[paths[i]]} as NormalizedFileInfo;
       info.__path = paths[i];
       info.lines_inserted = info.lines_inserted || 0;
       info.lines_deleted = info.lines_deleted || 0;
@@ -884,12 +887,12 @@
 
   _handleLeftPane() {
     if (this._noDiffsExpanded()) return;
-    this.diffCursor.moveLeft();
+    this.diffCursor?.moveLeft();
   }
 
   _handleRightPane() {
     if (this._noDiffsExpanded()) return;
-    this.diffCursor.moveRight();
+    this.diffCursor?.moveRight();
   }
 
   _handleToggleInlineDiff() {
@@ -899,7 +902,7 @@
 
   _handleCursorNext(e: KeyboardEvent) {
     if (this.filesExpanded === FilesExpandedState.ALL) {
-      this.diffCursor.moveDown();
+      this.diffCursor?.moveDown();
       this._displayLine = true;
     } else {
       if (e.key === Key.DOWN) return;
@@ -910,7 +913,7 @@
 
   _handleCursorPrev(e: KeyboardEvent) {
     if (this.filesExpanded === FilesExpandedState.ALL) {
-      this.diffCursor.moveUp();
+      this.diffCursor?.moveUp();
       this._displayLine = true;
     } else {
       if (e.key === Key.UP) return;
@@ -921,7 +924,7 @@
 
   _handleNewComment() {
     this.classList.remove('hideComments');
-    this.diffCursor.createCommentInPlace();
+    this.diffCursor?.createCommentInPlace();
   }
 
   handleOpenFile() {
@@ -934,22 +937,22 @@
 
   _handleNextChunk() {
     if (this._noDiffsExpanded()) return;
-    this.diffCursor.moveToNextChunk();
+    this.diffCursor?.moveToNextChunk();
   }
 
   _handleNextComment() {
     if (this._noDiffsExpanded()) return;
-    this.diffCursor.moveToNextCommentThread();
+    this.diffCursor?.moveToNextCommentThread();
   }
 
   _handlePrevChunk() {
     if (this._noDiffsExpanded()) return;
-    this.diffCursor.moveToPreviousChunk();
+    this.diffCursor?.moveToPreviousChunk();
   }
 
   _handlePrevComment() {
     if (this._noDiffsExpanded()) return;
-    this.diffCursor.moveToPreviousCommentThread();
+    this.diffCursor?.moveToPreviousCommentThread();
   }
 
   _handleToggleFileReviewed() {
@@ -974,7 +977,7 @@
   }
 
   _openCursorFile() {
-    const diff = this.diffCursor.getTargetDiffElement();
+    const diff = this.diffCursor?.getTargetDiffElement();
     if (!this.change || !diff || !this.patchRange || !diff.path) {
       throw new Error('change, diff and patchRange must be all set and valid');
     }
@@ -1017,28 +1020,23 @@
 
   _computeDiffURL(
     change?: ParsedChangeInfo,
-    patchRange?: PatchRange,
+    basePatchNum?: BasePatchSetNum,
+    patchNum?: RevisionPatchSetNum,
     path?: string,
     editMode?: boolean
   ) {
-    // Polymer 2: check for undefined
     if (
       change === undefined ||
-      !patchRange?.patchNum ||
+      patchNum === undefined ||
       path === undefined ||
       editMode === undefined
     ) {
       return;
     }
     if (editMode && path !== SpecialFilePath.MERGE_LIST) {
-      return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum);
+      return GerritNav.getEditUrlForDiff(change, path, patchNum);
     }
-    return GerritNav.getUrlForDiff(
-      change,
-      path,
-      patchRange.patchNum,
-      patchRange.basePatchNum
-    );
+    return GerritNav.getUrlForDiff(change, path, patchNum, basePatchNum);
   }
 
   _formatBytes(bytes?: number) {
@@ -1116,18 +1114,17 @@
 
   _handleShowParent1(): void {
     if (!this.change || !this.patchRange) return;
-    GerritNav.navigateToChange(
-      this.change,
-      this.patchRange.patchNum,
-      -1 as BasePatchSetNum // Parent 1
-    );
+    GerritNav.navigateToChange(this.change, {
+      patchNum: this.patchRange.patchNum,
+      basePatchNum: -1 as BasePatchSetNum, // Parent 1
+    });
   }
 
   @observe(
     '_filesByPath',
     'changeComments',
     'patchRange',
-    '_reviewed',
+    'reviewed',
     '_loading'
   )
   _computeFiles(
@@ -1197,7 +1194,7 @@
 
   _updateDiffCursor() {
     // Overwrite the cursor's list of diffs:
-    this.diffCursor.replaceDiffs(this.diffs);
+    this.diffCursor?.replaceDiffs(this.diffs);
   }
 
   _filesChanged() {
@@ -1325,17 +1322,16 @@
     // Required so that the newly created diff view is included in this.diffs.
     flush();
 
-    this.reporting.time(Timing.FILE_EXPAND_ALL);
-
     if (newFiles.length) {
       this._renderInOrder(newFiles, this.diffs, newFiles.length);
     }
 
     this._updateDiffCursor();
-    this.diffCursor.reInitAndUpdateStops();
+    this.diffCursor?.reInitAndUpdateStops();
   }
 
-  private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
+  // private but used in test
+  _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
     for (const diff of collapsedDiffs) {
       diff.cancel();
       diff.clearDiffContent();
@@ -1347,15 +1343,16 @@
    * for each path in order, awaiting the previous render to complete before
    * continuing.
    *
-   * @param initialCount The total number of paths in the pass. This
-   * is used to generate log messages.
+   * private but used in test
+   *
+   * @param initialCount The total number of paths in the pass.
    */
-  private _renderInOrder(
+  async _renderInOrder(
     files: PatchSetFile[],
     diffElements: GrDiffHost[],
     initialCount: number
   ) {
-    let iter = 0;
+    this.reporting.time(Timing.FILE_EXPAND_ALL);
 
     for (const file of files) {
       const path = file.path;
@@ -1365,12 +1362,10 @@
       }
     }
 
-    asyncForeach(files, (file, cancel) => {
+    await asyncForeach(files, (file, cancel) => {
       const path = file.path;
       this._cancelForEachDiff = cancel;
 
-      iter++;
-      console.info('Expanding diff', iter, 'of', initialCount, ':', path);
       const diffElem = this._findDiffByPath(path, diffElements);
       if (!diffElem) {
         this.reporting.error(
@@ -1382,33 +1377,39 @@
         throw new Error('diffPrefs must be set');
       }
 
-      const promises: Array<Promise<unknown>> = [diffElem.reload()];
-      if (this._loggedIn && !this.diffPrefs.manual_review) {
-        promises.push(this._reviewFile(path, true));
+      // When one file is expanded individually then automatically mark as
+      // reviewed, if the user's diff prefs request it. Doing this for
+      // "Expand All" would not be what the user wants, because there is no
+      // control over which diffs were actually seen. And for lots of diffs
+      // that would even be a problem for write QPS quota.
+      if (
+        this._loggedIn &&
+        !this.diffPrefs.manual_review &&
+        initialCount === 1
+      ) {
+        this._reviewFile(path, true);
       }
-      return Promise.all(promises);
-    }).then(() => {
-      this._cancelForEachDiff = undefined;
-      console.info('Finished expanding', initialCount, 'diff(s)');
-      this.reporting.timeEndWithAverage(
-        Timing.FILE_EXPAND_ALL,
-        Timing.FILE_EXPAND_ALL_AVG,
-        initialCount
-      );
-      /* Block diff cursor from auto scrolling after files are done rendering.
-      * This prevents the bug where the screen jumps to the first diff chunk
-      * after files are done being rendered after the user has already begun
-      * scrolling.
-      * This also however results in the fact that the cursor does not auto
-      * focus on the first diff chunk on a small screen. This is however, a use
-      * case we are willing to not support for now.
-
-      * Using handleDiffUpdate resulted in diffCursor.row being set which
-      * prevented the issue of scrolling to top when we expand the second
-      * file individually.
-      */
-      this.diffCursor.reInitAndUpdateStops();
+      return diffElem.reload();
     });
+
+    this._cancelForEachDiff = undefined;
+    this.reporting.timeEnd(Timing.FILE_EXPAND_ALL, {
+      count: initialCount,
+      height: this.clientHeight,
+    });
+    /* Block diff cursor from auto scrolling after files are done rendering.
+    * This prevents the bug where the screen jumps to the first diff chunk
+    * after files are done being rendered after the user has already begun
+    * scrolling.
+    * This also however results in the fact that the cursor does not auto
+    * focus on the first diff chunk on a small screen. This is however, a use
+    * case we are willing to not support for now.
+
+    * Using handleDiffUpdate resulted in diffCursor.row being set which
+    * prevented the issue of scrolling to top when we expand the second
+    * file individually.
+    */
+    this.diffCursor?.reInitAndUpdateStops();
   }
 
   /** Cancel the rendering work of every diff in the list */
@@ -1564,7 +1565,7 @@
     } else if (!this._showBarsForPath(path)) {
       hideClass = 'invisible';
     }
-    return `sizeBars desktop ${hideClass}`;
+    return `sizeBars ${hideClass}`;
   }
 
   /**
@@ -1621,11 +1622,9 @@
   _reportRenderedRow(index: number) {
     if (index === this._shownFiles.length - 1) {
       setTimeout(() => {
-        this.reporting.timeEndWithAverage(
-          Timing.FILE_RENDER,
-          Timing.FILE_RENDER_AVG,
-          this._reportinShownFilesIncrement
-        );
+        this.reporting.timeEnd(Timing.FILE_RENDER, {
+          count: this._reportinShownFilesIncrement,
+        });
       }, 1);
     }
     return '';
@@ -1640,9 +1639,7 @@
   }
 
   _handleReloadingDiffPreference() {
-    this._getDiffPreferences().then(prefs => {
-      this.diffPrefs = prefs;
-    });
+    this.userModel.getDiffPreferences();
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index f7be36b..67ca9be 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -37,13 +37,15 @@
       max-width: 1px;
       overflow: hidden;
       display: none;
+      vertical-align: top;
     }
     div[role='gridcell']
       > div.comments
       > span:empty
       + span:empty
       + span.noCommentsScreenReaderText {
-      display: inline;
+      /* inline-block instead of block, such that it can control width */
+      display: inline-block;
     }
     :host(.loading) .row {
       opacity: 0.5;
@@ -126,6 +128,7 @@
     .comments {
       padding-left: var(--spacing-l);
       min-width: 7.5em;
+      white-space: nowrap;
     }
     .row:not(.header-row) .stats,
     .total-stats {
@@ -263,8 +266,19 @@
       visibility: visible;
     }
 
-    /** small screen breakpoint: 768px */
-    @media screen and (max-width: 55em) {
+    @media screen and (max-width: 1200px) {
+      gr-endpoint-decorator.extra-col {
+        display: none;
+      }
+    }
+
+    @media screen and (max-width: 1000px) {
+      .reviewed {
+        display: none;
+      }
+    }
+
+    @media screen and (max-width: 800px) {
       .desktop {
         display: none;
       }
@@ -281,9 +295,6 @@
       .status {
         justify-content: flex-start;
       }
-      .reviewed {
-        display: none;
-      }
       .comments {
         min-width: initial;
       }
@@ -315,7 +326,11 @@
           items="[[_dynamicPrependedHeaderEndpoints]]"
           as="headerEndpoint"
         >
-          <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
+          <gr-endpoint-decorator
+            class="prepended-col"
+            name$="[[headerEndpoint]]"
+            role="columnheader"
+          >
             <gr-endpoint-param name="change" value="[[change]]">
             </gr-endpoint-param>
             <gr-endpoint-param name="patchRange" value="[[patchRange]]">
@@ -326,8 +341,9 @@
         </template>
       </template>
       <div class="path" role="columnheader">File</div>
-      <div class="comments" role="columnheader">Comments</div>
-      <div class="sizeBars" role="columnheader">Size</div>
+      <div class="comments desktop" role="columnheader">Comments</div>
+      <div class="comments mobile" role="columnheader" title="Comments">C</div>
+      <div class="sizeBars desktop" role="columnheader">Size</div>
       <div class="header-stats" role="columnheader">Delta</div>
       <!-- endpoint: change-view-file-list-header -->
       <template is="dom-if" if="[[_showDynamicColumns]]">
@@ -336,7 +352,11 @@
           items="[[_dynamicHeaderEndpoints]]"
           as="headerEndpoint"
         >
-          <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
+          <gr-endpoint-decorator
+            class="extra-col"
+            name$="[[headerEndpoint]]"
+            role="columnheader"
+          >
           </gr-endpoint-decorator>
         </template>
       </template>
@@ -373,7 +393,11 @@
               items="[[_dynamicPrependedContentEndpoints]]"
               as="contentEndpoint"
             >
-              <gr-endpoint-decorator name="[[contentEndpoint]]" role="gridcell">
+              <gr-endpoint-decorator
+                class="prepended-col"
+                name="[[contentEndpoint]]"
+                role="gridcell"
+              >
                 <gr-endpoint-param name="change" value="[[change]]">
                 </gr-endpoint-param>
                 <gr-endpoint-param name="changeNum" value="[[changeNum]]">
@@ -389,13 +413,13 @@
           </template>
           <!-- TODO: Remove data-url as it appears its not used -->
           <span
-            data-url="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
+            data-url="[[_computeDiffURL(change, patchRange.basePatchNum, patchRange.patchNum, file.__path, editMode)]]"
             class="path"
             role="gridcell"
           >
             <a
               class="pathLink"
-              href$="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
+              href$="[[_computeDiffURL(change, patchRange.basePatchNum, patchRange.patchNum, file.__path, editMode)]]"
             >
               <span
                 title$="[[_computeDisplayPath(file.__path)]]"
@@ -472,7 +496,7 @@
               </span>
             </div>
           </div>
-          <div role="gridcell">
+          <div class="desktop" role="gridcell">
             <!-- The content must be in a separate div. It guarantees, that
               gridcell always visible for screen readers.
               For example, without a nested div screen readers pronounce the
@@ -540,7 +564,10 @@
               as="contentEndpoint"
             >
               <div class$="[[_computeClass('', file.__path)]]" role="gridcell">
-                <gr-endpoint-decorator name="[[contentEndpoint]]">
+                <gr-endpoint-decorator
+                  class="extra-col"
+                  name="[[contentEndpoint]]"
+                >
                   <gr-endpoint-param name="change" value="[[change]]">
                   </gr-endpoint-param>
                   <gr-endpoint-param name="changeNum" value="[[changeNum]]">
@@ -643,7 +670,6 @@
             prefs="[[diffPrefs]]"
             project-name="[[change.project]]"
             no-render-on-prefs-change=""
-            view-mode="[[diffViewMode]]"
           ></gr-diff-host>
         </template>
       </div>
@@ -660,7 +686,11 @@
             items="[[_dynamicPrependedContentEndpoints]]"
             as="contentEndpoint"
           >
-            <gr-endpoint-decorator name="[[contentEndpoint]]" role="gridcell">
+            <gr-endpoint-decorator
+              class="prepended-col"
+              name="[[contentEndpoint]]"
+              role="gridcell"
+            >
               <gr-endpoint-param name="change" value="[[change]]">
               </gr-endpoint-param>
               <gr-endpoint-param name="changeNum" value="[[changeNum]]">
@@ -723,7 +753,7 @@
         items="[[_dynamicSummaryEndpoints]]"
         as="summaryEndpoint"
       >
-        <gr-endpoint-decorator name="[[summaryEndpoint]]">
+        <gr-endpoint-decorator class="extra-col" name="[[summaryEndpoint]]">
           <gr-endpoint-param
             name="change"
             value="[[change]]"
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 7409be7..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ /dev/null
@@ -1,1746 +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 '../../diff/gr-comment-api/gr-comment-api.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"
-        change-comments="[[_changeComments]]"></gr-file-list>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
-`);
-
-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();
-      const diffDisplay = element.diffs[0];
-      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
-      element.set('diffViewMode', 'UNIFIED_DIFF');
-      assert.equal(diffDisplay.viewMode, '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: 'p0',
-        style: {},
-        prefetchDiff() {},
-        reload() {
-          assert.equal(reviewStub.callCount, 2);
-          assert.equal(callCount++, 2);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p1',
-        style: {},
-        prefetchDiff() {},
-        reload() {
-          assert.equal(reviewStub.callCount, 1);
-          assert.equal(callCount++, 1);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p2',
-        style: {},
-        prefetchDiff() {},
-        reload() {
-          assert.equal(reviewStub.callCount, 0);
-          assert.equal(callCount++, 0);
-          return Promise.resolve();
-        },
-      }];
-      element._renderInOrder([
-        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3);
-      await flush();
-      assert.equal(reviewStub.callCount, 3);
-    });
-
-    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';
-      const patchRange = {
-        patchNum: 1,
-      };
-      assert.equal(
-          element._computeDiffURL(change, patchRange, 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';
-      const patchRange = {
-        patchNum: 1,
-      };
-      assert.equal(
-          element._computeDiffURL(change, patchRange, 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';
-      const patchRange = {
-        patchNum: 1,
-      };
-      assert.equal(
-          element._computeDiffURL(change, patchRange, 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';
-      const patchRange = {
-        patchNum: 1,
-      };
-      assert.equal(
-          element._computeDiffURL(change, patchRange, 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 desktop hide');
-      assert.equal(element._computeSizeBarsClass(true, '/COMMIT_MSG'),
-          'sizeBars desktop invisible');
-      assert.equal(element._computeSizeBarsClass(true, 'foo/bar.baz'),
-          'sizeBars desktop ');
-    });
-  });
-
-  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();
-      sinon.stub(diff.changeComments, 'getCommentsForPath')
-          .withArgs('/COMMIT_MSG', {
-            basePatchNum: 'PARENT',
-            patchNum: 2,
-          })
-          .returns(diff.comments);
-      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..cc977f1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -0,0 +1,2250 @@
+/**
+ * @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 {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {runA11yAudit} from '../../../test/a11y-test-utils';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {
+  listenOnce,
+  mockPromise,
+  query,
+  spyRestApi,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  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 {
+  assertIsDefined,
+  queryAll,
+  queryAndAssert,
+} from '../../../utils/common-util';
+import {GrFileList, NormalizedFileInfo} from './gr-file-list';
+import {DiffPreferencesInfo} from '../../../types/diff';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {ParsedChangeInfo} from '../../../types/types';
+import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
+import {IronIconElement} from '@polymer/iron-icon';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrEditFileControls} from '../../edit/gr-edit-file-controls/gr-edit-file-controls';
+
+const commentApiMock = createCommentApiMockWithTemplateElement(
+  'gr-file-list-comment-api-mock',
+  html` <gr-file-list id="fileList"></gr-file-list> `
+);
+
+const basicFixture = fixtureFromElement(commentApiMock.is);
+
+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 commentApiWrapper: any;
+
+  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.
+      commentApiWrapper = basicFixture.instantiate();
+      element = commentApiWrapper.$.fileList;
+
+      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);
+        assertIsDefined(element.diffCursor);
+
+        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', () => {
+        assertIsDefined(element.diffCursor);
+        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');
+      assertIsDefined(element.diffCursor);
+      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]);
+      }
+
+      assertIsDefined(element.diffCursor);
+      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.
+      commentApiWrapper = basicFixture.instantiate();
+      element = commentApiWrapper.$.fileList;
+      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());
+        assertIsDefined(element.diffCursor);
+        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-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index d50e00f..3d7ab55 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -15,14 +15,14 @@
  * limitations under the License.
  */
 import '@polymer/iron-input/iron-input';
-import '../../../styles/shared-styles';
-import '../../../styles/gr-font-styles';
 import '../../shared/gr-button/gr-button';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-included-in-dialog_html';
-import {customElement, property} from '@polymer/decorators';
 import {IncludedInInfo, NumericChangeId} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
 
 interface DisplayGroup {
   title: string;
@@ -30,77 +30,182 @@
 }
 
 @customElement('gr-included-in-dialog')
-export class GrIncludedInDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrIncludedInDialog extends LitElement {
   /**
    * Fired when the user presses the close button.
    *
    * @event close
    */
 
-  @property({type: Object, observer: '_resetData'})
+  @property({type: Object})
   changeNum?: NumericChangeId;
 
-  @property({type: Object})
-  _includedIn?: IncludedInInfo;
+  // private but used in test
+  @state() includedIn?: IncludedInInfo;
 
-  @property({type: Boolean})
-  _loaded = false;
+  @state() private loaded = false;
 
-  @property({type: String})
-  _filterText = '';
+  // private but used in test
+  @state() filterText = '';
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      fontStyles,
+      sharedStyles,
+      css`
+        :host {
+          background-color: var(--dialog-background-color);
+          display: block;
+          max-height: 80vh;
+          overflow-y: auto;
+          padding: 4.5em var(--spacing-l) var(--spacing-l) var(--spacing-l);
+        }
+        header {
+          background-color: var(--dialog-background-color);
+          border-bottom: 1px solid var(--border-color);
+          left: 0;
+          padding: var(--spacing-l);
+          position: absolute;
+          right: 0;
+          top: 0;
+        }
+        #title {
+          display: inline-block;
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+          margin-top: var(--spacing-xs);
+        }
+        #filterInput {
+          display: block;
+          float: right;
+          margin: 0 var(--spacing-l);
+          padding: var(--spacing-xs);
+        }
+        .closeButtonContainer {
+          float: right;
+        }
+        ul {
+          margin-bottom: var(--spacing-l);
+        }
+        ul li {
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          background: var(--chip-background-color);
+          display: inline-block;
+          margin: 0 var(--spacing-xs) var(--spacing-s) var(--spacing-xs);
+          padding: var(--spacing-xs) var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <header>
+        <h1 id="title" class="heading-1">Included In:</h1>
+        <span class="closeButtonContainer">
+          <gr-button
+            id="closeButton"
+            link
+            @click=${(e: Event) => {
+              this.handleCloseTap(e);
+            }}
+            >Close</gr-button
+          >
+        </span>
+        <iron-input
+          id="filterInput"
+          .bindValue=${this.filterText}
+          @bind-value-changed=${(e: BindValueChangeEvent) => {
+            this.filterText = e.detail.value;
+          }}
+        >
+          <input placeholder="Filter" />
+        </iron-input>
+      </header>
+      ${this.renderLoading()}
+      ${this.computeGroups().map(group => this.renderGroup(group))}
+    `;
+  }
+
+  private renderLoading() {
+    if (this.loaded) return;
+
+    return html`<div>Loading...</div>`;
+  }
+
+  private renderGroup(group: DisplayGroup) {
+    return html`
+      <div>
+        <span>${group.title}:</span>
+        <ul>
+          ${group.items.map(item => this.renderGroupItem(item))}
+        </ul>
+      </div>
+    `;
+  }
+
+  private renderGroupItem(item: string) {
+    return html`<li>${item}</li>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('changeNum')) {
+      this.resetData();
+    }
+  }
 
   loadData() {
     if (!this.changeNum) {
       return Promise.reject(new Error('missing required property changeNum'));
     }
-    this._filterText = '';
+    this.filterText = '';
     return this.restApiService
       .getChangeIncludedIn(this.changeNum)
       .then(configs => {
         if (!configs) {
           return;
         }
-        this._includedIn = configs;
-        this._loaded = true;
+        this.includedIn = configs;
+        this.loaded = true;
       });
   }
 
-  _resetData() {
-    this._includedIn = undefined;
-    this._loaded = false;
+  private resetData() {
+    this.includedIn = undefined;
+    this.loaded = false;
   }
 
-  _computeGroups(includedIn: IncludedInInfo | undefined, filterText: string) {
-    if (!includedIn || filterText === undefined) {
+  // private but used in test
+  computeGroups() {
+    if (!this.includedIn || this.filterText === undefined) {
       return [];
     }
 
     const filter = (item: string) =>
-      !filterText.length ||
-      item.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
+      !this.filterText.length ||
+      item.toLowerCase().indexOf(this.filterText.toLowerCase()) !== -1;
 
     const groups: DisplayGroup[] = [
-      {title: 'Branches', items: includedIn.branches.filter(filter)},
-      {title: 'Tags', items: includedIn.tags.filter(filter)},
+      {title: 'Branches', items: this.includedIn.branches.filter(filter)},
+      {title: 'Tags', items: this.includedIn.tags.filter(filter)},
     ];
-    if (includedIn.external) {
-      for (const externalKey of Object.keys(includedIn.external)) {
+    if (this.includedIn.external) {
+      for (const externalKey of Object.keys(this.includedIn.external)) {
         groups.push({
           title: externalKey,
-          items: includedIn.external[externalKey].filter(filter),
+          items: this.includedIn.external[externalKey].filter(filter),
         });
       }
     }
     return groups.filter(g => g.items.length);
   }
 
-  _handleCloseTap(e: Event) {
+  private handleCloseTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -110,10 +215,6 @@
       })
     );
   }
-
-  _computeLoadingClass(loaded: boolean) {
-    return loaded ? 'loading loaded' : 'loading';
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts
deleted file mode 100644
index 674b7e7..0000000
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_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="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      background-color: var(--dialog-background-color);
-      display: block;
-      max-height: 80vh;
-      overflow-y: auto;
-      padding: 4.5em var(--spacing-l) var(--spacing-l) var(--spacing-l);
-    }
-    header {
-      background-color: var(--dialog-background-color);
-      border-bottom: 1px solid var(--border-color);
-      left: 0;
-      padding: var(--spacing-l);
-      position: absolute;
-      right: 0;
-      top: 0;
-    }
-    #title {
-      display: inline-block;
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-      margin-top: var(--spacing-xs);
-    }
-    #filterInput {
-      display: inline-block;
-      float: right;
-      margin: 0 var(--spacing-l);
-      padding: var(--spacing-xs);
-    }
-    .closeButtonContainer {
-      float: right;
-    }
-    ul {
-      margin-bottom: var(--spacing-l);
-    }
-    ul li {
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      background: var(--chip-background-color);
-      display: inline-block;
-      margin: 0 var(--spacing-xs) var(--spacing-s) var(--spacing-xs);
-      padding: var(--spacing-xs) var(--spacing-s);
-    }
-    .loading.loaded {
-      display: none;
-    }
-  </style>
-  <header>
-    <h1 id="title" class="heading-1">Included In:</h1>
-    <span class="closeButtonContainer">
-      <gr-button id="closeButton" link="" on-click="_handleCloseTap"
-        >Close</gr-button
-      >
-    </span>
-    <iron-input
-      id="filterInput"
-      placeholder="Filter"
-      bind-value="{{_filterText}}"
-    >
-      <input
-        is="iron-input"
-        placeholder="Filter"
-        bind-value="{{_filterText}}"
-      />
-    </iron-input>
-  </header>
-  <div class$="[[_computeLoadingClass(_loaded)]]">Loading...</div>
-  <template
-    is="dom-repeat"
-    items="[[_computeGroups(_includedIn, _filterText)]]"
-    as="group"
-  >
-    <div>
-      <span>[[group.title]]:</span>
-      <ul>
-        <template is="dom-repeat" items="[[group.items]]">
-          <li>[[item]]</li>
-        </template>
-      </ul>
-    </div>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.ts
index 4e02155..e3d1e02 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.ts
@@ -27,66 +27,66 @@
 suite('gr-included-in-dialog', () => {
   let element: GrIncludedInDialog;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
-  test('_computeGroups', () => {
-    const includedIn = {branches: [], tags: []} as IncludedInInfo;
-    let filterText = '';
-    assert.deepEqual(element._computeGroups(includedIn, filterText), []);
+  test('computeGroups', () => {
+    element.includedIn = {branches: [], tags: []} as IncludedInInfo;
+    element.filterText = '';
+    assert.deepEqual(element.computeGroups(), []);
 
-    includedIn.branches.push(
+    element.includedIn.branches.push(
       'master' as BranchName,
       'development' as BranchName,
       'stable-2.0' as BranchName
     );
-    includedIn.tags.push(
+    element.includedIn.tags.push(
       'v1.9' as TagName,
       'v2.0' as TagName,
       'v2.1' as TagName
     );
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+    assert.deepEqual(element.computeGroups(), [
       {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
       {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
     ]);
 
-    includedIn.external = {};
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+    element.includedIn.external = {};
+    assert.deepEqual(element.computeGroups(), [
       {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
       {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
     ]);
 
-    includedIn.external.foo = ['abc', 'def', 'ghi'];
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+    element.includedIn.external.foo = ['abc', 'def', 'ghi'];
+    assert.deepEqual(element.computeGroups(), [
       {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
       {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
       {title: 'foo', items: ['abc', 'def', 'ghi']},
     ]);
 
-    filterText = 'v2';
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+    element.filterText = 'v2';
+    assert.deepEqual(element.computeGroups(), [
       {title: 'Tags', items: ['v2.0', 'v2.1']},
     ]);
 
     // Filtering is case-insensitive.
-    filterText = 'V2';
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+    element.filterText = 'V2';
+    assert.deepEqual(element.computeGroups(), [
       {title: 'Tags', items: ['v2.0', 'v2.1']},
     ]);
   });
 
-  test('_computeGroups with .bindValue', async () => {
+  test('computeGroups with .bindValue', async () => {
     queryAndAssert<IronInputElement>(element, '#filterInput')!.bindValue =
       'stable-3.2';
-    const includedIn = {branches: [], tags: []} as IncludedInInfo;
-    includedIn.branches.push(
+    element.includedIn = {branches: [], tags: []} as IncludedInInfo;
+    element.includedIn.branches.push(
       'master' as BranchName,
       'stable-3.2' as BranchName
     );
-    await flush();
-    const filterText = element._filterText;
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+    await element.updateComplete;
+    assert.deepEqual(element.computeGroups(), [
       {title: 'Branches', items: ['stable-3.2']},
     ]);
   });
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 9a38095..e2852f6 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
@@ -16,35 +16,22 @@
  */
 import '@polymer/iron-selector/iron-selector';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-label-score-row_html';
-import {customElement, property} from '@polymer/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+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 {hasOwnProperty} from '../../../utils/common-util';
-
-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;
-}
-
-export interface GrLabelScoreRow {
-  $: {
-    labelSelector: IronSelectorElement;
-  };
-}
+import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
+import {getAppContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {classMap} from 'lit/directives/class-map';
+import {Label} from '../../../utils/label-util';
+import {LabelNameToValuesMap} from '../../../api/rest-api';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -53,115 +40,308 @@
 }
 
 @customElement('gr-label-score-row')
-export class GrLabelScoreRow extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrLabelScoreRow extends LitElement {
   /**
    * Fired when any label is changed.
    *
    * @event labels-changed
    */
 
+  @query('#labelSelector')
+  labelSelector?: IronSelectorElement;
+
   @property({type: Object})
   label: Label | undefined | null;
 
   @property({type: Object})
   labels?: LabelNameToInfoMap;
 
-  @property({type: String, reflectToAttribute: true})
+  @property({type: String, reflect: true})
   name?: string;
 
   @property({type: Object})
-  permittedLabels: LabelNameToValueMap | undefined | null;
+  permittedLabels: LabelNameToValuesMap | undefined | null;
 
-  @property({type: Object})
-  labelValues?: LabelValuesMap;
+  @property({type: Array})
+  orderedLabelValues?: number[];
 
-  @property({type: String})
-  _selectedValueText = 'No value selected';
+  @state()
+  private selectedValueText = 'No value selected';
 
-  @property({
-    computed: '_computePermittedLabelValues(permittedLabels, label.name)',
-    type: Array,
-  })
-  _items!: string[];
+  private readonly flagsService = getAppContext().flagsService;
 
-  get selectedItem() {
-    if (!this._ironSelector) {
+  private readonly isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
+    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+  );
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        .labelNameCell,
+        .buttonsCell,
+        .selectedValueCell {
+          padding: var(--spacing-s) var(--spacing-m);
+          display: table-cell;
+        }
+        /* We want the :hover highlight to extend to the border of the dialog. */
+        .labelNameCell {
+          padding-left: var(--spacing-xl);
+        }
+        .labelNameCell.newSubmitRequirements {
+          width: 160px;
+        }
+        .selectedValueCell {
+          padding-right: var(--spacing-xl);
+        }
+        /* This is a trick to let the selectedValueCell take the remaining width. */
+        .labelNameCell,
+        .buttonsCell {
+          white-space: nowrap;
+        }
+        .selectedValueCell {
+          width: 75%;
+        }
+        .selectedValueCell.newSubmitRequirements {
+          width: 52%;
+        }
+        .labelMessage {
+          color: var(--deemphasized-text-color);
+        }
+        gr-button {
+          min-width: 42px;
+          box-sizing: border-box;
+          --vote-text-color: var(--vote-chip-unselected-text-color);
+        }
+        gr-button.iron-selected {
+          --vote-text-color: var(--vote-chip-selected-text-color);
+        }
+        gr-button::part(paper-button) {
+          padding: 0 var(--spacing-m);
+          background-color: var(
+            --button-background-color,
+            var(--table-header-background-color)
+          );
+          border-color: var(--vote-chip-unselected-outline-color);
+        }
+        gr-button.iron-selected::part(paper-button) {
+          border-color: transparent;
+        }
+        gr-button {
+          --button-background-color: var(--vote-chip-unselected-color);
+        }
+        gr-button[data-vote='max'].iron-selected {
+          --button-background-color: var(--vote-chip-selected-positive-color);
+        }
+        gr-button[data-vote='positive'].iron-selected {
+          --button-background-color: var(--vote-chip-selected-positive-color);
+        }
+        gr-button[data-vote='neutral'].iron-selected {
+          --button-background-color: var(--vote-chip-selected-neutral-color);
+        }
+        gr-button[data-vote='negative'].iron-selected {
+          --button-background-color: var(--vote-chip-selected-negative-color);
+        }
+        gr-button[data-vote='min'].iron-selected {
+          --button-background-color: var(--vote-chip-selected-negative-color);
+        }
+        gr-button > gr-tooltip-content {
+          margin: 0px -10px;
+          padding: 0px 10px;
+        }
+        .placeholder {
+          display: inline-block;
+          width: 42px;
+          height: 1px;
+        }
+        .placeholder::before {
+          content: ' ';
+        }
+        .selectedValueCell {
+          color: var(--deemphasized-text-color);
+          font-style: italic;
+        }
+        .selectedValueCell.hidden {
+          display: none;
+        }
+        @media only screen and (max-width: 50em) {
+          .selectedValueCell {
+            display: none;
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <span
+        class=${classMap({
+          labelNameCell: true,
+          newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
+        })}
+        id="labelName"
+        aria-hidden="true"
+        >${this.label?.name ?? ''}</span
+      >
+      ${this.renderButtonsCell()} ${this.renderSelectedValue()}
+    `;
+  }
+
+  private renderButtonsCell() {
+    return html`
+      <div class="buttonsCell">
+        ${this.renderBlankItems('start')} ${this.renderLabelSelector()}
+        ${this.renderBlankItems('end')}
+      </div>
+    `;
+  }
+
+  // Render blank cells so that all same value votes are aligned
+  private renderBlankItems(position: string) {
+    const blankItemCount = this.computeBlankItemsCount(position);
+    return new Array(blankItemCount)
+      .fill('')
+      .map(
+        () => html`
+          <span class="placeholder" data-label=${this.label?.name ?? ''}>
+          </span>
+        `
+      );
+  }
+
+  private renderLabelSelector() {
+    return html`
+      <iron-selector
+        id="labelSelector"
+        .attrForSelected=${'data-value'}
+        selected=${ifDefined(this._computeLabelValue())}
+        @selected-item-changed=${this.setSelectedValueText}
+        role="radiogroup"
+        aria-labelledby="labelName"
+      >
+        ${this.renderPermittedLabels()}
+      </iron-selector>
+    `;
+  }
+
+  private renderPermittedLabels() {
+    const items = this.computePermittedLabelValues();
+    return items.map(
+      (value, index) => html`
+        <gr-button
+          role="radio"
+          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}
+          voteChip
+          flatten
+        >
+          <gr-tooltip-content
+            has-tooltip
+            light-tooltip
+            title=${ifDefined(this.computeLabelValueTitle(value))}
+          >
+            ${value}
+          </gr-tooltip-content>
+        </gr-button>
+      `
+    );
+  }
+
+  private renderSelectedValue() {
+    return html`
+      <div
+        class=${classMap({
+          selectedValueCell: true,
+          newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
+        })}
+      >
+        <span id="selectedValueLabel">${this.selectedValueText}</span>
+      </div>
+    `;
+  }
+
+  get selectedItem(): IronSelectorElement | undefined {
+    if (!this.labelSelector) {
       return undefined;
     }
-    return this._ironSelector.selectedItem;
+    return this.labelSelector.selectedItem as IronSelectorElement;
   }
 
   get selectedValue() {
-    if (!this._ironSelector) {
+    if (!this.labelSelector) {
       return undefined;
     }
-    return this._ironSelector.selected;
+    return this.labelSelector.selected;
   }
 
   setSelectedValue(value: string) {
     // The selector may not be present if it’s not at the latest patch set.
-    if (!this._ironSelector) {
+    if (!this.labelSelector) {
       return;
     }
-    this._ironSelector.select(value);
+    this.labelSelector.select(value);
   }
 
-  get _ironSelector() {
-    return this.$ && this.$.labelSelector;
-  }
-
-  _computeBlankItems(
-    permittedLabels: LabelNameToValueMap,
-    label: string,
-    side: string
-  ) {
+  // Private but used in tests.
+  computeBlankItemsCount(side: string) {
     if (
-      !permittedLabels ||
-      !permittedLabels[label] ||
-      !permittedLabels[label].length ||
-      !this.labelValues ||
-      !Object.keys(this.labelValues).length
+      !this.label ||
+      !this.permittedLabels?.[this.label.name] ||
+      !this.permittedLabels[this.label.name].length ||
+      !this.orderedLabelValues?.length
     ) {
-      return [];
+      return 0;
     }
-    const startPosition = this.labelValues[Number(permittedLabels[label][0])];
+    const orderedLabelValues = this.orderedLabelValues;
+    const permittedLabel = this.permittedLabels[this.label.name];
+    // 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);
+      return Number(permittedLabel[0]) - orderedLabelValues[0];
     }
-    const endPosition =
-      this.labelValues[
-        Number(permittedLabels[label][permittedLabels[label].length - 1])
-      ];
-    return new Array(Object.keys(this.labelValues).length - endPosition - 1);
+    // 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])
+    );
   }
 
-  _getLabelValue(
-    labels: LabelNameToInfoMap,
-    permittedLabels: LabelNameToValueMap,
-    label: Label
-  ) {
-    if (label.value) {
-      return label.value;
+  private getLabelValue() {
+    assertIsDefined(this.labels);
+    assertIsDefined(this.label);
+    assertIsDefined(this.permittedLabels);
+    if (this.label.value) {
+      return this.label.value;
     } else if (
-      hasOwnProperty(labels[label.name], 'default_value') &&
-      hasOwnProperty(permittedLabels, label.name)
+      hasOwnProperty(this.labels[this.label.name], 'default_value') &&
+      hasOwnProperty(this.permittedLabels, this.label.name)
     ) {
       // default_value is an int, convert it to string label, e.g. "+1".
-      return permittedLabels[label.name].find(
+      return this.permittedLabels[this.label.name].find(
         value =>
-          Number(value) === (labels[label.name] as QuickLabelInfo).default_value
+          Number(value) ===
+          (this.labels![this.label!.name] as QuickLabelInfo).default_value
       );
     }
     return;
   }
 
   /**
+   * Private but used in tests.
    * Maps the label value to exactly one of: min, max, positive, negative,
-   * neutral. Used for the 'vote' attribute, because we don't want to
+   * neutral. Used for the 'data-vote' attribute, because we don't want to
    * interfere with <iron-selector> using the 'class' attribute for setting
    * 'iron-selected'.
    */
@@ -179,37 +359,29 @@
     }
   }
 
-  _computeLabelValue(
-    labels?: LabelNameToInfoMap,
-    permittedLabels?: LabelNameToValueMap,
-    label?: Label
-  ) {
+  // Private but used in tests.
+  _computeLabelValue() {
     // Polymer 2+ undefined check
-    if (
-      labels === undefined ||
-      permittedLabels === undefined ||
-      label === undefined
-    ) {
-      return null;
+    if (!this.labels || !this.permittedLabels || !this.label) {
+      return undefined;
     }
 
-    if (!labels[label.name]) {
-      return null;
+    if (!this.labels[this.label.name]) {
+      return undefined;
     }
-    const labelValue = this._getLabelValue(labels, permittedLabels, label);
-    const len = permittedLabels[label.name]
-      ? permittedLabels[label.name].length
-      : 0;
+    const labelValue = this.getLabelValue();
+    const permittedLabel = this.permittedLabels[this.label.name];
+    const len = permittedLabel ? permittedLabel.length : 0;
     for (let i = 0; i < len; i++) {
-      const val = permittedLabels[label.name][i];
+      const val = permittedLabel[i];
       if (val === labelValue) {
         return val;
       }
     }
-    return null;
+    return undefined;
   }
 
-  _setSelectedValueText(e: Event) {
+  private setSelectedValueText = (e: Event) => {
     // Needed because when the selected item changes, it first changes to
     // nothing and then to the new item.
     const selectedItem = (e.target as IronSelectorElement)
@@ -217,19 +389,17 @@
     if (!selectedItem) {
       return;
     }
-    if (!this.$.labelSelector.items) {
+    if (!this.labelSelector?.items) {
       return;
     }
-    for (const item of this.$.labelSelector.items) {
+    for (const item of this.labelSelector.items) {
       if (selectedItem === item) {
         item.setAttribute('aria-checked', 'true');
       } else {
         item.removeAttribute('aria-checked');
       }
     }
-    this._selectedValueText = selectedItem.getAttribute('title') || '';
-    // Needed to update the style of the selected button.
-    this.updateStyles();
+    this.selectedValueText = selectedItem.getAttribute('title') || '';
     const name = selectedItem.dataset['name'];
     const value = selectedItem.dataset['value'];
     this.dispatchEvent(
@@ -239,47 +409,25 @@
         composed: true,
       })
     );
-  }
+  };
 
-  _computeAnyPermittedLabelValues(
-    permittedLabels: LabelNameToValueMap,
-    labelName: string
-  ) {
-    return (
-      permittedLabels &&
-      hasOwnProperty(permittedLabels, labelName) &&
-      permittedLabels[labelName].length
-    );
-  }
-
-  _computeHiddenClass(permittedLabels: LabelNameToValueMap, labelName: string) {
-    return !this._computeAnyPermittedLabelValues(permittedLabels, labelName)
-      ? 'hidden'
-      : '';
-  }
-
-  _computePermittedLabelValues(
-    permittedLabels?: LabelNameToValueMap,
-    labelName?: string
-  ) {
-    // Polymer 2: check for undefined
-    if (permittedLabels === undefined || labelName === undefined) {
+  private computePermittedLabelValues() {
+    if (!this.permittedLabels || !this.label) {
       return [];
     }
 
-    return permittedLabels[labelName] || [];
+    return this.permittedLabels[this.label.name] || [];
   }
 
-  _computeLabelValueTitle(
-    labels: LabelNameToInfoMap,
-    label: string,
-    value: string
-  ) {
-    // TODO(TS): maybe add a type guard for DetailedLabelInfo and QuickLabelInfo
-    return (
-      labels[label] &&
-      (labels[label] as DetailedLabelInfo).values &&
-      (labels[label] as DetailedLabelInfo).values![value]
-    );
+  private computeLabelValueTitle(value: string) {
+    if (!this.labels || !this.label) return '';
+    const label = this.labels[this.label.name];
+    if (label && (label as DetailedLabelInfo).values) {
+      // TODO(TS): maybe add a type guard for DetailedLabelInfo and
+      // QuickLabelInfo
+      return (label as DetailedLabelInfo).values![value];
+    } else {
+      return '';
+    }
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
deleted file mode 100644
index e57a216..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_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">
-    .labelNameCell,
-    .buttonsCell,
-    .selectedValueCell {
-      padding: var(--spacing-s) var(--spacing-m);
-      display: table-cell;
-    }
-    /* We want the :hover highlight to extend to the border of the dialog. */
-    .labelNameCell {
-      padding-left: var(--spacing-xl);
-    }
-    .selectedValueCell {
-      padding-right: var(--spacing-xl);
-    }
-    /* This is a trick to let the selectedValueCell take the remaining width. */
-    .labelNameCell,
-    .buttonsCell {
-      white-space: nowrap;
-    }
-    .selectedValueCell {
-      width: 75%;
-    }
-    .labelMessage {
-      color: var(--deemphasized-text-color);
-    }
-    gr-button {
-      min-width: 42px;
-      box-sizing: border-box;
-    }
-    gr-button::part(paper-button) {
-      background-color: var(
-        --button-background-color,
-        var(--table-header-background-color)
-      );
-      padding: 0 var(--spacing-m);
-    }
-    gr-button[vote='max'].iron-selected {
-      --button-background-color: var(--vote-color-approved);
-    }
-    gr-button[vote='positive'].iron-selected {
-      --button-background-color: var(--vote-color-recommended);
-    }
-    gr-button[vote='min'].iron-selected {
-      --button-background-color: var(--vote-color-rejected);
-    }
-    gr-button[vote='negative'].iron-selected {
-      --button-background-color: var(--vote-color-disliked);
-    }
-    gr-button[vote='neutral'].iron-selected {
-      --button-background-color: var(--vote-color-neutral);
-    }
-    gr-button[vote='positive'].iron-selected::part(paper-button) {
-      border-color: var(--vote-outline-recommended);
-    }
-    gr-button[vote='negative'].iron-selected::part(paper-button) {
-      border-color: var(--vote-outline-disliked);
-    }
-    gr-button > gr-tooltip-content {
-      margin: 0px -10px;
-      padding: 0px 10px;
-    }
-    .placeholder {
-      display: inline-block;
-      width: 42px;
-      height: 1px;
-    }
-    .placeholder::before {
-      content: ' ';
-    }
-    .selectedValueCell {
-      color: var(--deemphasized-text-color);
-      font-style: italic;
-    }
-    .selectedValueCell.hidden {
-      display: none;
-    }
-    @media only screen and (max-width: 50em) {
-      .selectedValueCell {
-        display: none;
-      }
-    }
-  </style>
-  <span class="labelNameCell" id="labelName" aria-hidden="true"
-    >[[label.name]]</span
-  >
-  <div class="buttonsCell">
-    <template
-      is="dom-repeat"
-      items="[[_computeBlankItems(permittedLabels, label.name, 'start')]]"
-      as="value"
-    >
-      <span class="placeholder" data-label$="[[label.name]]"></span>
-    </template>
-    <iron-selector
-      id="labelSelector"
-      attr-for-selected="data-value"
-      selected="[[_computeLabelValue(labels, permittedLabels, label)]]"
-      hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
-      on-selected-item-changed="_setSelectedValueText"
-      role="radiogroup"
-      aria-labelledby="labelName"
-    >
-      <template is="dom-repeat" items="[[_items]]" as="value">
-        <gr-button
-          role="radio"
-          vote$="[[_computeVoteAttribute(value, index, _items.length)]]"
-          title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
-          data-name$="[[label.name]]"
-          data-value$="[[value]]"
-          aria-label$="[[value]]"
-          voteChip
-        >
-          <gr-tooltip-content
-            has-tooltip
-            title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
-          >
-            [[value]]
-          </gr-tooltip-content>
-        </gr-button>
-      </template>
-    </iron-selector>
-    <template
-      is="dom-repeat"
-      items="[[_computeBlankItems(permittedLabels, label.name, 'end')]]"
-      as="value"
-    >
-      <span class="placeholder" data-label$="[[label.name]]"></span>
-    </template>
-    <span
-      class="labelMessage"
-      hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
-    >
-      You don't have permission to edit this label.
-    </span>
-  </div>
-  <div
-    class$="selectedValueCell [[_computeHiddenClass(permittedLabels, label.name)]]"
-  >
-    <span id="selectedValueLabel">[[_selectedValueText]]</span>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
deleted file mode 100644
index 51d76b2..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
+++ /dev/null
@@ -1,339 +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-label-score-row.js';
-
-const basicFixture = fixtureFromElement('gr-label-score-row');
-
-suite('gr-label-row-score tests', () => {
-  let element;
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    element.labels = {
-      'Code-Review': {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: 0,
-        value: 1,
-        all: [{
-          _account_id: 123,
-          value: 1,
-        }],
-      },
-      'Verified': {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: 0,
-        value: 1,
-        all: [{
-          _account_id: 123,
-          value: 1,
-        }],
-      },
-    };
-
-    element.permittedLabels = {
-      'Code-Review': [
-        '-2',
-        '-1',
-        ' 0',
-        '+1',
-        '+2',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-
-    element.labelValues = {'0': 2, '1': 3, '2': 4, '-2': 0, '-1': 1};
-
-    element.label = {
-      name: 'Verified',
-      value: '+1',
-    };
-
-    await flush();
-  });
-
-  function checkAriaCheckedValid() {
-    const items = element.$.labelSelector.items;
-    const selectedItem = element.selectedItem;
-    for (let i = 0; i < items.length; i++) {
-      const item = items[i];
-      if (items[i] === selectedItem) {
-        assert.isTrue(item.hasAttribute('aria-checked'), `item ${i}`);
-        assert.equal(item.getAttribute('aria-checked'), 'true', `item ${i}`);
-      } else {
-        assert.isFalse(item.hasAttribute('aria-checked'), `item ${i}`);
-      }
-    }
-  }
-
-  test('label picker', async () => {
-    const labelsChangedHandler = sinon.stub();
-    element.addEventListener('labels-changed', labelsChangedHandler);
-    assert.ok(element.$.labelSelector);
-    MockInteractions.tap(
-        element.shadowRoot.querySelector('gr-button[data-value="-1"]'));
-    await flush();
-    assert.strictEqual(element.selectedValue, '-1');
-    assert.strictEqual(element.selectedItem.textContent.trim(), '-1');
-    assert.strictEqual(element.$.selectedValueLabel.textContent.trim(), 'bad');
-    const detail = labelsChangedHandler.args[0][0].detail;
-    assert.equal(detail.name, 'Verified');
-    assert.equal(detail.value, '-1');
-    checkAriaCheckedValid();
-  });
-
-  test('_computeVoteAttribute', () => {
-    let value = 1;
-    let index = 0;
-    const totalItems = 5;
-    // positive and first position
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'positive');
-    // negative and first position
-    value = -1;
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'min');
-    // negative but not first position
-    index = 1;
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'negative');
-    // neutral
-    value = 0;
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'neutral');
-    // positive but not last position
-    value = 1;
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'positive');
-    // positive and last position
-    index = 4;
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'max');
-    // negative and last position
-    value = -1;
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'negative');
-  });
-
-  test('correct item is selected', () => {
-    // 1 should be the value of the selected item
-    assert.strictEqual(element.$.labelSelector.selected, '+1');
-    assert.strictEqual(
-        element.$.labelSelector.selectedItem.textContent.trim(), '+1');
-    assert.strictEqual(element.$.selectedValueLabel.textContent.trim(), 'good');
-    checkAriaCheckedValid();
-  });
-
-  test('_computeLabelValue', () => {
-    assert.strictEqual(
-        element._computeLabelValue(
-            element.labels, element.permittedLabels, element.label),
-        '+1');
-  });
-
-  test('_computeBlankItems', () => {
-    element.labelValues = {
-      '-2': 0,
-      '-1': 1,
-      '0': 2,
-      '1': 3,
-      '2': 4,
-    };
-
-    assert.strictEqual(
-        element._computeBlankItems(element.permittedLabels, 'Code-Review')
-            .length,
-        0);
-
-    assert.strictEqual(
-        element._computeBlankItems(element.permittedLabels, 'Verified').length,
-        1);
-  });
-
-  test('labelValues returns no keys', () => {
-    element.labelValues = {};
-
-    assert.deepEqual(
-        element._computeBlankItems(element.permittedLabels, 'Code-Review'), []);
-  });
-
-  test('changes in label score are reflected in the DOM', async () => {
-    element.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,
-      },
-    };
-    await flush();
-    const selector = element.$.labelSelector;
-    element.set('label', {name: 'Verified', value: ' 0'});
-    await flush();
-    assert.strictEqual(selector.selected, ' 0');
-    assert.strictEqual(
-        element.$.selectedValueLabel.textContent.trim(), 'No score');
-    checkAriaCheckedValid();
-  });
-
-  test('without permitted labels', async () => {
-    element.permittedLabels = {
-      Verified: [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    await flush();
-    assert.isOk(element.$.labelSelector);
-    assert.isFalse(element.$.labelSelector.hidden);
-
-    element.permittedLabels = {};
-    await flush();
-    assert.isOk(element.$.labelSelector);
-    assert.isTrue(element.$.labelSelector.hidden);
-
-    element.permittedLabels = {Verified: []};
-    await flush();
-    assert.isOk(element.$.labelSelector);
-    assert.isTrue(element.$.labelSelector.hidden);
-  });
-
-  test('asymmetrical labels', async () => {
-    element.permittedLabels = {
-      'Code-Review': [
-        '-2',
-        '-1',
-        ' 0',
-        '+1',
-        '+2',
-      ],
-      'Verified': [
-        ' 0',
-        '+1',
-      ],
-    };
-    await flush();
-    assert.strictEqual(element.$.labelSelector.items.length, 2);
-    assert.strictEqual(element.root.querySelectorAll('.placeholder').length, 3);
-
-    element.permittedLabels = {
-      'Code-Review': [
-        ' 0',
-        '+1',
-      ],
-      'Verified': [
-        '-2',
-        '-1',
-        ' 0',
-        '+1',
-        '+2',
-      ],
-    };
-    await flush();
-    assert.strictEqual(element.$.labelSelector.items.length, 5);
-    assert.strictEqual(element.root.querySelectorAll('.placeholder').length, 0);
-  });
-
-  test('default_value', () => {
-    element.permittedLabels = {
-      Verified: [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    element.labels = {
-      Verified: {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: -1,
-      },
-    };
-    element.label = {
-      name: 'Verified',
-      value: null,
-    };
-    flush();
-    assert.strictEqual(element.selectedValue, '-1');
-    checkAriaCheckedValid();
-  });
-
-  test('default_value is null if not permitted', () => {
-    element.permittedLabels = {
-      Verified: [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    element.labels = {
-      'Code-Review': {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: -1,
-      },
-    };
-    element.label = {
-      name: 'Code-Review',
-      value: null,
-    };
-    flush();
-    assert.isNull(element.selectedValue);
-  });
-});
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
new file mode 100644
index 0000000..24d4f9b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts
@@ -0,0 +1,384 @@
+/**
+ * @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-label-score-row';
+import {GrLabelScoreRow} from './gr-label-score-row';
+import {AccountId} from '../../../api/rest-api';
+import {GrButton} from '../../shared/gr-button/gr-button';
+
+const basicFixture = fixtureFromElement('gr-label-score-row');
+
+suite('gr-label-row-score tests', () => {
+  let element: GrLabelScoreRow;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+        value: 1,
+        all: [
+          {
+            _account_id: 123 as unknown as AccountId,
+            value: 1,
+          },
+        ],
+      },
+      Verified: {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+        value: 1,
+        all: [
+          {
+            _account_id: 123 as unknown as AccountId,
+            value: 1,
+          },
+        ],
+      },
+    };
+
+    element.permittedLabels = {
+      'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
+      Verified: ['-1', ' 0', '+1'],
+    };
+
+    element.orderedLabelValues = [-2, -1, 0, 1, 2];
+    //  {'0': 2, '1': 3, '2': 4, '-2': 0, '-1': 1};
+
+    element.label = {
+      name: 'Verified',
+      value: '+1',
+    };
+
+    await element.updateComplete;
+    await flush();
+  });
+
+  function checkAriaCheckedValid() {
+    const items = element.labelSelector!.items;
+    assert.ok(items);
+    const selectedItem = element.selectedItem;
+    for (let i = 0; i < items!.length; i++) {
+      const item = items![i];
+      if (items![i] === selectedItem) {
+        assert.isTrue(item.hasAttribute('aria-checked'), `item ${i}`);
+        assert.equal(item.getAttribute('aria-checked'), 'true', `item ${i}`);
+      } else {
+        assert.isFalse(item.hasAttribute('aria-checked'), `item ${i}`);
+      }
+    }
+  }
+
+  test('label picker', async () => {
+    const labelsChangedHandler = sinon.stub();
+    element.addEventListener('labels-changed', labelsChangedHandler);
+    assert.ok(element.labelSelector);
+    const button = element.shadowRoot!.querySelector(
+      'gr-button[data-value="-1"]'
+    ) as GrButton;
+    button.click();
+    await element.updateComplete;
+
+    assert.strictEqual(element.selectedValue, '-1');
+    assert.strictEqual(element.selectedItem!.textContent!.trim(), '-1');
+    const selectedValueLabel = element.shadowRoot!.querySelector(
+      '#selectedValueLabel'
+    );
+    assert.strictEqual(selectedValueLabel!.textContent!.trim(), 'bad');
+    const detail = labelsChangedHandler.args[0][0].detail;
+    assert.equal(detail.name, 'Verified');
+    assert.equal(detail.value, '-1');
+    checkAriaCheckedValid();
+  });
+
+  test('_computeVoteAttribute', () => {
+    let value = 1;
+    let index = 0;
+    const totalItems = 5;
+    // positive and first position
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'positive'
+    );
+    // negative and first position
+    value = -1;
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'min'
+    );
+    // negative but not first position
+    index = 1;
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'negative'
+    );
+    // neutral
+    value = 0;
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'neutral'
+    );
+    // positive but not last position
+    value = 1;
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'positive'
+    );
+    // positive and last position
+    index = 4;
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'max'
+    );
+    // negative and last position
+    value = -1;
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'negative'
+    );
+  });
+
+  test('correct item is selected', () => {
+    // 1 should be the value of the selected item
+    assert.strictEqual(element.labelSelector!.selected, '+1');
+    assert.strictEqual(element.selectedItem!.textContent!.trim(), '+1');
+    const selectedValueLabel = element.shadowRoot!.querySelector(
+      '#selectedValueLabel'
+    );
+    assert.strictEqual(selectedValueLabel!.textContent!.trim(), 'good');
+    checkAriaCheckedValid();
+  });
+
+  test('_computeLabelValue', () => {
+    assert.strictEqual(element._computeLabelValue(), '+1');
+  });
+
+  test('computeBlankItemsCount', () => {
+    element.orderedLabelValues = [-2, -1, 0, 1, 2];
+    element.label = {name: 'Code-Review', value: ' 0'};
+    assert.strictEqual(element.computeBlankItemsCount('start'), 0);
+
+    element.label = {name: 'Verified', value: ' 0'};
+    assert.strictEqual(element.computeBlankItemsCount('start'), 1);
+  });
+
+  test('labelValues returns no keys', () => {
+    element.orderedLabelValues = [];
+    element.label = {name: 'Code-Review', value: ' 0'};
+
+    assert.deepEqual(element.computeBlankItemsCount('start'), 0);
+  });
+
+  test('changes in label score are reflected in the DOM', async () => {
+    element.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,
+      },
+    };
+    // For some reason we need the element.labels to flush first before we
+    // change the element.label
+    await element.updateComplete;
+
+    element.label = {name: 'Verified', value: ' 0'};
+    await element.updateComplete;
+    // Wait for @selected-item-changed to fire
+    await flush();
+
+    const selector = element.labelSelector;
+    assert.strictEqual(selector!.selected, ' 0');
+    const selectedValueLabel = element.shadowRoot!.querySelector(
+      '#selectedValueLabel'
+    );
+    assert.strictEqual(selectedValueLabel!.textContent!.trim(), 'No score');
+    checkAriaCheckedValid();
+  });
+
+  test('asymmetrical labels', async () => {
+    element.permittedLabels = {
+      'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
+      Verified: [' 0', '+1'],
+    };
+    await element.updateComplete;
+    assert.strictEqual(element.labelSelector!.items!.length, 2);
+    assert.strictEqual(
+      element.shadowRoot!.querySelectorAll('.placeholder').length,
+      3
+    );
+
+    element.permittedLabels = {
+      'Code-Review': [' 0', '+1'],
+      Verified: ['-2', '-1', ' 0', '+1', '+2'],
+    };
+    await element.updateComplete;
+    assert.strictEqual(element.labelSelector!.items!.length, 5);
+    assert.strictEqual(
+      element.shadowRoot!.querySelectorAll('.placeholder').length,
+      0
+    );
+  });
+
+  test('default_value', async () => {
+    element.permittedLabels = {
+      Verified: ['-1', ' 0', '+1'],
+    };
+    element.labels = {
+      Verified: {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: -1,
+      },
+    };
+    element.label = {
+      name: 'Verified',
+      value: null,
+    };
+    await element.updateComplete;
+    assert.strictEqual(element.selectedValue, '-1');
+    checkAriaCheckedValid();
+  });
+
+  test('default_value is null if not permitted', async () => {
+    element.permittedLabels = {
+      Verified: ['-1', ' 0', '+1'],
+    };
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: -1,
+      },
+    };
+    element.label = {
+      name: 'Code-Review',
+      value: null,
+    };
+    await element.updateComplete;
+    assert.isNull(element.selectedValue);
+  });
+
+  test('shadowDom test', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <span class="labelNameCell" id="labelName" aria-hidden="true">
+        Verified
+      </span>
+      <div class="buttonsCell">
+        <span class="placeholder" data-label="Verified"></span>
+        <iron-selector
+          aria-labelledby="labelName"
+          id="labelSelector"
+          role="radiogroup"
+          selected="+1"
+        >
+          <gr-button
+            aria-disabled="false"
+            aria-label="-1"
+            data-name="Verified"
+            data-value="-1"
+            role="radio"
+            tabindex="0"
+            title="bad"
+            data-vote="min"
+            votechip=""
+            flatten=""
+          >
+            <gr-tooltip-content light-tooltip="" has-tooltip="" title="bad">
+              -1
+            </gr-tooltip-content>
+          </gr-button>
+          <gr-button
+            aria-disabled="false"
+            aria-label=" 0"
+            data-name="Verified"
+            data-value=" 0"
+            role="radio"
+            tabindex="0"
+            data-vote="neutral"
+            votechip=""
+            flatten=""
+          >
+            <gr-tooltip-content light-tooltip="" has-tooltip="">
+              0
+            </gr-tooltip-content>
+          </gr-button>
+          <gr-button
+            aria-checked="true"
+            aria-disabled="false"
+            aria-label="+1"
+            class="iron-selected"
+            data-name="Verified"
+            data-value="+1"
+            role="radio"
+            tabindex="0"
+            title="good"
+            data-vote="max"
+            votechip=""
+            flatten=""
+          >
+            <gr-tooltip-content light-tooltip="" has-tooltip="" title="good">
+              +1
+            </gr-tooltip-content>
+          </gr-button>
+        </iron-selector>
+        <span class="placeholder" data-label="Verified"></span>
+      </div>
+      <div class="selectedValueCell ">
+        <span id="selectedValueLabel">good</span>
+      </div>
+    `);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index a496be5..dd2a83e 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,29 +18,29 @@
 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} from '../gr-label-score-row/gr-label-score-row';
+import {getAppContext} from '../../../services/app-context';
 import {
-  GrLabelScoreRow,
+  getTriggerVotes,
+  showNewSubmitRequirements,
+  computeLabels,
   Label,
-  LabelValuesMap,
-} from '../gr-label-score-row/gr-label-score-row';
-import {appContext} from '../../../services/app-context';
-import {labelCompare} from '../../../utils/label-util';
-import {Execution} from '../../../constants/reporting';
+  computeOrderedLabelValues,
+  getDefaultValue,
+} from '../../../utils/label-util';
 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;
@@ -48,50 +48,132 @@
   @property({type: Object})
   account?: AccountInfo;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly flagsService = getAppContext().flagsService;
 
   static override get styles() {
     return [
+      fontStyles,
       css`
         .scoresTable {
           display: table;
           width: 100%;
         }
+        .scoresTable.newSubmitRequirements {
+          table-layout: fixed;
+        }
         .mergedMessage,
         .abandonedMessage {
           font-style: italic;
           text-align: center;
           width: 100%;
         }
+        .permissionMessage {
+          width: 100%;
+          color: var(--deemphasized-text-color);
+          padding-left: var(--spacing-xl);
+        }
         gr-label-score-row:hover {
           background-color: var(--hover-background-color);
         }
         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);
+          margin-top: var(--spacing-l);
+        }
+        .heading-3:first-of-type {
+          margin-top: 0;
         }
       `,
     ];
   }
 
   override render() {
-    const labels = this._computeLabels();
-    const labelValues = this._computeColumns();
-    return html`<div class="scoresTable">
-        ${labels.map(
+    if (showNewSubmitRequirements(this.flagsService, this.change)) {
+      return this.renderNewSubmitRequirements();
+    } else {
+      return this.renderOldSubmitRequirements();
+    }
+  }
+
+  private renderOldSubmitRequirements() {
+    const labels = computeLabels(this.account, this.change);
+    return html`${this.renderLabels(labels)}${this.renderErrorMessages()}`;
+  }
+
+  private renderNewSubmitRequirements() {
+    return html`${this.renderSubmitReqsLabels()}${this.renderTriggerVotes()}
+    ${this.renderErrorMessages()}`;
+  }
+
+  private renderSubmitReqsLabels() {
+    const triggerVotes = getTriggerVotes(this.change);
+    const labels = computeLabels(this.account, this.change).filter(
+      label => !triggerVotes.includes(label.name)
+    );
+    if (!labels.length) return;
+    if (
+      labels.filter(
+        label => !this.permittedLabels || this.permittedLabels[label.name]
+      ).length === 0
+    ) {
+      return html`<h3 class="heading-3">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>
+      ${this.renderLabels(labels)}`;
+  }
+
+  private renderTriggerVotes() {
+    const triggerVotes = getTriggerVotes(this.change);
+    const labels = computeLabels(this.account, this.change).filter(label =>
+      triggerVotes.includes(label.name)
+    );
+    if (!labels.length) return;
+    if (
+      labels.filter(
+        label => !this.permittedLabels || this.permittedLabels[label.name]
+      ).length === 0
+    ) {
+      return html`<h3 class="heading-3">Trigger Votes</h3>
+        <div class="permissionMessage">You don't have permission to vote</div>`;
+    }
+    return html`<h3 class="heading-3">Trigger Votes</h3>
+      ${this.renderLabels(labels)}`;
+  }
+
+  private renderLabels(labels: Label[]) {
+    const newSubReqs = showNewSubmitRequirements(
+      this.flagsService,
+      this.change
+    );
+    return html`<div
+      class="scoresTable ${newSubReqs ? 'newSubmitRequirements' : ''}"
+    >
+      ${labels
+        .filter(
+          label =>
+            this.permittedLabels?.[label.name] &&
+            this.permittedLabels?.[label.name].length > 0
+        )
+        .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}"
+            .label=${label}
+            .name=${label.name}
+            .labels=${this.change?.labels}
+            .permittedLabels=${this.permittedLabels}
+            .orderedLabelValues=${computeOrderedLabelValues(
+              this.permittedLabels
+            )}
           ></gr-label-score-row>`
         )}
-      </div>
-      <div
+    </div>`;
+  }
+
+  private renderErrorMessages() {
+    return html`<div
         class="mergedMessage"
         ?hidden=${this.change?.status !== ChangeStatus.MERGED}
       >
@@ -105,15 +187,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 =
@@ -123,106 +205,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 f529464..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');
 
@@ -82,7 +83,7 @@
       'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
       Verified: ['-1', ' 0', '+1'],
     };
-    await flush();
+    await element.updateComplete;
   });
 
   test('get and set label scores', async () => {
@@ -93,7 +94,7 @@
       );
       row.setSelectedValue('-1');
     }
-    await flush();
+    await element.updateComplete;
     assert.deepEqual(element.getLabelValues(), {
       'Code-Review': -1,
       Verified: -1,
@@ -110,83 +111,20 @@
         },
       },
     };
-    await flush();
+    await element.updateComplete;
 
     assert.deepEqual(element.getLabelValues(true), {'Code-Review': 0});
     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
new file mode 100644
index 0000000..4bf9d10
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
@@ -0,0 +1,200 @@
+/**
+ * @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-trigger-vote/gr-trigger-vote';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {ChangeInfo} from '../../../api/rest-api';
+import {
+  ChangeMessage,
+  LabelExtreme,
+  PATCH_SET_PREFIX_PATTERN,
+} 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)';
+
+interface Score {
+  label?: string;
+  value?: string;
+}
+
+export const LABEL_TITLE_SCORE_PATTERN =
+  /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.:]?$/;
+
+@customElement('gr-message-scores')
+export class GrMessageScores extends LitElement {
+  @property()
+  labelExtremes?: LabelExtreme;
+
+  @property({type: Object})
+  message?: ChangeMessage;
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  static override get styles() {
+    return css`
+      .score,
+      gr-trigger-vote {
+        padding: 0 var(--spacing-s);
+        margin-right: var(--spacing-s);
+        display: inline-block;
+      }
+      .score {
+        box-sizing: border-box;
+        border-radius: var(--border-radius);
+        color: var(--vote-text-color);
+        text-align: center;
+        min-width: 115px;
+      }
+      .score.removed {
+        background-color: var(--vote-color-neutral);
+      }
+      .score.negative {
+        background-color: var(--vote-color-disliked);
+        border: 1px solid var(--vote-outline-disliked);
+        line-height: calc(var(--line-height-normal) - 2px);
+        color: var(--chip-color);
+      }
+      .score.negative.min {
+        background-color: var(--vote-color-rejected);
+        border: none;
+        padding-top: 1px;
+        padding-bottom: 1px;
+        color: var(--vote-text-color);
+      }
+      .score.positive {
+        background-color: var(--vote-color-recommended);
+        border: 1px solid var(--vote-outline-recommended);
+        line-height: calc(var(--line-height-normal) - 2px);
+        color: var(--chip-color);
+      }
+      .score.positive.max {
+        background-color: var(--vote-color-approved);
+        border: none;
+        padding-top: 1px;
+        padding-bottom: 1px;
+        color: var(--vote-text-color);
+      }
+
+      @media screen and (max-width: 50em) {
+        .score {
+          min-width: 0px;
+        }
+      }
+    `;
+  }
+
+  private readonly flagsService = getAppContext().flagsService;
+
+  override render() {
+    const scores = this._getScores(this.message, this.labelExtremes);
+    const triggerVotes = getTriggerVotes(this.change);
+    return scores.map(score => this.renderScore(score, triggerVotes));
+  }
+
+  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}
+        .displayValue=${score.value}
+        .labelInfo=${labels[score.label]}
+        .change=${this.change}
+        .mutable=${false}
+        disable-hovercards
+      >
+      </gr-trigger-vote>`;
+    }
+    return html`<span
+      class="score ${this._computeScoreClass(score, this.labelExtremes)}"
+    >
+      ${score.label} ${score.value}
+    </span>`;
+  }
+
+  _computeScoreClass(score?: Score, labelExtremes?: LabelExtreme) {
+    // Polymer 2: check for undefined
+    if (score === undefined || labelExtremes === undefined) {
+      return '';
+    }
+    if (!score.value) {
+      return '';
+    }
+    if (score.value.includes(VOTE_RESET_TEXT)) {
+      return 'removed';
+    }
+    const classes = [];
+    if (Number(score.value) > 0) {
+      classes.push('positive');
+    } else if (Number(score.value) < 0) {
+      classes.push('negative');
+    }
+    if (score.label) {
+      const extremes = labelExtremes[score.label];
+      if (extremes) {
+        const intScore = Number(score.value);
+        if (intScore === extremes.max) {
+          classes.push('max');
+        } else if (intScore === extremes.min) {
+          classes.push('min');
+        }
+      }
+    }
+    return classes.join(' ');
+  }
+
+  _getScores(message?: ChangeMessage, labelExtremes?: LabelExtreme): Score[] {
+    if (!message || !message.message || !labelExtremes) {
+      return [];
+    }
+    const line = message.message.split('\n', 1)[0];
+    const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
+    if (!line.match(patchSetPrefix)) {
+      return [];
+    }
+    const scoresRaw = line.split(patchSetPrefix)[1];
+    if (!scoresRaw) {
+      return [];
+    }
+    return scoresRaw
+      .split(' ')
+      .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
+      .filter(
+        ms => ms && ms.length === 4 && hasOwnProperty(labelExtremes, ms[2])
+      )
+      .map(ms => {
+        const label = ms?.[2];
+        const value = ms?.[1] === '-' ? VOTE_RESET_TEXT : ms?.[3];
+        return {label, value};
+      });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-message-scores': GrMessageScores;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores_test.ts b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores_test.ts
new file mode 100644
index 0000000..74d1114
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores_test.ts
@@ -0,0 +1,179 @@
+/**
+ * @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 './gr-message-scores';
+import {
+  createChange,
+  createChangeMessage,
+  createDetailedLabelInfo,
+} from '../../../test/test-data-generators';
+import {queryAll, stubFlags} from '../../../test/test-utils';
+import {GrMessageScores} from './gr-message-scores';
+
+const basicFixture = fixtureFromElement('gr-message-scores');
+
+suite('gr-message-score tests', () => {
+  let element: GrMessageScores;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  test('votes', async () => {
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
+    };
+    element.labelExtremes = {
+      Verified: {max: 1, min: -1},
+      'Code-Review': {max: 2, min: -2},
+      'Trybot-Label3': {max: 3, min: 0},
+    };
+    await element.updateComplete;
+    const scoreChips = queryAll(element, '.score');
+    assert.equal(scoreChips.length, 3);
+
+    assert.isTrue(scoreChips[0].classList.contains('positive'));
+    assert.isTrue(scoreChips[0].classList.contains('max'));
+
+    assert.isTrue(scoreChips[1].classList.contains('negative'));
+    assert.isTrue(scoreChips[1].classList.contains('min'));
+
+    assert.isTrue(scoreChips[2].classList.contains('positive'));
+    assert.isFalse(scoreChips[2].classList.contains('min'));
+  });
+
+  test('Uploaded patch set X', async () => {
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message:
+        'Uploaded patch set 1:' +
+        'Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
+    };
+    element.labelExtremes = {
+      Verified: {max: 1, min: -1},
+      'Code-Review': {max: 2, min: -2},
+      'Trybot-Label3': {max: 3, min: 0},
+    };
+    await element.updateComplete;
+    const scoreChips = queryAll(element, '.score');
+    assert.equal(scoreChips.length, 3);
+
+    assert.isTrue(scoreChips[0].classList.contains('positive'));
+    assert.isTrue(scoreChips[0].classList.contains('max'));
+
+    assert.isTrue(scoreChips[1].classList.contains('negative'));
+    assert.isTrue(scoreChips[1].classList.contains('min'));
+
+    assert.isTrue(scoreChips[2].classList.contains('positive'));
+    assert.isFalse(scoreChips[2].classList.contains('min'));
+  });
+
+  test('Uploaded and rebased', async () => {
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message: 'Uploaded patch set 4: Commit-Queue+1: Patch Set 3 was rebased.',
+    };
+    element.labelExtremes = {
+      'Commit-Queue': {max: 2, min: -2},
+    };
+    await element.updateComplete;
+    const scoreChips = queryAll(element, '.score');
+    assert.equal(scoreChips.length, 1);
+    assert.isTrue(scoreChips[0].classList.contains('positive'));
+  });
+
+  test('removed votes', async () => {
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message: 'Patch Set 1: Verified+1 -Code-Review -Commit-Queue',
+    };
+    element.labelExtremes = {
+      Verified: {max: 1, min: -1},
+      'Code-Review': {max: 2, min: -2},
+      'Commit-Queue': {max: 3, min: 0},
+    };
+    await element.updateComplete;
+    const scoreChips = queryAll(element, '.score');
+    assert.equal(scoreChips.length, 3);
+
+    assert.isTrue(scoreChips[1].classList.contains('removed'));
+    assert.isTrue(scoreChips[2].classList.contains('removed'));
+  });
+
+  test('false negative vote', async () => {
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
+    };
+    element.labelExtremes = {};
+    await element.updateComplete;
+    const scoreChips = element.shadowRoot?.querySelectorAll('.score');
+    assert.equal(scoreChips?.length, 0);
+  });
+
+  test('reset vote', async () => {
+    stubFlags('isEnabled').returns(true);
+    element = basicFixture.instantiate();
+    element.change = {
+      ...createChange(),
+      labels: {
+        'Commit-Queue': createDetailedLabelInfo(),
+        'Auto-Submit': createDetailedLabelInfo(),
+      },
+    };
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message: 'Patch Set 10: Auto-Submit+1 -Commit-Queue',
+    };
+    element.labelExtremes = {
+      'Commit-Queue': {max: 2, min: 0},
+      'Auto-Submit': {max: 1, min: 0},
+    };
+    await element.updateComplete;
+    const triggerChips =
+      element.shadowRoot?.querySelectorAll('gr-trigger-vote');
+    assert.equal(triggerChips?.length, 1);
+    const triggerChip = triggerChips?.[0];
+    expect(triggerChip).shadowDom.equal(`<div class="container">
+      <span class="label">Auto-Submit</span>
+      <gr-vote-chip></gr-vote-chip>
+    </div>`);
+    const voteChips = triggerChip?.shadowRoot?.querySelectorAll('gr-vote-chip');
+    assert.equal(voteChips?.length, 1);
+    expect(voteChips?.[0]).shadowDom.equal('');
+    const scoreChips = element.shadowRoot?.querySelectorAll('.score');
+    assert.equal(scoreChips?.length, 1);
+    expect(scoreChips?.[0]).dom.equal(`<span class="removed score">
+    Commit-Queue 0 (vote reset)
+    </span>`);
+  });
+});
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 904b742..f9afd8c 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -21,19 +21,17 @@
 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 {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-message_html';
+import '../gr-message-scores/gr-message-scores';
+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 {hasOwnProperty} from '../../../utils/common-util';
 import {
   ChangeInfo,
-  ChangeMessageInfo,
   ServerInfo,
   ConfigInfo,
   RepoName,
   ReviewInputTag,
-  VotingRangeInfo,
   NumericChangeId,
   ChangeMessageId,
   PatchSetNum,
@@ -41,9 +39,15 @@
   BasePatchSetNum,
   LabelNameToInfoMap,
 } from '../../../types/common';
-import {CommentThread} from '../../../utils/comment-util';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {appContext} from '../../../services/app-context';
+import {
+  ChangeMessage,
+  CommentThread,
+  isFormattedReviewerUpdate,
+  LabelExtreme,
+  PATCH_SET_PREFIX_PATTERN,
+} from '../../../utils/comment-util';
+import {LABEL_TITLE_SCORE_PATTERN} from '../gr-message-scores/gr-message-scores';
+import {getAppContext} from '../../../services/app-context';
 import {pluralize} from '../../../utils/string-util';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
@@ -52,13 +56,12 @@
   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 PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
-const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.]?$/;
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
-const VOTE_RESET_TEXT = '0 (vote reset)';
-
 declare global {
   interface HTMLElementTagNameMap {
     'gr-message': GrMessage;
@@ -69,26 +72,8 @@
   id: ChangeMessageId;
 }
 
-export interface ChangeMessage extends ChangeMessageInfo {
-  // TODO(TS): maybe should be an enum instead
-  type: string;
-  expanded: boolean;
-  commentThreads: CommentThread[];
-}
-
-export type LabelExtreme = {[labelName: string]: VotingRangeInfo};
-
-interface Score {
-  label?: string;
-  value?: string;
-}
-
 @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.
    *
@@ -114,12 +99,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;
   }
@@ -130,31 +114,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
@@ -163,53 +124,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;
+  @state()
+  private 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,' +
-      ' change.labels)',
-  })
-  _messageContentExpanded = '';
-
-  @property({
-    type: String,
-    computed:
-      '_computeMessageContentCollapsed(message.message,' +
-      ' message.accounts_in_message,' +
-      ' message.tag,' +
-      ' commentThreads,' +
-      ' change.labels)',
-  })
-  _messageContentCollapsed = '';
-
-  @property({
-    type: String,
-    computed: '_computeCommentCountText(commentThreads)',
-  })
-  _commentCountText = '';
-
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this.addEventListener('click', e => this._handleClick(e));
+    this.addEventListener('click', e => this.handleClick(e));
   }
 
   override connectedCallback() {
@@ -218,51 +149,382 @@
       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.change?.labels
+      ) || 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,
+      this.change?.labels
+    );
+    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,
-    labels?: LabelNameToInfoMap
-  ) {
-    if (!expanded) return '';
-    return this._computeMessageContent(
-      true,
-      content,
-      accountsInMessage,
-      tag,
-      labels
-    );
-  }
-
-  _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) {
@@ -283,47 +545,29 @@
     return '';
   }
 
-  _computeMessageContentCollapsed(
-    content?: string,
-    accountsInMessage?: AccountInfo[],
-    tag?: ReviewInputTag,
-    commentThreads?: CommentThread[],
-    labels?: LabelNameToInfoMap
-  ) {
-    // Content is under text-overflow, so it's always shorten
-    const shortenedContent = content?.substring(0, 1000);
-    const summary = this._computeMessageContent(
-      false,
-      shortenedContent,
-      accountsInMessage,
-      tag,
-      labels
-    );
-    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;
@@ -345,12 +589,13 @@
       patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
       basePatchNum = computePredecessor(patchNum)!;
     }
-    GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
+    GerritNav.navigateToChange(this.change, {patchNum, basePatchNum});
     // stop propagation to stop message expansion
     e.stopPropagation();
   }
 
-  _computeMessageContent(
+  // private but used in tests
+  computeMessageContent(
     isExpanded: boolean,
     content?: string,
     accountsInMessage?: AccountInfo[],
@@ -358,7 +603,7 @@
     labels?: LabelNameToInfoMap
   ) {
     if (!content) return '';
-    const isNewPatchSet = this._isNewPatchsetTag(tag);
+    const isNewPatchSet = this.isNewPatchsetTag(tag);
 
     if (accountsInMessage) {
       content = replaceTemplates(content, accountsInMessage, this.config);
@@ -415,131 +660,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';
-  }
-
-  _getScores(message?: ChangeMessage, labelExtremes?: LabelExtreme): Score[] {
-    if (!message || !message.message || !labelExtremes) {
-      return [];
-    }
-    const line = message.message.split('\n', 1)[0];
-    const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
-    if (!line.match(patchSetPrefix)) {
-      return [];
-    }
-    const scoresRaw = line.split(patchSetPrefix)[1];
-    if (!scoresRaw) {
-      return [];
-    }
-    return scoresRaw
-      .split(' ')
-      .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
-      .filter(
-        ms => ms && ms.length === 4 && hasOwnProperty(labelExtremes, ms[2])
-      )
-      .map(ms => {
-        const label = ms?.[2];
-        const value = ms?.[1] === '-' ? VOTE_RESET_TEXT : ms?.[3];
-        return {label, value};
-      });
-  }
-
-  _computeScoreClass(score?: Score, labelExtremes?: LabelExtreme) {
-    // Polymer 2: check for undefined
-    if (score === undefined || labelExtremes === undefined) {
-      return '';
-    }
-    if (!score.value) {
-      return '';
-    }
-    if (score.value.includes(VOTE_RESET_TEXT)) {
-      return 'removed';
-    }
-    const classes = [];
-    if (Number(score.value) > 0) {
-      classes.push('positive');
-    } else if (Number(score.value) < 0) {
-      classes.push('negative');
-    }
-    if (score.label) {
-      const extremes = labelExtremes[score.label];
-      if (extremes) {
-        const intScore = Number(score.value);
-        if (intScore === extremes.max) {
-          classes.push('max');
-        } else if (intScore === extremes.min) {
-          classes.push('min');
-        }
-      }
-    }
-    return classes.join(' ');
-  }
-
-  _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,
@@ -553,7 +735,7 @@
     );
   }
 
-  _handleReplyTap(e: Event) {
+  private handleReplyTap(e: Event) {
     e.preventDefault();
     this.dispatchEvent(
       new CustomEvent('reply', {
@@ -564,14 +746,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},
@@ -582,19 +764,25 @@
       });
   }
 
-  @observe('projectName')
-  _projectNameChanged(name: string) {
-    this.restApiService.getProjectConfig(name as RepoName).then(config => {
-      this._projectConfig = config;
+  private projectNameChanged() {
+    if (!this.projectName) {
+      this.projectConfig = undefined;
+      return;
+    }
+    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 7f3e9de..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ /dev/null
@@ -1,352 +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;
-    }
-    .score {
-      box-sizing: border-box;
-      border-radius: var(--border-radius);
-      color: var(--vote-text-color);
-      display: inline-block;
-      padding: 0 var(--spacing-s);
-      text-align: center;
-    }
-    .score,
-    .commentsSummary {
-      margin-right: var(--spacing-s);
-      min-width: 115px;
-    }
-    .expanded .commentsSummary {
-      display: none;
-    }
-    .commentsIcon {
-      vertical-align: top;
-    }
-    .score.removed {
-      background-color: var(--vote-color-neutral);
-    }
-    .score.negative {
-      background-color: var(--vote-color-disliked);
-      border: 1px solid var(--vote-outline-disliked);
-      line-height: calc(var(--line-height-normal) - 2px);
-      color: var(--chip-color);
-    }
-    .score.negative.min {
-      background-color: var(--vote-color-rejected);
-      border: none;
-      padding-top: 1px;
-      padding-bottom: 1px;
-      color: var(--vote-text-color);
-    }
-    .score.positive {
-      background-color: var(--vote-color-recommended);
-      border: 1px solid var(--vote-outline-recommended);
-      line-height: calc(var(--line-height-normal) - 2px);
-      color: var(--chip-color);
-    }
-    .score.positive.max {
-      background-color: var(--vote-color-approved);
-      border: none;
-      padding-top: 1px;
-      padding-bottom: 1px;
-      color: var(--vote-text-color);
-    }
-    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;
-      }
-      .score,
-      .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>
-        <template
-          is="dom-repeat"
-          items="[[_getScores(message, labelExtremes)]]"
-          as="score"
-        >
-          <span class$="score [[_computeScoreClass(score, labelExtremes)]]">
-            [[score.label]] [[score.value]]
-          </span>
-        </template>
-      </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
-              change="[[change]]"
-              hidden$="[[!commentThreads.length]]"
-              threads="[[commentThreads]]"
-              change-num="[[changeNum]]"
-              logged-in="[[_loggedIn]]"
-              hide-dropdown
-              show-comment-context
-            >
-            </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 9f9b792..6e550ac 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
@@ -29,7 +29,6 @@
 import {
   mockPromise,
   query,
-  queryAll,
   queryAndAssert,
   stubRestApi,
 } from '../../../test/test-utils';
@@ -53,8 +52,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;
@@ -62,8 +61,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 () => {
@@ -87,9 +85,7 @@
         promise.resolve();
       });
       await flush();
-      assert.isFalse(
-        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
-      );
+      assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
       tap(queryAndAssert(element, '.replyBtn'));
       await promise;
     });
@@ -108,9 +104,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 () => {
@@ -128,96 +124,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', () => {
@@ -226,42 +332,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());
     });
 
-    ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'].forEach(label => {
-      test(`${label} ignored for color voting`, () => {
-        element.message = {
-          ...createChangeMessage(),
-          author: {},
-          expanded: false,
-          message: `Patch Set 1: ${label}+1`,
-        };
-        assert.isNotOk(query(element, '.negativeVote'));
-        assert.isNotOk(query(element, '.positiveVote'));
-      });
-    });
-
-    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');
@@ -269,7 +365,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', () => {
@@ -284,13 +380,12 @@
           ...createChangeMessage(),
           message: 'Uploaded patch set 1.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
-          navStub.calledWithExactly(
-            element.change!,
-            1 as PatchSetNum,
-            'PARENT' as BasePatchSetNum
-          )
+          navStub.calledWithExactly(element.change!, {
+            patchNum: 1 as PatchSetNum,
+            basePatchNum: 'PARENT' as BasePatchSetNum,
+          })
         );
       });
 
@@ -299,26 +394,24 @@
           ...createChangeMessage(),
           message: 'Uploaded patch set 2.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
-          navStub.calledWithExactly(
-            element.change!,
-            2 as PatchSetNum,
-            1 as BasePatchSetNum
-          )
+          navStub.calledWithExactly(element.change!, {
+            patchNum: 2 as PatchSetNum,
+            basePatchNum: 1 as BasePatchSetNum,
+          })
         );
 
         element.message = {
           ...createChangeMessage(),
           message: 'Uploaded patch set 200.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
-          navStub.calledWithExactly(
-            element.change!,
-            200 as PatchSetNum,
-            199 as BasePatchSetNum
-          )
+          navStub.calledWithExactly(element.change!, {
+            patchNum: 200 as PatchSetNum,
+            basePatchNum: 199 as BasePatchSetNum,
+          })
         );
       });
 
@@ -327,13 +420,12 @@
           ...createChangeMessage(),
           message: 'Commit message updated.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
-          navStub.calledWithExactly(
-            element.change!,
-            4 as PatchSetNum,
-            3 as BasePatchSetNum
-          )
+          navStub.calledWithExactly(element.change!, {
+            patchNum: 4 as PatchSetNum,
+            basePatchNum: 3 as BasePatchSetNum,
+          })
         );
       });
 
@@ -342,13 +434,12 @@
           ...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!,
-            4 as PatchSetNum,
-            3 as BasePatchSetNum
-          )
+          navStub.calledWithExactly(element.change!, {
+            patchNum: 4 as PatchSetNum,
+            basePatchNum: 3 as BasePatchSetNum,
+          })
         );
       });
     });
@@ -360,7 +451,7 @@
       };
       test('empty', () => {
         assert.equal(
-          element._computeMessageContent(
+          element.computeMessageContent(
             true,
             '',
             undefined,
@@ -370,7 +461,7 @@
           ''
         );
         assert.equal(
-          element._computeMessageContent(
+          element.computeMessageContent(
             false,
             '',
             undefined,
@@ -384,13 +475,13 @@
       test('new patchset', () => {
         const original = 'Uploaded patch set 1.';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, [], tag, [], labels)
+          element.computeMessageContent(true, original, [], tag, labels)
         );
         assert.equal(actual, original);
-        actual = element._computeMessageContent(
+        actual = element.computeMessageContent(
           false,
           original,
           [],
@@ -404,7 +495,7 @@
         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(
+        let actual = element.computeMessageContent(
           true,
           original,
           [],
@@ -414,9 +505,9 @@
         assert.equal(actual, expected);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, [], tag, [])
+          element.computeMessageContent(true, original, [], tag)
         );
-        actual = element._computeMessageContent(
+        actual = element.computeMessageContent(
           false,
           original,
           [],
@@ -430,7 +521,7 @@
         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(
+        let actual = element.computeMessageContent(
           true,
           original,
           [],
@@ -440,9 +531,9 @@
         assert.equal(actual, expected);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, [], tag, [])
+          element.computeMessageContent(true, original, [], tag)
         );
-        actual = element._computeMessageContent(
+        actual = element.computeMessageContent(
           false,
           original,
           [],
@@ -455,7 +546,7 @@
         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(
+        let actual = element.computeMessageContent(
           true,
           original,
           [],
@@ -463,7 +554,7 @@
           labels
         );
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(
+        actual = element.computeMessageContent(
           false,
           original,
           [],
@@ -476,7 +567,7 @@
         const original = 'Patch Set 1: Code-Style+1';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(
+        let actual = element.computeMessageContent(
           true,
           original,
           [],
@@ -484,7 +575,7 @@
           labels
         );
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(
+        actual = element.computeMessageContent(
           false,
           original,
           [],
@@ -498,7 +589,7 @@
         const original = 'Patch Set 1: Legacy Message';
         const tag = undefined;
         const expected = 'Legacy Message';
-        let actual = element._computeMessageContent(
+        let actual = element.computeMessageContent(
           true,
           original,
           [],
@@ -506,7 +597,7 @@
           labels
         );
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(
+        actual = element.computeMessageContent(
           false,
           original,
           [],
@@ -520,7 +611,7 @@
         const original = 'Patch Set 1:\n\n(3 comments)';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(
+        let actual = element.computeMessageContent(
           true,
           original,
           [],
@@ -528,7 +619,7 @@
           labels
         );
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(
+        actual = element.computeMessageContent(
           false,
           original,
           [],
@@ -548,7 +639,7 @@
           createAccountWithIdNameAndEmail(1),
           createAccountWithIdNameAndEmail(2),
         ];
-        let actual = element._computeMessageContent(
+        let actual = element.computeMessageContent(
           true,
           original,
           accountsInMessage,
@@ -556,7 +647,7 @@
           labels
         );
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(
+        actual = element.computeMessageContent(
           false,
           original,
           accountsInMessage,
@@ -572,7 +663,7 @@
         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(
+        let actual = element.computeMessageContent(
           true,
           original,
           [],
@@ -580,7 +671,7 @@
           labels
         );
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(
+        actual = element.computeMessageContent(
           false,
           original,
           [],
@@ -590,103 +681,16 @@
         assert.equal(actual, expected);
       });
     });
-
-    test('votes', () => {
-      element.message = {
-        ...createChangeMessage(),
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
-      };
-      element.labelExtremes = {
-        Verified: {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Trybot-Label3': {max: 3, min: 0},
-      };
-      flush();
-      const scoreChips = queryAll(element, '.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[0].classList.contains('positive'));
-      assert.isTrue(scoreChips[0].classList.contains('max'));
-
-      assert.isTrue(scoreChips[1].classList.contains('negative'));
-      assert.isTrue(scoreChips[1].classList.contains('min'));
-
-      assert.isTrue(scoreChips[2].classList.contains('positive'));
-      assert.isFalse(scoreChips[2].classList.contains('min'));
-    });
-
-    test('Uploaded patch set X', () => {
-      element.message = {
-        ...createChangeMessage(),
-        author: {},
-        expanded: false,
-        message:
-          'Uploaded patch set 1:' +
-          'Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
-      };
-      element.labelExtremes = {
-        Verified: {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Trybot-Label3': {max: 3, min: 0},
-      };
-      flush();
-      const scoreChips = queryAll(element, '.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[0].classList.contains('positive'));
-      assert.isTrue(scoreChips[0].classList.contains('max'));
-
-      assert.isTrue(scoreChips[1].classList.contains('negative'));
-      assert.isTrue(scoreChips[1].classList.contains('min'));
-
-      assert.isTrue(scoreChips[2].classList.contains('positive'));
-      assert.isFalse(scoreChips[2].classList.contains('min'));
-    });
-
-    test('removed votes', () => {
-      element.message = {
-        ...createChangeMessage(),
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Verified+1 -Code-Review -Commit-Queue',
-      };
-      element.labelExtremes = {
-        Verified: {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Commit-Queue': {max: 3, min: 0},
-      };
-      flush();
-      const scoreChips = queryAll(element, '.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[1].classList.contains('removed'));
-      assert.isTrue(scoreChips[2].classList.contains('removed'));
-    });
-
-    test('false negative vote', () => {
-      element.message = {
-        ...createChangeMessage(),
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
-      };
-      element.labelExtremes = {};
-      const scoreChips = element.root!.querySelectorAll('.score');
-      assert.equal(scoreChips.length, 0);
-    });
   });
 
   suite('when not logged in', () => {
     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,
@@ -701,25 +705,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: [
             {
@@ -732,7 +735,6 @@
               message: 'testing the load',
               unresolved: false,
               path: '/PATCHSET_LEVEL',
-              collapsed: false,
             },
           ],
           patchNum: 1 as PatchSetNum,
@@ -741,23 +743,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: [
             {
@@ -768,7 +762,6 @@
               message: 'testing the load',
               unresolved: false,
               path: '/PATCHSET_LEVEL',
-              collapsed: false,
             },
             {
               change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
@@ -780,7 +773,6 @@
               unresolved: false,
               path: '/PATCHSET_LEVEL',
               __draft: true,
-              collapsed: true,
             },
           ],
           patchNum: 1 as PatchSetNum,
@@ -789,17 +781,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),
         ''
       );
     });
@@ -808,11 +792,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,
@@ -826,17 +809,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.
@@ -855,10 +837,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 e16c073..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
@@ -14,12 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {Subscription} from 'rxjs';
 import '@polymer/paper-toggle-button/paper-toggle-button';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
 import '../gr-message/gr-message';
+import '../../../styles/gr-paper-styles';
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-messages-list_html';
 import {
   Shortcut,
@@ -27,13 +28,12 @@
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {parseDate} from '../../../utils/date-util';
 import {MessageTag} from '../../../constants/constants';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {customElement, property} from '@polymer/decorators';
 import {
   ChangeId,
   ChangeMessageId,
   ChangeMessageInfo,
-  ChangeViewChangeInfo,
   LabelNameToInfoMap,
   NumericChangeId,
   PatchSetNum,
@@ -41,13 +41,23 @@
   ReviewerUpdateInfo,
   VotingRangeInfo,
 } from '../../../types/common';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
-import {CommentThread, isRobot} from '../../../utils/comment-util';
+import {
+  CommentThread,
+  isRobot,
+  LabelExtreme,
+} from '../../../utils/comment-util';
 import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat';
 import {getVotingRange} from '../../../utils/label-util';
-import {FormattedReviewerUpdateInfo} from '../../../types/types';
+import {
+  FormattedReviewerUpdateInfo,
+  ParsedChangeInfo,
+} from '../../../types/types';
+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.
@@ -62,7 +72,7 @@
   all: number;
 }
 
-type CombinedMessage = Omit<
+export type CombinedMessage = Omit<
   FormattedReviewerUpdateInfo | ChangeMessageInfo,
   'tag'
 > & {
@@ -91,17 +101,10 @@
   message: CombinedMessage,
   allThreadsForChange: CommentThread[]
 ): CommentThread[] {
-  if (message._index === undefined) {
-    return [];
-  }
+  if (message._index === undefined) return [];
   const messageId = getMessageId(message);
   return allThreadsForChange.filter(thread =>
-    thread.comments.some(comment => {
-      const matchesMessage = comment.change_message_id === messageId;
-      if (!matchesMessage) return false;
-      comment.collapsed = !matchesMessage;
-      return matchesMessage;
-    })
+    thread.comments.some(comment => comment.change_message_id === messageId)
   );
 }
 
@@ -133,9 +136,6 @@
   if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) {
     return MessageTag.TAG_NEW_PATCHSET;
   }
-  if (message.tag === MessageTag.TAG_UNSET_ASSIGNEE) {
-    return MessageTag.TAG_SET_ASSIGNEE;
-  }
   if (message.tag === MessageTag.TAG_UNSET_PRIVATE) {
     return MessageTag.TAG_SET_PRIVATE;
   }
@@ -201,14 +201,16 @@
 }
 
 @customElement('gr-messages-list')
-export class GrMessagesList extends PolymerElement {
+export class GrMessagesList extends DIPolymerElement {
   static get template() {
     return htmlTemplate;
   }
 
+  // Private internal @state, derived from the application state.
   @property({type: Object})
-  change?: ChangeViewChangeInfo;
+  change?: ParsedChangeInfo;
 
+  // Private internal @state, derived from the application state.
   @property({type: String})
   changeNum?: ChangeId | NumericChangeId;
 
@@ -218,12 +220,15 @@
   @property({type: Array})
   reviewerUpdates: ReviewerUpdateInfo[] = [];
 
+  // Private internal @state, derived from the application state.
   @property({type: Object})
-  changeComments?: ChangeComments;
+  commentThreads: CommentThread[] = [];
 
+  // Private internal @state, derived from the application state.
   @property({type: String})
   projectName?: RepoName;
 
+  // Private internal @state, derived from the application state.
   @property({type: Boolean})
   showReplyButtons = false;
 
@@ -243,19 +248,65 @@
     type: Array,
     computed:
       '_computeCombinedMessages(messages, reviewerUpdates, ' +
-      'changeComments)',
+      'commentThreads)',
     observer: '_combinedMessagesChanged',
   })
   _combinedMessages: CombinedMessage[] = [];
 
   @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
-  _labelExtremes: {[labelName: string]: VotingRangeInfo} = {};
+  _labelExtremes: LabelExtreme = {};
 
-  private readonly reporting = appContext.reportingService;
+  private readonly userModel = getAppContext().userModel;
 
-  private readonly shortcuts = appContext.shortcutsService;
+  // Private but used in tests.
+  readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  scrollToMessage(messageID: string) {
+  private readonly changeModel = resolve(this, changeModelToken);
+
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly shortcuts = getAppContext().shortcutsService;
+
+  private subscriptions: Subscription[] = [];
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.subscriptions.push(
+      this.getCommentsModel().threads$.subscribe(x => {
+        this.commentThreads = x;
+      })
+    );
+    this.subscriptions.push(
+      this.changeModel().change$.subscribe(x => {
+        this.change = x;
+      })
+    );
+    this.subscriptions.push(
+      this.userModel.loggedIn$.subscribe(x => {
+        this.showReplyButtons = x;
+      })
+    );
+    this.subscriptions.push(
+      this.changeModel().repo$.subscribe(x => {
+        this.projectName = x;
+      })
+    );
+    this.subscriptions.push(
+      this.changeModel().changeNum$.subscribe(x => {
+        this.changeNum = x;
+      })
+    );
+  }
+
+  override disconnectedCallback() {
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+    super.disconnectedCallback();
+  }
+
+  async scrollToMessage(messageID: string) {
     const selector = `[data-message-id="${messageID}"]`;
     const el = this.shadowRoot!.querySelector(selector) as
       | GrMessage
@@ -267,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;
@@ -304,16 +357,11 @@
    * all messages and updates, aligns or massages some of the properties.
    */
   _computeCombinedMessages(
-    messages?: ChangeMessageInfo[],
-    reviewerUpdates?: FormattedReviewerUpdateInfo[],
-    changeComments?: ChangeComments
+    messages: ChangeMessageInfo[] | undefined,
+    reviewerUpdates: FormattedReviewerUpdateInfo[] | undefined,
+    commentThreads: CommentThread[]
   ) {
-    if (
-      messages === undefined ||
-      reviewerUpdates === undefined ||
-      changeComments === undefined
-    )
-      return [];
+    if (messages === undefined || reviewerUpdates === undefined) return;
 
     let mi = 0;
     let ri = 0;
@@ -345,20 +393,12 @@
       }
     }
 
-    const allThreadsForChange = changeComments.getAllThreadsForChange();
-    // collapse all by default
-    for (const thread of allThreadsForChange) {
-      for (const comment of thread.comments) {
-        comment.collapsed = true;
-      }
-    }
-
     for (let i = 0; i < combinedMessages.length; i++) {
       const message = combinedMessages[i];
       if (message.expanded === undefined) {
         message.expanded = false;
       }
-      message.commentThreads = computeThreads(message, allThreadsForChange);
+      message.commentThreads = computeThreads(message, commentThreads);
       message._revision_number = computeRevision(message, combinedMessages);
       message.tag = computeTag(message);
     }
@@ -372,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_html.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
index 56fae87..087ee19 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
@@ -51,6 +51,9 @@
       border-bottom: 1px solid var(--border-color);
     }
   </style>
+  <style include="gr-paper-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <div class="header">
     <div id="showAllActivityToggleContainer" class="container">
       <template
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 0939daa..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ /dev/null
@@ -1,534 +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 '../../diff/gr-comment-api/gr-comment-api.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';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api.js';
-
-createCommentApiMockWithTemplateElement(
-    'gr-messages-list-comment-mock-api', html`
-     <gr-messages-list
-         id="messagesList"
-         change-comments="[[_changeComments]]"></gr-messages-list>
-     <gr-comment-api id="commentAPI"></gr-comment-api>
-`);
-
-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(() => {
-      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;
-      element.changeComments = new ChangeComments(comments);
-      element.messages = messages;
-      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.changeComments = new ChangeComments();
-      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..b9cb616
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -0,0 +1,620 @@
+/**
+ * @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 {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
+import {CombinedMessage, GrMessagesList, TEST_ONLY} from './gr-messages-list';
+import {MessageTag} from '../../../constants/constants';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {
+  query,
+  queryAll,
+  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';
+
+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 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[];
+
+  let commentApiWrapper: any;
+
+  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 must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = basicFixture.instantiate();
+      element = queryAndAssert<GrMessagesList>(
+        commentApiWrapper,
+        '#messagesList'
+      );
+      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[];
+
+    let commentApiWrapper: any;
+
+    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 must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = basicFixture.instantiate();
+      element = queryAndAssert<GrMessagesList>(
+        commentApiWrapper,
+        '#messagesList'
+      );
+      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 e4703df..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
@@ -35,6 +35,9 @@
   href?: string;
 
   @property()
+  label?: string;
+
+  @property()
   showSubmittableCheck = false;
 
   @property()
@@ -110,7 +113,12 @@
     const linkClass = this._computeLinkClass(change);
     return html`
       <div class="changeContainer">
-        <a href="${ifDefined(this.href)}" class="${linkClass}"><slot></slot></a>
+        <a
+          href=${ifDefined(this.href)}
+          aria-label=${ifDefined(this.label)}
+          class=${linkClass}
+          ><slot></slot
+        ></a>
         ${this.showSubmittableCheck
           ? html`<span
               tabindex="-1"
@@ -122,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 7493e2f..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
@@ -30,9 +30,10 @@
   PatchSetNum,
   CommitId,
 } from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {truncatePath} from '../../../utils/path-list-util';
 import {pluralize} from '../../../utils/string-util';
 import {
   changeIsOpen,
@@ -89,7 +90,7 @@
   @state()
   sameTopicChanges: ChangeInfo[] = [];
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
@@ -146,6 +147,45 @@
       this.conflictingChanges.length,
       this.cherryPickChanges.length
     );
+
+    const sectionRenderers = [
+      this.renderRelationChain,
+      this.renderSubmittedTogether,
+      this.renderSameTopic,
+      this.renderMergeConflicts,
+      this.renderCherryPicks,
+    ];
+
+    let firstNonEmptySectionFound = false;
+    const sections = [];
+    for (const renderer of sectionRenderers) {
+      const section: TemplateResult<1> | undefined = renderer.call(
+        this,
+        !firstNonEmptySectionFound,
+        sectionSize
+      );
+      firstNonEmptySectionFound = firstNonEmptySectionFound || !!section;
+      sections.push(section);
+    }
+
+    return html`<gr-endpoint-decorator name="related-changes-section">
+      <gr-endpoint-param
+        name="change"
+        .value=${this.change}
+      ></gr-endpoint-param>
+      <gr-endpoint-slot name="top"></gr-endpoint-slot>
+      ${sections}
+      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+    </gr-endpoint-decorator>`;
+  }
+
+  private renderRelationChain(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
+    if (this.relatedChanges.length === 0) {
+      return undefined;
+    }
     const relatedChangesMarkersPredicate = this.markersPredicateFactory(
       this.relatedChanges.length,
       this.relatedChanges.findIndex(relatedChange =>
@@ -158,41 +198,35 @@
       this.patchNum,
       this.relatedChanges
     );
-    let firstNonEmptySectionFound = false;
-    let isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.relatedChanges.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const relatedChangeSection = html` <section
-      id="relatedChanges"
-      ?hidden=${!this.relatedChanges.length}
-    >
+
+    return html`<section id="relatedChanges">
       <gr-related-collapse
         title="Relation chain"
-        class="${classMap({first: isFirstNonEmpty})}"
+        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
               >
@@ -200,8 +234,19 @@
         )}
       </gr-related-collapse>
     </section>`;
+  }
 
+  private renderSubmittedTogether(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
     const submittedTogetherChanges = this.submittedTogether?.changes ?? [];
+    if (
+      !submittedTogetherChanges.length &&
+      !this.submittedTogether?.non_visible_changes
+    ) {
+      return undefined;
+    }
     const countNonVisibleChanges =
       this.submittedTogether?.non_visible_changes ?? 0;
     const submittedTogetherMarkersPredicate = this.markersPredicateFactory(
@@ -211,42 +256,33 @@
       ),
       sectionSize(Section.SUBMITTED_TOGETHER)
     );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound &&
-      (!!submittedTogetherChanges?.length ||
-        !!this.submittedTogether?.non_visible_changes);
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const submittedTogetherSection = html`<section
-      id="submittedTogether"
-      ?hidden=${!submittedTogetherChanges?.length &&
-      !this.submittedTogether?.non_visible_changes}
-    >
+    return html`<section id="submittedTogether">
       <gr-related-collapse
         title="Submitted together"
-        class="${classMap({first: isFirstNonEmpty})}"
+        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
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
+                .label=${this.renderChangeTitle(change)}
+                .change=${change}
+                .href=${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
-                )}"
+                )}
                 .showSubmittableCheck=${true}
-                >${change.project}: ${change.branch}:
-                ${change.subject}</gr-related-change
+                >${this.renderChangeLine(change)}</gr-related-change
               >
             </div>`
         )}
@@ -255,144 +291,151 @@
         (+ ${pluralize(countNonVisibleChanges, 'non-visible change')})
       </div>
     </section>`;
+  }
+
+  private renderSameTopic(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
+    if (!this.sameTopicChanges?.length) {
+      return undefined;
+    }
 
     const sameTopicMarkersPredicate = this.markersPredicateFactory(
       this.sameTopicChanges.length,
       -1,
       sectionSize(Section.SAME_TOPIC)
     );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.sameTopicChanges?.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const sameTopicSection = html`<section
-      id="sameTopic"
-      ?hidden=${!this.sameTopicChanges?.length}
-    >
+    return html`<section id="sameTopic">
       <gr-related-collapse
         title="Same topic"
-        class="${classMap({first: isFirstNonEmpty})}"
+        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}"
-                .href="${GerritNav.getUrlForChangeById(
+                .change=${change}
+                .label=${this.renderChangeTitle(change)}
+                .href=${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
-                )}"
-                >${change.project}: ${change.branch}:
-                ${change.subject}</gr-related-change
+                )}
+                >${this.renderChangeLine(change)}</gr-related-change
               >
             </div>`
         )}
       </gr-related-collapse>
     </section>`;
+  }
 
+  private renderMergeConflicts(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
+    if (!this.conflictingChanges?.length) {
+      return undefined;
+    }
     const mergeConflictsMarkersPredicate = this.markersPredicateFactory(
       this.conflictingChanges.length,
       -1,
       sectionSize(Section.MERGE_CONFLICTS)
     );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.conflictingChanges?.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const mergeConflictsSection = html`<section
-      id="mergeConflicts"
-      ?hidden=${!this.conflictingChanges?.length}
-    >
+    return html`<section id="mergeConflicts">
       <gr-related-collapse
         title="Merge conflicts"
-        class="${classMap({first: isFirstNonEmpty})}"
+        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>`
         )}
       </gr-related-collapse>
     </section>`;
+  }
 
+  private renderCherryPicks(
+    isFirst: boolean,
+    sectionSize: (section: Section) => number
+  ) {
+    if (!this.cherryPickChanges.length) {
+      return undefined;
+    }
     const cherryPicksMarkersPredicate = this.markersPredicateFactory(
       this.cherryPickChanges.length,
       -1,
       sectionSize(Section.CHERRY_PICKS)
     );
-    isFirstNonEmpty =
-      !firstNonEmptySectionFound && !!this.cherryPickChanges?.length;
-    firstNonEmptySectionFound = firstNonEmptySectionFound || isFirstNonEmpty;
-    const cherryPicksSection = html`<section
-      id="cherryPicks"
-      ?hidden=${!this.cherryPickChanges?.length}
-    >
+    return html`<section id="cherryPicks">
       <gr-related-collapse
         title="Cherry picks"
-        class="${classMap({first: isFirstNonEmpty})}"
+        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>`
         )}
       </gr-related-collapse>
     </section>`;
+  }
 
-    return html`<gr-endpoint-decorator name="related-changes-section">
-      <gr-endpoint-param
-        name="change"
-        .value=${this.change}
-      ></gr-endpoint-param>
-      <gr-endpoint-slot name="top"></gr-endpoint-slot>
-      ${relatedChangeSection} ${submittedTogetherSection} ${sameTopicSection}
-      ${mergeConflictsSection} ${cherryPicksSection}
-      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
-    </gr-endpoint-decorator>`;
+  private renderChangeTitle(change: ChangeInfo) {
+    return `${change.project}: ${change.branch}: ${change.subject}`;
+  }
+
+  private renderChangeLine(change: ChangeInfo) {
+    const truncatedRepo = truncatePath(change.project, 2);
+    return html`<span class="truncatedRepo" .title=${change.project}
+        >${truncatedRepo}</span
+      >: ${change.branch}: ${change.subject}`;
   }
 
   sectionSizeFactory(
@@ -577,7 +620,10 @@
         this.restApiService.getConfig().then(config => {
           if (config && !config.change.submit_whole_topic) {
             return this.restApiService
-              .getChangesWithSameTopic(changeTopic, change._number)
+              .getChangesWithSameTopic(changeTopic, {
+                openChangesOnly: true,
+                changeToExclude: change._number,
+              })
               .then(response => {
                 if (changeTopic === this.change?.topic) {
                   this.sameTopicChanges = response ?? [];
@@ -682,7 +728,7 @@
   @property()
   numChangesWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   static override get styles() {
     return [
@@ -729,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 a6dc338f..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
@@ -30,6 +30,7 @@
   createSubmittedTogetherInfo,
 } from '../../../test/test-data-generators';
 import {
+  query,
   queryAndAssert,
   resetPlugins,
   stubRestApi,
@@ -46,7 +47,6 @@
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import './gr-related-changes-list';
 import {
@@ -56,8 +56,6 @@
   Section,
 } from './gr-related-changes-list';
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 const basicFixture = fixtureFromElement('gr-related-changes-list');
 
 suite('gr-related-changes-list', () => {
@@ -216,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 () => {
@@ -227,16 +225,13 @@
         Promise.resolve(submittedTogether)
       );
       await element.reload();
-      const relatedChanges = queryAndAssert<GrRelatedCollapse>(
-        queryAndAssert<HTMLElement>(element, '#relatedChanges'),
-        'gr-related-collapse'
-      );
-      assert.isFalse(relatedChanges!.classList.contains('first'));
+      const relatedChanges = query<HTMLElement>(element, '#relatedChanges');
+      assert.notExists(relatedChanges);
       const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
         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 () => {
@@ -254,17 +249,17 @@
         queryAndAssert<HTMLElement>(element, '#relatedChanges'),
         'gr-related-collapse'
       );
-      assert.isTrue(relatedChanges!.classList.contains('first'));
-      const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
-        queryAndAssert<HTMLElement>(element, '#submittedTogether'),
-        'gr-related-collapse'
+      assert.isTrue(relatedChanges.classList.contains('first'));
+      const submittedTogetherSection = query<HTMLElement>(
+        element,
+        '#submittedTogether'
       );
-      assert.isFalse(submittedTogetherSection!.classList.contains('first'));
+      assert.notExists(submittedTogetherSection);
       const cherryPicks = queryAndAssert<GrRelatedCollapse>(
         queryAndAssert<HTMLElement>(element, '#cherryPicks'),
         'gr-related-collapse'
       );
-      assert.isFalse(cherryPicks!.classList.contains('first'));
+      assert.isFalse(cherryPicks.classList.contains('first'));
     });
   });
 
@@ -608,7 +603,7 @@
       }
       let hookEl: RelatedChangesListGrEndpointDecorator;
       let plugin: PluginApi;
-      pluginApi.install(
+      window.Gerrit.install(
         p => {
           plugin = p;
           plugin
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 486f37a..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
+++ /dev/null
@@ -1,130 +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 {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-
-const basicFixture = fixtureFromElement('gr-reply-dialog');
-const pluginApi = _testOnly_initGerritPluginApi();
-
-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();
-    pluginApi.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 b8932e5..3e766c9 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
@@ -16,6 +16,8 @@
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 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 '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-textarea/gr-textarea';
 import '../../shared/gr-button/gr-button';
@@ -25,13 +27,11 @@
 import '../gr-label-scores/gr-label-scores';
 import '../gr-thread-list/gr-thread-list';
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-reply-dialog_html';
 import {
   GrReviewerSuggestionsProvider,
   SUGGESTIONS_PROVIDERS_USERS_TYPES,
 } from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {
   ChangeStatus,
   DraftsAction,
@@ -46,14 +46,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 {
@@ -62,32 +62,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,
@@ -116,6 +108,16 @@
 import {Interaction, Timing} from '../../../constants/reporting';
 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} 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';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -151,24 +153,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 PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrReplyDialog extends LitElement {
   /**
    * Fired when a reply is successfully sent.
    *
@@ -215,9 +201,9 @@
 
   FocusTarget = FocusTarget;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
-  private readonly changeService = appContext.changeService;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
   @property({type: Object})
   change?: ChangeInfo;
@@ -228,151 +214,437 @@
   @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: String})
-  quote = '';
+  @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: (AccountInfoInput | GroupInfoInput)[] = [];
 
-  @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;
+  @state()
+  allReviewers: (AccountInfo | GroupInfo)[] = [];
 
-  @property({type: Array, computed: '_computeAllReviewers(_reviewers.*)'})
-  _allReviewers: (AccountInfo | GroupInfo)[] = [];
+  private readonly restApiService: RestApiService =
+    getAppContext().restApiService;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly storage = getAppContext().storageService;
 
-  private readonly storage = appContext.storageService;
+  private readonly jsAPI = getAppContext().jsApiService;
 
-  private readonly jsAPI = appContext.jsApiService;
-
-  private storeTask?: DelayedTask;
+  storeTask?: DelayedTask;
 
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
 
+  static override styles = [
+    sharedStyles,
+    css`
+      :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);
+      }
+    `,
+  ];
+
+  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();
+    }
+  }
+
   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);
   }
 
   override connectedCallback() {
@@ -380,23 +652,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
@@ -405,21 +677,17 @@
     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 disconnectedCallback() {
     this.storeTask?.cancel();
     for (const cleanup of this.cleanups) cleanup();
@@ -427,117 +695,602 @@
     super.disconnectedCallback();
   }
 
-  open(focusTarget?: FocusTarget) {
+  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
+   * change view for initializing the dialog after opening the overlay. Maybe it
+   * should be called `onOpened()` or `initialize()`?
+   */
+  open(focusTarget?: FocusTarget, quote?: string) {
     assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
-    this.changeService.fetchChangeUpdates(this.change).then(result => {
-      this.knownLatestState = result.isLatest
-        ? LatestPatchState.LATEST
-        : LatestPatchState.NOT_LATEST;
-    });
+    this.getChangeModel()
+      .fetchChangeUpdates(this.change)
+      .then(result => {
+        this.knownLatestState = result.isLatest
+          ? LatestPatchState.LATEST
+          : LatestPatchState.NOT_LATEST;
+      });
 
-    this._focusOn(focusTarget);
-    if (this.quote && this.quote.length) {
-      // If a reply quote has been provided, use it and clear the property.
-      this.draft = this.quote;
-      this.quote = '';
+    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) {
-    const selectorEl = this.getLabelScores().shadowRoot?.querySelector(
-      `gr-label-score-row[name="${label}"]`
-    );
-    if (!selectorEl) {
-      return;
-    }
-    (selectorEl as GrLabelScoreRow).setSelectedValue(value);
+  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}"]`
+      );
+    selectorEl?.setSelectedValue(value);
   }
 
   getLabelValue(label: string) {
-    const selectorEl = this.getLabelScores().shadowRoot?.querySelector(
-      `gr-label-score-row[name="${label}"]`
+    const selectorEl =
+      this.getLabelScores().shadowRoot?.querySelector<GrLabelScoreRow>(
+        `gr-label-score-row[name="${label}"]`
+      );
+    return selectorEl?.selectedValue;
+  }
+
+  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 (!selectorEl) {
-      return null;
-    }
-
-    return (selectorEl as GrLabelScoreRow).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;
+    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);
     }
   }
 
@@ -557,33 +1310,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
     );
@@ -605,23 +1352,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
     );
@@ -629,7 +1376,7 @@
     if (this.draft) {
       const comment: CommentInput = {
         message: this.draft,
-        unresolved: !this._isResolvedPatchsetLevelComment,
+        unresolved: !this.isResolvedPatchsetLevelComment,
       };
       reviewInput.comments = {
         [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment],
@@ -640,8 +1387,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
@@ -654,7 +1401,7 @@
         }
 
         this.draft = '';
-        this._includeComments = true;
+        this.includeComments = true;
         this.dispatchEvent(
           new CustomEvent('send', {
             composed: true,
@@ -674,31 +1421,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;
     }
@@ -707,15 +1454,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
@@ -750,45 +1497,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 = [];
 
-    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) {
@@ -803,172 +1547,140 @@
       }
     }
 
-    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.requestUpdate();
   }
 
-  _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>,
@@ -983,7 +1695,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>();
@@ -991,7 +1703,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);
@@ -1001,110 +1713,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)) {
@@ -1120,24 +1815,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,
@@ -1145,36 +1836,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,
@@ -1185,7 +1876,7 @@
     });
   }
 
-  _saveReview(review: ReviewInput, errFn?: ErrorCallback) {
+  saveReview(review: ReviewInput, errFn?: ErrorCallback) {
     assertIsDefined(this.change, 'change');
     assertIsDefined(this.patchNum, 'patchNum');
     return this.restApiService.saveChangeReview(
@@ -1196,43 +1887,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,
@@ -1241,50 +1932,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() {
@@ -1292,82 +2006,77 @@
     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) {
+  getReviewerSuggestionsProvider(change?: ChangeInfo) {
+    if (!change) return;
     const provider = GrReviewerSuggestionsProvider.create(
       this.restApiService,
       change._number,
@@ -1377,7 +2086,8 @@
     return provider;
   }
 
-  _getCcSuggestionsProvider(change: ChangeInfo) {
+  getCcSuggestionsProvider(change?: ChangeInfo) {
+    if (!change) return;
     const provider = GrReviewerSuggestionsProvider.create(
       this.restApiService,
       change._number,
@@ -1395,24 +2105,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 45a55c1..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ /dev/null
@@ -1,651 +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;
-    }
-    .container {
-      display: flex;
-      flex-direction: column;
-      max-height: 100%;
-    }
-    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 class$="container" 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]]"
-            fixed-position-dropdown=""
-            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]]"
-        change="[[change]]"
-        change-num="[[change._number]]"
-        logged-in="true"
-        hide-dropdown=""
-      >
-      </gr-thread-list>
-      <span
-        id="savingLabel"
-        class$="[[_computeSavingLabelClass(_savingComments)]]"
-      >
-        Saving comments...
-      </span>
-    </section>
-    <div class$="stickyBottom newReplyDialog">
-      <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>
-      <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>
-    </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 5a11f47..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
@@ -18,9 +18,11 @@
 import '../../../test/common-test-setup-karma';
 import './gr-reply-dialog';
 import {
+  addListenerForTest,
   mockPromise,
   queryAll,
   queryAndAssert,
+  stubRestApi,
   stubStorage,
 } from '../../../test/test-utils';
 import {
@@ -28,14 +30,12 @@
   ReviewerState,
   SpecialFilePath,
 } from '../../../constants/constants';
-import {appContext} from '../../../services/app-context';
-import {addListenerForTest} from '../../../test/test-utils';
-import {stubRestApi} from '../../../test/test-utils';
 import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {StandardLabels} from '../../../utils/label-util';
 import {
   createAccountWithId,
   createChange,
+  createComment,
   createCommentThread,
   createDraft,
   createRevision,
@@ -44,7 +44,7 @@
   pressAndReleaseKeyOn,
   tap,
 } from '@polymer/iron-test-helpers/mock-interactions';
-import {GrReplyDialog} from './gr-reply-dialog';
+import {FocusTarget, GrReplyDialog} from './gr-reply-dialog';
 import {
   AccountId,
   AccountInfo,
@@ -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,14 +114,15 @@
     stubRestApi('getChange').returns(Promise.resolve({...createChange()}));
     stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
 
-    sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
+    element = await fixture<GrReplyDialog>(html`
+      <gr-reply-dialog></gr-reply-dialog>
+    `);
 
-    element = basicFixture.instantiate();
     element.change = {
       ...createChange(),
       _number: changeNum,
       owner: {
-        _account_id: 999 as AccountId as AccountId,
+        _account_id: 999 as AccountId,
         display_name: 'Kermit',
       },
       labels: {
@@ -144,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',
@@ -164,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 {
@@ -208,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, {
@@ -234,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;
@@ -271,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;
@@ -295,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[],
@@ -311,28 +305,22 @@
     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 = [
         {
-          ...createCommentThread([
-            {
-              ...createDraft(),
-              __draft: true,
-              unresolved: true,
-            },
-          ]),
+          ...createCommentThread([{...createDraft(), unresolved: true}]),
         },
       ];
     }
     replyToIds?.forEach(id =>
       draftThreads[0].comments.push({
+        ...createComment(),
         author: {_account_id: id},
       })
     );
@@ -340,6 +328,9 @@
       ...createChange(),
       owner: {_account_id: ownerId},
       status,
+      reviewers: {
+        [ReviewerState.REVIEWER]: element.reviewers,
+      },
     };
     attSetIds?.forEach(id => {
       if (!change.attention_set) change.attention_set = {};
@@ -355,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,
       [],
@@ -382,7 +367,8 @@
       [],
       [999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -391,7 +377,8 @@
       [],
       [999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -400,7 +387,8 @@
       [],
       [999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -409,7 +397,8 @@
       [],
       [22 as AccountId, 999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -418,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],
@@ -428,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,
       [],
@@ -437,7 +428,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -446,7 +438,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -456,7 +449,8 @@
       []
     );
 
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -465,7 +459,8 @@
       [22 as AccountId],
       [22 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -474,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],
@@ -483,7 +479,8 @@
       [22 as AccountId],
       [22 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -492,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],
@@ -502,7 +500,8 @@
       [22 as AccountId, 33 as AccountId]
     );
     // with uploader
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -512,7 +511,8 @@
       [2 as AccountId],
       2 as AccountId
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -522,7 +522,8 @@
       [2 as AccountId],
       2 as AccountId
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -534,8 +535,9 @@
     );
   });
 
-  test('computeNewAttention MERGED', () => {
-    checkComputeAttention(
+  test('computeNewAttention MERGED', async () => {
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       undefined,
       [],
@@ -546,7 +548,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -557,7 +560,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -568,7 +572,8 @@
       undefined,
       true
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -580,7 +585,8 @@
       true,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -591,7 +597,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -602,7 +609,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -613,7 +621,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -622,7 +631,8 @@
       [22 as AccountId],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -631,7 +641,8 @@
       [22 as AccountId],
       [33 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -640,7 +651,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -651,7 +663,8 @@
       undefined,
       true
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -660,7 +673,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -671,7 +685,8 @@
       undefined,
       true
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -680,7 +695,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -689,7 +705,8 @@
       [22 as AccountId],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -698,7 +715,8 @@
       [22 as AccountId],
       [33 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -707,7 +725,8 @@
       [22 as AccountId],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -716,7 +735,8 @@
       [22 as AccountId, 33 as AccountId],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -727,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]);
@@ -857,7 +845,7 @@
     );
   });
 
-  test('_computeCommentAccounts', () => {
+  test('computeCommentAccounts', () => {
     element.change = {
       ...createChange(),
       labels: {
@@ -868,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',
@@ -881,11 +869,13 @@
       {
         ...createCommentThread([
           {
+            ...createComment(),
             id: '1' as UrlEncodedCommentId,
             author: {_account_id: 1 as AccountId},
             unresolved: false,
           },
           {
+            ...createComment(),
             id: '2' as UrlEncodedCommentId,
             in_reply_to: '1' as UrlEncodedCommentId,
             author: {_account_id: 2 as AccountId},
@@ -896,11 +886,13 @@
       {
         ...createCommentThread([
           {
+            ...createComment(),
             id: '3' as UrlEncodedCommentId,
             author: {_account_id: 3 as AccountId},
             unresolved: false,
           },
           {
+            ...createComment(),
             id: '4' as UrlEncodedCommentId,
             in_reply_to: '3' as UrlEncodedCommentId,
             author: {_account_id: 4 as AccountId},
@@ -909,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]);
@@ -924,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;
@@ -949,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,
     });
@@ -957,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(() => {
@@ -968,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.'
@@ -994,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: {
@@ -1032,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,
@@ -1124,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'))
     );
@@ -1138,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
       );
     }
 
@@ -1169,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
     );
@@ -1182,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,
       };
     }
 
@@ -1226,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: {
@@ -1242,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!
         )
       );
     }
@@ -1266,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', () => {
@@ -1317,28 +1326,28 @@
     const storedDraft = 'hello world';
     const quote = '> foo bar';
     getDraftCommentStub.returns({message: storedDraft});
-    element.quote = quote;
-    element.open();
+    element.open(FocusTarget.ANY, quote);
     assert.isFalse(getDraftCommentStub.called);
     assert.equal(element.draft, quote);
-    assert.isNotOk(element.quote);
   });
 
   test('updates stored draft on edits', async () => {
     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));
   });
@@ -1346,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,
@@ -1367,7 +1377,7 @@
     };
     addListenerForTest(document, 'server-error', listener);
 
-    await flush();
+    await element.updateComplete;
     element.send(false, false);
     await promise;
   });
@@ -1375,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());
       }
@@ -1392,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;
   });
@@ -1403,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));
@@ -1419,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,
@@ -1455,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,
@@ -1508,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();
@@ -1527,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,
@@ -1551,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')
@@ -1606,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();
@@ -1616,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,
@@ -1640,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', {
@@ -1670,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,
@@ -1691,7 +1753,7 @@
         bubbles: true,
       })
     );
-    reviewers.$.entry.dispatchEvent(
+    reviewers.entry!.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: {account: cc1}},
         composed: true,
@@ -1699,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,
@@ -1716,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
@@ -1731,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)
     );
@@ -1753,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]: [],
@@ -1768,7 +1863,7 @@
     const mutations: ReviewerInput[] = [];
 
     stubSaveReview((review: ReviewInput) => {
-      mutations.push(...review!.reviewers!);
+      mutations.push(...review.reviewers!);
     });
 
     // Remove and add to other field.
@@ -1779,7 +1874,7 @@
         bubbles: true,
       })
     );
-    ccs.$.entry.dispatchEvent(
+    ccs.entry!.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: {account: reviewer1}},
         composed: true,
@@ -1796,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);
   });
@@ -1819,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';
@@ -1868,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);
   });
 
@@ -1890,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));
     });
   });
@@ -1928,7 +2027,7 @@
       assert.isFalse(element.disabled);
     }
 
-    test('error occurs in _saveReview', () => {
+    test('error occurs in saveReview', () => {
       stubSaveReview(() => {
         throw expectedError;
       });
@@ -1949,199 +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([{__draft: true}])},
-        ],
-        /* 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([{__draft: true}])},
-        ],
-        /* 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([{}])}],
-        /* 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([{}])}],
-        /* 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([{}])}],
-        /* 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([{}])}],
-        /* 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([{}])}],
-        /* 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);
@@ -2149,11 +2212,16 @@
     element.draftCommentThreads = [
       {
         ...createCommentThread([
-          {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+          {
+            ...createDraft(),
+            path: 'test',
+            line: 1,
+            patch_set: 1 as PatchSetNum,
+          },
         ]),
       },
     ];
-    await flush();
+    await element.updateComplete;
 
     tap(queryAndAssert(element, 'gr-button.send'));
     assert.isTrue(sendStub.called);
@@ -2163,29 +2231,35 @@
     // 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 = [
       {
         ...createCommentThread([
-          {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+          {
+            ...createDraft(),
+            path: 'test',
+            line: 1,
+            patch_set: 1 as PatchSetNum,
+          },
         ]),
       },
     ];
-    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 de11b16..6740977 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,146 +17,177 @@
 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 {appContext} from '../../../services/app-context';
-import {fireAlert} from '../../../utils/event-util';
-import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
+import {getAppContext} from '../../../services/app-context';
+import {
+  getApprovalInfo,
+  getCodeReviewLabel,
+  showNewSubmitRequirements,
+} from '../../../utils/label-util';
 import {sortReviewers} from '../../../utils/attention-set-util';
+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 = appContext.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)),
-      };
-    });
-  }
-
-  /**
-   * Returns hash of labels to max permitted score.
-   *
-   * @returns labels to max permitted scores hash
-   */
-  _getMaxPermittedScores(change: ChangeInfo) {
-    return this._permittedLabelsToNumericScores(change.permitted_labels)
-      .map(({label, scores}) => {
-        return {
-          [label]: scores.reduce((a, b) => Math.max(a, b)),
-        };
-      })
-      .reduce((acc, i) => Object.assign(acc, i), {});
+  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()}
+      >
+        ${showNewSubmitRequirements(this.flagsService, this.change)
+          ? html`<gr-vote-chip
+              slot="vote-chip"
+              .vote=${this.computeVote(reviewer)}
+              .label=${this.computeCodeReviewLabel()}
+              circle-shape
+            ></gr-vote-chip>`
+          : nothing}
+      </gr-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;
     }
@@ -174,55 +205,41 @@
     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 = [];
-    const maxPermitted = this._getMaxPermittedScores(change);
     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;
       }
-      if (maxScore > 0 && maxScore === maxPermitted[label]) {
-        maxScores.push(`${label}: +${maxScore}`);
-      } else {
-        maxScores.push(`${label}`);
-      }
+      const scoreLabel = maxScore > 0 ? `+${maxScore}` : `${maxScore}`;
+      maxScores.push(`${label}: ${scoreLabel}`);
     }
     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;
@@ -234,65 +251,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) {
-    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,
@@ -312,15 +285,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);
-  }
 }
 
 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 dec65e2..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_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.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;
-      margin-right: var(--spacing-s);
-    }
-  </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)]]"
-        >
-          <gr-vote-chip
-            slot="vote-chip"
-            vote="[[_computeVote(reviewer, change)]]"
-            label="[[_computeCodeReviewLabel(change)]]"
-          ></gr-vote-chip>
-        </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 bf15bb5..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),
-      'Bar'
+      element.computeVoteableText({...createAccountDetailWithId(1)}),
+      'Bar: +1'
     );
     assert.strictEqual(
-      element._computeVoteableText({...createAccountDetailWithId(7)}, change),
-      'Foo: +2, Bar, FooBar'
+      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-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard.ts
new file mode 100644
index 0000000..72f04e3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard.ts
@@ -0,0 +1,59 @@
+/**
+ * @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-submit-requirements/gr-submit-requirements';
+import {customElement, property} from 'lit/decorators';
+import {css, html, LitElement} from 'lit';
+import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
+import {ParsedChangeInfo} from '../../../types/types';
+
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = HovercardMixin(LitElement);
+
+@customElement('gr-submit-requirement-dashboard-hovercard')
+export class GrSubmitRequirementDashboardHovercard extends base {
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  static override get styles() {
+    return [
+      base.styles || [],
+      css`
+        #container {
+          padding: var(--spacing-xl);
+          padding-left: var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`<div id="container" role="tooltip" tabindex="-1">
+      <gr-submit-requirements
+        .change=${this.change}
+        disable-hovercards
+        suppress-title
+        disable-endpoints
+      ></gr-submit-requirements>
+    </div>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-submit-requirement-dashboard-hovercard': GrSubmitRequirementDashboardHovercard;
+  }
+}
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 002ac56..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
@@ -19,18 +19,30 @@
 import {customElement, property} from 'lit/decorators';
 import {
   AccountInfo,
+  ChangeStatus,
+  isDetailedLabelInfo,
   SubmitRequirementExpressionInfo,
   SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
 } from '../../../api/rest-api';
 import {
+  canVote,
   extractAssociatedLabels,
+  getApprovalInfo,
+  hasVotes,
   iconForStatus,
 } from '../../../utils/label-util';
 import {ParsedChangeInfo} from '../../../types/types';
-import {Label} from '../gr-change-requirements/gr-change-requirements';
 import {css, html, LitElement} from 'lit';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {DraftsAction} from '../../../constants/constants';
+import {ReviewInput} from '../../../types/common';
+import {getAppContext} from '../../../services/app-context';
+import {assertIsDefined} from '../../../utils/common-util';
+import {CURRENT} from '../../../utils/patch-set-util';
+import {fireReload} from '../../../utils/event-util';
+import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
 const base = HovercardMixin(LitElement);
@@ -52,9 +64,12 @@
   @property({type: Boolean})
   expanded = false;
 
+  private readonly restApiService = getAppContext().restApiService;
+
   static override get styles() {
     return [
       fontStyles,
+      submitRequirementsStyles,
       base.styles || [],
       css`
         #container {
@@ -97,28 +112,28 @@
           width: 20px;
           height: 20px;
         }
-        .condition {
+        .section.condition > .sectionContent {
           background-color: var(--gray-background);
           padding: var(--spacing-m);
           flex-grow: 1;
         }
+        .button ~ .condition {
+          margin-top: var(--spacing-m);
+        }
         .expression {
           color: var(--gray-foreground);
         }
-        iron-icon.check {
-          color: var(--success-foreground);
-        }
-        iron-icon.close {
-          color: var(--warning-foreground);
-        }
-        .showConditions iron-icon {
+        .button iron-icon {
           color: inherit;
         }
-        div.showConditions {
+        div.button {
           border-top: 1px solid var(--border-color);
           margin-top: var(--spacing-m);
           padding: var(--spacing-m) var(--spacing-xl) 0;
         }
+        .section.description > .sectionContent {
+          white-space: pre-wrap;
+        }
       `,
     ];
   }
@@ -129,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">
@@ -148,12 +163,49 @@
           </div>
         </div>
       </div>
-      ${this.renderLabelSection()} ${this.renderConditionSection()}
+      ${this.renderLabelSection()}${this.renderDescription()}
+      ${this.renderShowHideConditionButton()}${this.renderConditionSection()}
+      ${this.renderVotingButtons()}
+    </div>`;
+  }
+
+  private renderDescription() {
+    let description = this.requirement?.description;
+    if (this.requirement?.status === SubmitRequirementStatus.ERROR) {
+      const submitRecord = this.change?.submit_records?.filter(
+        record => record.rule_name === this.requirement?.name
+      );
+      if (submitRecord?.length === 1 && submitRecord[0].error_message) {
+        description = submitRecord[0].error_message;
+      }
+    }
+    if (!description) return;
+    return html`<div class="section description">
+      <div class="sectionIcon">
+        <iron-icon icon="gr-icons:description"></iron-icon>
+      </div>
+      <div class="sectionContent">${description}</div>
     </div>`;
   }
 
   private renderLabelSection() {
-    const labels = this.computeLabels();
+    if (!this.requirement) return;
+    const requirementLabels = extractAssociatedLabels(this.requirement);
+    const allLabels = this.change?.labels ?? {};
+    const labels: string[] = [];
+    for (const label of Object.keys(allLabels)) {
+      if (requirementLabels.includes(label)) {
+        const labelInfo = allLabels[label];
+        const canSomeoneVote = (this.change?.reviewers['REVIEWER'] ?? []).some(
+          reviewer => canVote(labelInfo, reviewer)
+        );
+        if (hasVotes(labelInfo) || canSomeoneVote) {
+          labels.push(label);
+        }
+      }
+    }
+
+    if (labels.length === 0) return;
     const showLabelName = labels.length >= 2;
     return html` <div class="section">
       <div class="sectionIcon"></div>
@@ -163,84 +215,136 @@
     </div>`;
   }
 
-  private renderLabel(label: Label, showLabelName: boolean) {
+  private renderLabel(labelName: string, showLabelName: boolean) {
+    const labels = this.change?.labels ?? {};
     return html`
-      ${showLabelName ? html`<div>${label.labelName} votes</div>` : ''}
+      ${showLabelName ? html`<div>${labelName} votes</div>` : ''}
       <gr-label-info
         .change=${this.change}
         .account=${this.account}
         .mutable=${this.mutable}
-        .label="${label.labelName}"
-        .labelInfo="${label.labelInfo}"
+        .label=${labelName}
+        .labelInfo=${labels[labelName]}
       ></gr-label-info>
     `;
   }
 
-  private renderConditionSection() {
-    if (!this.expanded) {
-      return html` <div class="showConditions">
-        <gr-button
-          link=""
-          class="showConditions"
-          @click="${(_: MouseEvent) => this.handleShowConditions()}"
-        >
-          View condition
-          <iron-icon icon="gr-icons:expand-more"></iron-icon
-        ></gr-button>
-      </div>`;
+  private renderShowHideConditionButton() {
+    const buttonText = this.expanded ? 'Hide conditions' : 'View conditions';
+    const icon = this.expanded ? 'expand-less' : 'expand-more';
+
+    return html` <div class="button">
+      <gr-button
+        link=""
+        id="toggleConditionsButton"
+        @click=${(_: MouseEvent) => this.toggleConditionsVisibility()}
+      >
+        ${buttonText}
+        <iron-icon icon="gr-icons:${icon}"></iron-icon
+      ></gr-button>
+    </div>`;
+  }
+
+  private renderVotingButtons() {
+    if (!this.requirement) return;
+    if (!this.account) return;
+    if (this.change?.status === ChangeStatus.MERGED) return;
+
+    const submittabilityLabels = extractAssociatedLabels(
+      this.requirement,
+      'onlySubmittability'
+    );
+    const submittabilityVotes = submittabilityLabels.map(labelName =>
+      this.renderLabelVote(labelName, 'submittability')
+    );
+
+    const overrideLabels = extractAssociatedLabels(
+      this.requirement,
+      'onlyOverride'
+    );
+    const overrideVotes = overrideLabels.map(labelName =>
+      this.renderLabelVote(labelName, 'override')
+    );
+
+    return submittabilityVotes.concat(overrideVotes);
+  }
+
+  private renderLabelVote(
+    labelName: string,
+    type: 'override' | 'submittability'
+  ) {
+    const labels = this.change?.labels ?? {};
+    const labelInfo = labels[labelName];
+    if (!labelInfo || !isDetailedLabelInfo(labelInfo)) return;
+    if (!this.account || !canVote(labelInfo, this.account)) return;
+
+    const approvalInfo = getApprovalInfo(labelInfo, this.account);
+    const maxVote = approvalInfo?.permitted_voting_range?.max;
+    if (!maxVote || maxVote <= 0) return;
+    if (approvalInfo?.value === maxVote) return; // Already voted maxVote
+    return html` <div class="button quickApprove">
+      <gr-button
+        link=""
+        @click=${(_: MouseEvent) => this.quickApprove(labelName, maxVote)}
+      >
+        ${this.computeVoteButtonName(labelName, maxVote, type)}
+      </gr-button>
+    </div>`;
+  }
+
+  private computeVoteButtonName(
+    labelName: string,
+    maxVote: number,
+    type: 'override' | 'submittability'
+  ) {
+    if (type === 'override') {
+      return `Override (${labelName})`;
     } else {
-      return html`
-        <div class="section">
-          <div class="sectionIcon">
-            <iron-icon icon="gr-icons:description"></iron-icon>
-          </div>
-          <div class="sectionContent">${this.requirement?.description}</div>
-        </div>
-        ${this.renderCondition(
-          'Blocking condition',
-          this.requirement?.submittability_expression_result
-        )}
-        ${this.renderCondition(
-          'Application condition',
-          this.requirement?.applicability_expression_result
-        )}
-        ${this.renderCondition(
-          'Override condition',
-          this.requirement?.override_expression_result
-        )}
-      `;
+      return `Vote ${labelName} +${maxVote}`;
     }
   }
 
-  private computeLabels() {
-    if (!this.requirement) return [];
-    const requirementLabels = extractAssociatedLabels(this.requirement);
-    const labels = this.change?.labels ?? {};
+  private quickApprove(label: string, score: number) {
+    assertIsDefined(this.change, 'change');
+    const review: ReviewInput = {
+      drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
+      labels: {
+        [label]: score,
+      },
+    };
+    return this.restApiService
+      .saveChangeReview(this.change._number, CURRENT, review)
+      .then(() => {
+        fireReload(this, true);
+      });
+  }
 
-    const allLabels: Label[] = [];
-
-    for (const label of Object.keys(labels)) {
-      if (requirementLabels.includes(label)) {
-        allLabels.push({
-          labelName: label,
-          icon: '',
-          style: '',
-          labelInfo: labels[label],
-        });
-      }
-    }
-    return allLabels;
+  private renderConditionSection() {
+    if (!this.expanded) return;
+    return html`
+      ${this.renderCondition(
+        'Submit condition',
+        this.requirement?.submittability_expression_result
+      )}
+      ${this.renderCondition(
+        'Application condition',
+        this.requirement?.applicability_expression_result
+      )}
+      ${this.renderCondition(
+        'Override condition',
+        this.requirement?.override_expression_result
+      )}
+    `;
   }
 
   private renderCondition(
     name: string,
     expression?: SubmitRequirementExpressionInfo
   ) {
-    if (!expression) return '';
+    if (!expression?.expression) return '';
     return html`
-      <div class="section">
-        <div class="sectionIcon"></div>
-        <div class="sectionContent condition">
+      <div class="section condition">
+        <div class="sectionContent">
           ${name}:<br />
           <span class="expression"> ${expression.expression} </span>
         </div>
@@ -248,8 +352,8 @@
     `;
   }
 
-  private handleShowConditions() {
-    this.expanded = true;
+  private toggleConditionsVisibility() {
+    this.expanded = !this.expanded;
   }
 }
 
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
new file mode 100644
index 0000000..ded88de
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
@@ -0,0 +1,356 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import './gr-submit-requirement-hovercard';
+import {GrSubmitRequirementHovercard} from './gr-submit-requirement-hovercard';
+import {
+  createAccountWithId,
+  createApproval,
+  createChange,
+  createDetailedLabelInfo,
+  createParsedChange,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import {ParsedChangeInfo} from '../../../types/types';
+import {query, queryAndAssert} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {ChangeStatus, SubmitRequirementResultInfo} from '../../../api/rest-api';
+
+suite('gr-submit-requirement-hovercard tests', () => {
+  let element: GrSubmitRequirementHovercard;
+
+  setup(async () => {
+    element = await fixture<GrSubmitRequirementHovercard>(
+      html`<gr-submit-requirement-hovercard
+        .requirement=${createSubmitRequirementResultInfo()}
+        .change=${createChange()}
+        .account=${createAccountWithId()}
+      ></gr-submit-requirement-hovercard>`
+    );
+  });
+
+  test('renders', async () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div id="container" role="tooltip" tabindex="-1">
+        <div class="section">
+          <div class="sectionIcon">
+            <iron-icon
+              class="check-circle-filled"
+              icon="gr-icons:check-circle-filled"
+            >
+            </iron-icon>
+          </div>
+          <div class="sectionContent">
+            <h3 class="heading-3 name">
+              <span> Verified </span>
+            </h3>
+          </div>
+        </div>
+        <div class="section">
+          <div class="sectionIcon">
+            <iron-icon class="small" icon="gr-icons:info-outline"> </iron-icon>
+          </div>
+          <div class="sectionContent">
+            <div class="row">
+              <div class="title">Status</div>
+              <div>SATISFIED</div>
+            </div>
+          </div>
+        </div>
+        <div class="button">
+          <gr-button
+            aria-disabled="false"
+            id="toggleConditionsButton"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            View conditions
+            <iron-icon icon="gr-icons:expand-more"> </iron-icon>
+          </gr-button>
+        </div>
+      </div>
+    `);
+  });
+
+  test('renders conditions after click', async () => {
+    const button = queryAndAssert<GrButton>(element, '#toggleConditionsButton');
+    button.click();
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div id="container" role="tooltip" tabindex="-1">
+        <div class="section">
+          <div class="sectionIcon">
+            <iron-icon
+              class="check-circle-filled"
+              icon="gr-icons:check-circle-filled"
+            >
+            </iron-icon>
+          </div>
+          <div class="sectionContent">
+            <h3 class="heading-3 name">
+              <span> Verified </span>
+            </h3>
+          </div>
+        </div>
+        <div class="section">
+          <div class="sectionIcon">
+            <iron-icon class="small" icon="gr-icons:info-outline"> </iron-icon>
+          </div>
+          <div class="sectionContent">
+            <div class="row">
+              <div class="title">Status</div>
+              <div>SATISFIED</div>
+            </div>
+          </div>
+        </div>
+        <div class="button">
+          <gr-button
+            aria-disabled="false"
+            id="toggleConditionsButton"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            Hide conditions
+            <iron-icon icon="gr-icons:expand-less"> </iron-icon>
+          </gr-button>
+        </div>
+        <div class="section condition">
+          <div class="sectionContent">
+            Submit condition:
+            <br />
+            <span class="expression">
+              label:Verified=MAX -label:Verified=MIN
+            </span>
+          </div>
+        </div>
+      </div>
+    `);
+  });
+
+  test('renders label', async () => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      description: 'Test Description',
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      labels: {
+        Verified: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      },
+    };
+    const element = await fixture<GrSubmitRequirementHovercard>(
+      html`<gr-submit-requirement-hovercard
+        .requirement=${submitRequirement}
+        .change=${change}
+        .account=${createAccountWithId()}
+      ></gr-submit-requirement-hovercard>`
+    );
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div id="container" role="tooltip" tabindex="-1">
+        <div class="section">
+          <div class="sectionIcon">
+            <iron-icon
+              class="check-circle-filled"
+              icon="gr-icons:check-circle-filled"
+            >
+            </iron-icon>
+          </div>
+          <div class="sectionContent">
+            <h3 class="heading-3 name">
+              <span> Verified </span>
+            </h3>
+          </div>
+        </div>
+        <div class="section">
+          <div class="sectionIcon">
+            <iron-icon class="small" icon="gr-icons:info-outline"> </iron-icon>
+          </div>
+          <div class="sectionContent">
+            <div class="row">
+              <div class="title">Status</div>
+              <div>SATISFIED</div>
+            </div>
+          </div>
+        </div>
+        <div class="section">
+          <div class="sectionIcon"></div>
+          <div class="row">
+            <div>
+              <gr-label-info> </gr-label-info>
+            </div>
+          </div>
+        </div>
+        <div class="section description">
+          <div class="sectionIcon">
+            <iron-icon icon="gr-icons:description"> </iron-icon>
+          </div>
+          <div class="sectionContent">Test Description</div>
+        </div>
+        <div class="button">
+          <gr-button
+            aria-disabled="false"
+            id="toggleConditionsButton"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            View conditions
+            <iron-icon icon="gr-icons:expand-more"> </iron-icon>
+          </gr-button>
+        </div>
+      </div>
+    `);
+  });
+
+  suite('quick approve label', () => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      description: 'Test Description',
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    const account = createAccountWithId();
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      status: ChangeStatus.NEW,
+      labels: {
+        Verified: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              _account_id: account._account_id,
+              permitted_voting_range: {
+                min: -2,
+                max: 2,
+              },
+            },
+          ],
+        },
+      },
+    };
+    test('renders', async () => {
+      const element = await fixture<GrSubmitRequirementHovercard>(
+        html`<gr-submit-requirement-hovercard
+          .requirement=${submitRequirement}
+          .change=${change}
+          .account=${account}
+        ></gr-submit-requirement-hovercard>`
+      );
+      const quickApprove = queryAndAssert(element, '.quickApprove');
+      expect(quickApprove).dom.to.equal(/* HTML */ `
+        <div class="button quickApprove">
+          <gr-button aria-disabled="false" link="" role="button" tabindex="0">
+            Vote Verified +2
+          </gr-button>
+        </div>
+      `);
+    });
+
+    test("doesn't render when already voted max vote", async () => {
+      const changeWithVote = {
+        ...change,
+        labels: {
+          ...change.labels,
+          Verified: {
+            ...createDetailedLabelInfo(),
+            all: [
+              {
+                ...createApproval(),
+                _account_id: account._account_id,
+                permitted_voting_range: {
+                  min: -2,
+                  max: 2,
+                },
+                value: 2,
+              },
+            ],
+          },
+        },
+      };
+      const element = await fixture<GrSubmitRequirementHovercard>(
+        html`<gr-submit-requirement-hovercard
+          .requirement=${submitRequirement}
+          .change=${changeWithVote}
+          .account=${account}
+        ></gr-submit-requirement-hovercard>`
+      );
+      assert.isUndefined(query(element, '.quickApprove'));
+    });
+
+    test('override button renders', async () => {
+      const submitRequirement: SubmitRequirementResultInfo = {
+        ...createSubmitRequirementResultInfo(),
+        description: 'Test Description',
+        submittability_expression_result:
+          createSubmitRequirementExpressionInfo(),
+        override_expression_result: createSubmitRequirementExpressionInfo(
+          'label:Build-Cop=MAX'
+        ),
+      };
+      const account = createAccountWithId();
+      const change: ParsedChangeInfo = {
+        ...createParsedChange(),
+        status: ChangeStatus.NEW,
+        labels: {
+          'Build-Cop': {
+            ...createDetailedLabelInfo(),
+            all: [
+              {
+                ...createApproval(),
+                _account_id: account._account_id,
+                permitted_voting_range: {
+                  min: -2,
+                  max: 2,
+                },
+              },
+            ],
+          },
+        },
+      };
+      const element = await fixture<GrSubmitRequirementHovercard>(
+        html`<gr-submit-requirement-hovercard
+          .requirement=${submitRequirement}
+          .change=${change}
+          .account=${account}
+        ></gr-submit-requirement-hovercard>`
+      );
+      const quickApprove = queryAndAssert(element, '.quickApprove');
+      expect(quickApprove).dom.to.equal(/* HTML */ `
+        <div class="button quickApprove">
+          <gr-button aria-disabled="false" link="" role="button" tabindex="0"
+            >Override (Build-Cop)
+          </gr-button>
+        </div>
+      `);
+    });
+  });
+});
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 e8859fd..6e60a22 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
@@ -16,39 +16,46 @@
  */
 import '../../shared/gr-label-info/gr-label-info';
 import '../gr-submit-requirement-hovercard/gr-submit-requirement-hovercard';
-import '../gr-trigger-vote-hovercard/gr-trigger-vote-hovercard';
-import {LitElement, css, html} from 'lit';
+import '../gr-trigger-vote/gr-trigger-vote';
+import '../gr-change-summary/gr-change-summary';
+import '../../shared/gr-limited-text/gr-limited-text';
+import '../../shared/gr-vote-chip/gr-vote-chip';
+import {LitElement, css, html, TemplateResult} from 'lit';
 import {customElement, property, state} from 'lit/decorators';
 import {ParsedChangeInfo} from '../../../types/types';
 import {
   AccountInfo,
   isDetailedLabelInfo,
   isQuickLabelInfo,
-  LabelInfo,
   LabelNameToInfoMap,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../../../api/rest-api';
-import {unique} from '../../../utils/common-util';
 import {
   extractAssociatedLabels,
   getAllUniqueApprovals,
+  getRequirements,
+  getTriggerVotes,
   hasNeutralStatus,
   hasVotes,
   iconForStatus,
   orderSubmitRequirements,
 } from '../../../utils/label-util';
 import {fontStyles} from '../../../styles/gr-font-styles';
-import {charsOnly, pluralize} from '../../../utils/string-util';
+import {capitalizeFirstLetter, charsOnly} from '../../../utils/string-util';
 import {subscribe} from '../../lit/subscription-controller';
-import {
-  allRunsLatestPatchsetLatestAttempt$,
-  CheckRun,
-} from '../../../services/checks/checks-model';
-import {getResultsOf, hasResultsOf} from '../../../services/checks/checks-util';
+import {CheckRun} from '../../../models/checks/checks-model';
+import {getResultsOf, hasResultsOf} from '../../../models/checks/checks-util';
 import {Category} from '../../../api/checks';
-import '../../shared/gr-vote-chip/gr-vote-chip';
+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';
 
+/**
+ * @attr {Boolean} suppress-title - hide titles, currently for hovercard view
+ */
 @customElement('gr-submit-requirements')
 export class GrSubmitRequirements extends LitElement {
   @property({type: Object})
@@ -60,30 +67,33 @@
   @property({type: Boolean})
   mutable?: boolean;
 
+  @property({type: Boolean, attribute: 'disable-hovercards'})
+  disableHovercards = false;
+
+  @property({type: Boolean, attribute: 'disable-endpoints'})
+  disableEndpoints = false;
+
   @state()
   runs: CheckRun[] = [];
 
   static override get styles() {
     return [
       fontStyles,
+      submitRequirementsStyles,
       css`
+        :host([suppress-title]) .metadata-title {
+          display: none;
+        }
         .metadata-title {
           color: var(--deemphasized-text-color);
           padding-left: var(--metadata-horizontal-padding);
           margin: 0 0 var(--spacing-s);
-          border-top: 1px solid var(--border-color);
           padding-top: var(--spacing-s);
         }
         iron-icon {
           width: var(--line-height-normal, 20px);
           height: var(--line-height-normal, 20px);
-        }
-        iron-icon.check,
-        iron-icon.overridden {
-          color: var(--success-foreground);
-        }
-        iron-icon.close {
-          color: var(--error-foreground);
+          vertical-align: top;
         }
         .requirements,
         section.trigger-votes {
@@ -108,42 +118,37 @@
         }
         td {
           padding: var(--spacing-s);
+          white-space: nowrap;
         }
         .votes-cell {
           display: flex;
         }
-        .check-error {
-          margin-right: var(--spacing-l);
-        }
-        .check-error iron-icon {
-          color: var(--error-foreground);
-          vertical-align: top;
-        }
         gr-vote-chip {
           margin-right: var(--spacing-s);
         }
+        gr-checks-chip {
+          /* .checksChip has top: 2px, this is canceling it */
+          margin-top: -2px;
+        }
       `,
     ];
   }
 
-  constructor() {
-    super();
-    subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
+  private readonly getChecksModel = resolve(this, checksModelToken);
+
+  override connectedCallback(): void {
+    super.connectedCallback();
+    subscribe(
+      this,
+      this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
+      x => (this.runs = x)
+    );
   }
 
   override render() {
-    let submit_requirements = orderSubmitRequirements(
-      this.change?.submit_requirements ?? []
-    ).filter(req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE);
-
-    const hasNonLegacyRequirements = submit_requirements.some(
-      req => req.is_legacy === false
+    const submit_requirements = orderSubmitRequirements(
+      getRequirements(this.change)
     );
-    if (hasNonLegacyRequirements) {
-      submit_requirements = submit_requirements.filter(
-        req => req.is_legacy === false
-      );
-    }
 
     return html` <h3
         class="metadata-title heading-3"
@@ -160,53 +165,99 @@
           </tr>
         </thead>
         <tbody>
-          ${submit_requirements.map(
-            requirement => html`<tr
-              id="requirement-${charsOnly(requirement.name)}"
-            >
-              <td>${this.renderStatus(requirement.status)}</td>
-              <td class="name">
-                <gr-limited-text
-                  class="name"
-                  limit="25"
-                  .text="${requirement.name}"
-                ></gr-limited-text>
-              </td>
-              <td>
-                <div class="votes-cell">
-                  ${this.renderVotes(requirement)}
-                  ${this.renderChecks(requirement)}
-                </div>
-              </td>
-            </tr>`
+          ${submit_requirements.map((requirement, index) =>
+            this.renderRequirement(requirement, index)
           )}
         </tbody>
       </table>
-      ${submit_requirements.map(
-        requirement => html`
-          <gr-submit-requirement-hovercard
-            for="requirement-${charsOnly(requirement.name)}"
-            .requirement="${requirement}"
-            .change="${this.change}"
-            .account="${this.account}"
-            .mutable="${this.mutable ?? false}"
-          ></gr-submit-requirement-hovercard>
-        `
-      )}
-      ${this.renderTriggerVotes(submit_requirements)}`;
+      ${this.disableHovercards
+        ? ''
+        : submit_requirements.map(
+            (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}
+              ></gr-submit-requirement-hovercard>
+            `
+          )}
+      ${this.renderTriggerVotes()}`;
+  }
+
+  renderRequirement(requirement: SubmitRequirementResultInfo, index: number) {
+    const row = html`
+     <td>${this.renderStatus(requirement.status)}</td>
+        <td class="name">
+          <gr-limited-text
+            class="name"
+            limit="25"
+            .text=${requirement.name}
+          ></gr-limited-text>
+        </td>
+        <td>
+          ${this.renderEndpoint(
+            requirement,
+            html`${this.renderVotesAndChecksChips(requirement)}
+            ${this.renderOverrideLabels(requirement)}`
+          )}
+        </td>
+      </tr>
+    `;
+
+    if (this.disableHovercards) {
+      // when hovercards are disabled, we don't make line focusable (tabindex)
+      // since otherwise there is no action associated with the line
+      return html`<tr>
+        ${row}
+      </tr>`;
+    } else {
+      return html`<tr
+        id="requirement-${index}-${charsOnly(requirement.name)}"
+        role="button"
+        tabindex="0"
+      >
+        ${row}
+      </tr>`;
+    }
+  }
+
+  renderEndpoint(
+    requirement: SubmitRequirementResultInfo,
+    slot: TemplateResult
+  ) {
+    if (this.disableEndpoints)
+      return html`<div class="votes-cell">${slot}</div>`;
+
+    const endpointName = this.calculateEndpointName(requirement.name);
+    return html`<gr-endpoint-decorator class="votes-cell" name=${endpointName}>
+      <gr-endpoint-param
+        name="change"
+        .value=${this.change}
+      ></gr-endpoint-param>
+      <gr-endpoint-param
+        name="requirement"
+        .value=${requirement}
+      ></gr-endpoint-param>
+      ${slot}
+    </gr-endpoint-decorator>`;
   }
 
   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>`;
   }
 
-  renderVotes(requirement: SubmitRequirementResultInfo) {
+  renderVotesAndChecksChips(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 =>
@@ -216,11 +267,23 @@
     const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
       label => !hasVotes(allLabels[label])
     );
-    if (everyAssociatedLabelsIsWithoutVotes) return html`No votes`;
 
-    return associatedLabels.map(label =>
+    const checksChips = this.renderChecks(requirement);
+
+    const requirementWithoutLabelToVoteOn = associatedLabels.length === 0;
+    if (requirementWithoutLabelToVoteOn) {
+      const status = capitalizeFirstLetter(requirement.status.toLowerCase());
+      return checksChips || html`${status}`;
+    }
+
+    if (everyAssociatedLabelsIsWithoutVotes) {
+      return checksChips || html`No votes`;
+    }
+
+    return html`${associatedLabels.map(label =>
       this.renderLabelVote(label, allLabels)
-    );
+    )}
+    ${checksChips}`;
   }
 
   renderLabelVote(label: string, labels: LabelNameToInfoMap) {
@@ -232,15 +295,15 @@
       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``;
     }
@@ -257,137 +320,72 @@
       (sum, run) => sum + getResultsOf(run, Category.ERROR).length,
       0
     );
-    if (runsCount > 0) {
-      return html`<span class="check-error"
-        ><iron-icon icon="gr-icons:error"></iron-icon>${pluralize(
-          runsCount,
-          'error'
-        )}</span
-      >`;
+    if (runsCount === 0) return;
+    const links = [];
+    if (requirementRuns.length === 1 && requirementRuns[0].statusLink) {
+      links.push(requirementRuns[0].statusLink);
     }
-    return;
+    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>`;
   }
 
-  renderTriggerVotes(submitReqs: SubmitRequirementResultInfo[]) {
+  renderOverrideLabels(requirement: SubmitRequirementResultInfo) {
+    if (requirement.status !== SubmitRequirementStatus.OVERRIDDEN) return;
+    const requirementLabels = extractAssociatedLabels(
+      requirement,
+      'onlyOverride'
+    ).filter(label => {
+      const allLabels = this.change?.labels ?? {};
+      return allLabels[label] && hasVotes(allLabels[label]);
+    });
+    return requirementLabels.map(
+      label => html`<span class="overrideLabel">${label}</span>`
+    );
+  }
+
+  renderTriggerVotes() {
     const labels = this.change?.labels ?? {};
-    const allLabels = Object.keys(labels);
-    const labelAssociatedWithSubmitReqs = submitReqs
-      .flatMap(req => extractAssociatedLabels(req))
-      .filter(unique);
-    const triggerVotes = allLabels
-      .filter(label => !labelAssociatedWithSubmitReqs.includes(label))
-      .filter(label => hasVotes(labels[label]));
+    const triggerVotes = getTriggerVotes(this.change).filter(label =>
+      hasVotes(labels[label])
+    );
     if (!triggerVotes.length) return;
     return html`<h3 class="metadata-title heading-3">Trigger Votes</h3>
       <section class="trigger-votes">
         ${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>`
         )}
       </section>`;
   }
-}
 
-@customElement('gr-trigger-vote')
-export class GrTriggerVote extends LitElement {
-  @property()
-  label?: string;
-
-  @property({type: Object})
-  labelInfo?: LabelInfo;
-
-  @property({type: Object})
-  change?: ParsedChangeInfo;
-
-  @property({type: Object})
-  account?: AccountInfo;
-
-  @property({type: Boolean})
-  mutable?: boolean;
-
-  static override get styles() {
-    return css`
-      :host {
-        display: block;
-      }
-      .container {
-        box-sizing: border-box;
-        border: 1px solid var(--border-color);
-        border-radius: calc(var(--border-radius) + 2px);
-        background-color: var(--background-color-primary);
-        display: flex;
-        padding: 0;
-        padding-left: var(--spacing-s);
-        padding-right: var(--spacing-xxs);
-        align-items: center;
-      }
-      .label {
-        padding-right: var(--spacing-s);
-        font-weight: var(--font-weight-bold);
-      }
-      gr-vote-chip {
-        --gr-vote-chip-width: 14px;
-        --gr-vote-chip-height: 14px;
-        margin-right: 0px;
-        margin-left: var(--spacing-xs);
-      }
-      gr-vote-chip:first-of-type {
-        margin-left: 0px;
-      }
-    `;
-  }
-
-  override render() {
-    if (!this.labelInfo) return;
-    return html`
-      <div class="container">
-        <gr-trigger-vote-hovercard .labelName=${this.label}>
-          <gr-label-info
-            slot="label-info"
-            .change=${this.change}
-            .account=${this.account}
-            .mutable=${this.mutable}
-            .label=${this.label}
-            .labelInfo=${this.labelInfo}
-            .showAllReviewers=${false}
-          ></gr-label-info>
-        </gr-trigger-vote-hovercard>
-        <span class="label">${this.label}</span>
-        ${this.renderVotes()}
-      </div>
-    `;
-  }
-
-  private renderVotes() {
-    const {labelInfo} = this;
-    if (!labelInfo) return;
-    if (isDetailedLabelInfo(labelInfo)) {
-      const approvals = getAllUniqueApprovals(labelInfo).filter(
-        approval => !hasNeutralStatus(labelInfo, approval)
-      );
-      return approvals.map(
-        approvalInfo => html`<gr-vote-chip
-          .vote="${approvalInfo}"
-          .label="${labelInfo}"
-        ></gr-vote-chip>`
-      );
-    } else if (isQuickLabelInfo(labelInfo)) {
-      return [html`<gr-vote-chip .label="${this.labelInfo}"></gr-vote-chip>`];
-    } else {
-      return html``;
-    }
+  // not private for tests
+  calculateEndpointName(requirementName: string) {
+    // remove class name annnotation after ~
+    const name = requirementName.split('~')[0];
+    const normalizedName = charsOnly(name).toLowerCase();
+    return `submit-requirement-${normalizedName}`;
   }
 }
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-submit-requirements': GrSubmitRequirements;
-    'gr-trigger-vote': GrTriggerVote;
   }
 }
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
new file mode 100644
index 0000000..392fac91
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
@@ -0,0 +1,164 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import './gr-submit-requirements';
+import {GrSubmitRequirements} from './gr-submit-requirements';
+import {
+  createAccountWithIdNameAndEmail,
+  createApproval,
+  createDetailedLabelInfo,
+  createParsedChange,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import {SubmitRequirementResultInfo} from '../../../api/rest-api';
+import {ParsedChangeInfo} from '../../../types/types';
+
+suite('gr-submit-requirements tests', () => {
+  let element: GrSubmitRequirements;
+  let change: ParsedChangeInfo;
+  setup(async () => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      description: 'Test Description',
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    change = {
+      ...createParsedChange(),
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
+      labels: {
+        Verified: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      },
+    };
+    const account = createAccountWithIdNameAndEmail();
+    element = await fixture<GrSubmitRequirements>(
+      html`<gr-submit-requirements
+        .change=${change}
+        .account=${account}
+      ></gr-submit-requirements>`
+    );
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <h3 class="heading-3 metadata-title" id="submit-requirements-caption">
+        Submit Requirements
+      </h3>
+      <table aria-labelledby="submit-requirements-caption" class="requirements">
+        <thead hidden="">
+          <tr>
+            <th>Status</th>
+            <th>Name</th>
+            <th>Votes</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr id="requirement-0-Verified" role="button" tabindex="0">
+            <td>
+              <iron-icon
+                aria-label="satisfied"
+                class="check-circle-filled"
+                icon="gr-icons:check-circle-filled"
+                role="img"
+              >
+              </iron-icon>
+            </td>
+            <td class="name">
+              <gr-limited-text class="name" limit="25"></gr-limited-text>
+            </td>
+            <td>
+              <gr-endpoint-decorator
+                class="votes-cell"
+                name="submit-requirement-verified"
+              >
+                <gr-endpoint-param name="change"></gr-endpoint-param>
+                <gr-endpoint-param name="requirement"></gr-endpoint-param>
+                <gr-vote-chip></gr-vote-chip>
+              </gr-endpoint-decorator>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+      <gr-submit-requirement-hovercard for="requirement-0-Verified">
+      </gr-submit-requirement-hovercard>
+    `);
+  });
+
+  suite('votes-cell', () => {
+    setup(async () => {
+      element.disableEndpoints = true;
+      await element.updateComplete;
+    });
+    test('with vote', () => {
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `
+        <div class="votes-cell">
+          <gr-vote-chip> </gr-vote-chip>
+        </div>
+      `);
+    });
+
+    test('no votes', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.labels = {
+        Verified: {
+          ...createDetailedLabelInfo(),
+        },
+      };
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `
+        <div class="votes-cell">No votes</div>
+      `);
+    });
+
+    test('without label to vote on', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.submit_requirements![0]!.submittability_expression_result.expression =
+        'hasfooter:"Release-Notes"';
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      expect(votesCell?.[0]).dom.equal(/* HTML */ `
+        <div class="votes-cell">Satisfied</div>
+      `);
+    });
+  });
+
+  test('calculateEndpointName()', () => {
+    assert.equal(
+      element.calculateEndpointName('code-owners~CodeOwnerSub'),
+      'submit-requirement-codeowners'
+    );
+  });
+});
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 deea4ab..bf85d11 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
@@ -17,50 +17,38 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-comment-thread/gr-comment-thread';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
-
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-thread-list_html';
-import {parseDate} from '../../../utils/date-util';
-
-import {CommentSide, SpecialFilePath} from '../../../constants/constants';
-import {computed, customElement, observe, property} from '@polymer/decorators';
-import {
-  PolymerSpliceChange,
-  PolymerDeepPropertyChange,
-} from '@polymer/polymer/interfaces';
+import {SpecialFilePath} from '../../../constants/constants';
 import {
   AccountDetailInfo,
   AccountInfo,
-  ChangeInfo,
   NumericChangeId,
   UrlEncodedCommentId,
 } from '../../../types/common';
+import {ChangeMessageId} from '../../../api/rest-api';
 import {
   CommentThread,
-  isDraft,
-  isUnresolved,
+  getCommentAuthors,
+  hasHumanReply,
   isDraftThread,
   isRobotThread,
-  hasHumanReply,
-  getCommentAuthors,
-  computeId,
-  UIComment,
+  isUnresolved,
+  lastUpdated,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
-import {assertIsDefined, assertNever} from '../../../utils/common-util';
-import {CommentTabState} from '../../../types/events';
+import {assertIsDefined} from '../../../utils/common-util';
+import {CommentTabState, TabState} from '../../../types/events';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-
-interface CommentThreadWithInfo {
-  thread: CommentThread;
-  hasRobotComment: boolean;
-  hasHumanReplyToRobotComment: boolean;
-  unresolved: boolean;
-  isEditing: boolean;
-  hasDraft: boolean;
-  updated?: Date;
-}
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, queryAll, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {ParsedChangeInfo} from '../../../types/types';
+import {repeat} from 'lit/directives/repeat';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {getAppContext} from '../../../services/app-context';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
 
 enum SortDropdownState {
   TIMESTAMP = 'Latest timestamp',
@@ -69,571 +57,521 @@
 
 export const __testOnly_SortDropdownState = SortDropdownState;
 
-@customElement('gr-thread-list')
-export class GrThreadList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+/**
+ * Order as follows:
+ * - Patchset level threads (descending based on patchset number)
+ * - unresolved
+ * - comments with drafts
+ * - comments without drafts
+ * - resolved
+ * - comments with drafts
+ * - comments without drafts
+ * - File name
+ * - Line number
+ * - Unresolved (descending based on patchset number)
+ * - comments with drafts
+ * - comments without drafts
+ * - Resolved (descending based on patchset number)
+ * - comments with drafts
+ * - comments without drafts
+ */
+export function compareThreads(
+  c1: CommentThread,
+  c2: CommentThread,
+  byTimestamp = false
+) {
+  if (byTimestamp) {
+    const c1Time = lastUpdated(c1)?.getTime() ?? 0;
+    const c2Time = lastUpdated(c2)?.getTime() ?? 0;
+    const timeDiff = c2Time - c1Time;
+    if (timeDiff !== 0) return c2Time - c1Time;
   }
 
-  @property({type: Object})
-  change?: ChangeInfo;
+  if (c1.path !== c2.path) {
+    // '/PATCHSET' will not come before '/COMMIT' when sorting
+    // alphabetically so move it to the front explicitly
+    if (c1.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+      return -1;
+    }
+    if (c2.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+      return 1;
+    }
+    return c1.path.localeCompare(c2.path);
+  }
 
+  // Convert 'FILE' and 'LOST' to undefined.
+  const line1 = typeof c1.line === 'number' ? c1.line : undefined;
+  const line2 = typeof c2.line === 'number' ? c2.line : undefined;
+  if (line1 !== line2) {
+    // one of them is a FILE/LOST comment, show first
+    if (line1 === undefined) return -1;
+    if (line2 === undefined) return 1;
+    // Lower line numbers first.
+    return line1 < line2 ? -1 : 1;
+  }
+
+  if (c1.patchNum !== c2.patchNum) {
+    // `patchNum` should be required, but show undefined first.
+    if (c1.patchNum === undefined) return -1;
+    if (c2.patchNum === undefined) return 1;
+    // Higher patchset numbers first.
+    return c1.patchNum > c2.patchNum ? -1 : 1;
+  }
+
+  // Sorting should not be based on the thread being unresolved or being a draft
+  // thread, because that would be a surprising re-sort when the thread changes
+  // state.
+
+  const c1Time = lastUpdated(c1)?.getTime() ?? 0;
+  const c2Time = lastUpdated(c2)?.getTime() ?? 0;
+  if (c2Time !== c1Time) {
+    // Newer comments first.
+    return c2Time - c1Time;
+  }
+
+  return 0;
+}
+
+@customElement('gr-thread-list')
+export class GrThreadList extends LitElement {
+  @queryAll('gr-comment-thread')
+  threadElements?: NodeList;
+
+  /**
+   * Raw list of threads for the component to show.
+   *
+   * ATTENTION! this.threads should never be used directly within the component.
+   *
+   * Either use getAllThreads(), which applies filters that are inherent to what
+   * the component is supposed to render,
+   * e.g. onlyShowRobotCommentsWithHumanReply.
+   *
+   * Or use getDisplayedThreads(), which applies the currently selected filters
+   * on top.
+   */
   @property({type: Array})
   threads: CommentThread[] = [];
 
-  @property({type: String})
-  changeNum?: NumericChangeId;
-
-  @property({type: Boolean})
-  loggedIn?: boolean;
-
-  @property({type: Array})
-  _sortedThreads: CommentThread[] = [];
-
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-comment-context'})
   showCommentContext = false;
 
-  @property({
-    computed:
-      '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
-      '_draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)',
-    type: Array,
-  })
-  _displayedThreads: CommentThread[] = [];
-
-  // thread-list is used in multiple places like the change log, hence
-  // keeping the default to be false. When used in comments tab, it's
-  // set as true.
-  @property({type: Boolean})
+  /** Along with `draftsOnly` is the currently selected filter. */
+  @property({type: Boolean, attribute: 'unresolved-only'})
   unresolvedOnly = false;
 
-  @property({type: Boolean})
-  _draftsOnly = false;
-
-  @property({type: Boolean})
+  @property({
+    type: Boolean,
+    attribute: 'only-show-robot-comments-with-human-reply',
+  })
   onlyShowRobotCommentsWithHumanReply = false;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'hide-dropdown'})
   hideDropdown = false;
 
-  @property({type: Object, observer: '_commentTabStateChange'})
-  commentTabState?: CommentTabState;
+  @property({type: Object, attribute: 'comment-tab-state'})
+  commentTabState?: TabState;
 
-  @property({type: Object})
-  sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
-
-  @property({type: Array, notify: true})
-  selectedAuthors: AccountInfo[] = [];
-
-  @property({type: Object})
-  account?: AccountDetailInfo;
-
-  @computed('unresolvedOnly', '_draftsOnly')
-  get commentsDropdownValue() {
-    // set initial value and triggered when comment summary chips are clicked
-    if (this._draftsOnly) return CommentTabState.DRAFTS;
-    return this.unresolvedOnly
-      ? CommentTabState.UNRESOLVED
-      : CommentTabState.SHOW_ALL;
-  }
-
-  @property({type: String})
+  @property({type: String, attribute: 'scroll-comment-id'})
   scrollCommentId?: UrlEncodedCommentId;
 
-  _showEmptyThreadsMessage(
-    threads: CommentThread[],
-    displayedThreads: CommentThread[],
-    unresolvedOnly: boolean
-  ) {
-    if (!threads || !displayedThreads) return false;
-    return !threads.length || (unresolvedOnly && !displayedThreads.length);
+  /**
+   * Optional context information when threads are being displayed for a
+   * specific change message. That influences which comments are expanded or
+   * collapsed by default.
+   */
+  @property({type: String, attribute: 'message-id'})
+  messageId?: ChangeMessageId;
+
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  change?: ParsedChangeInfo;
+
+  @state()
+  account?: AccountDetailInfo;
+
+  @state()
+  selectedAuthors: AccountInfo[] = [];
+
+  @state()
+  sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
+
+  /** Along with `unresolvedOnly` is the currently selected filter. */
+  @state()
+  draftsOnly = false;
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly userModel = getAppContext().userModel;
+
+  override connectedCallback(): void {
+    super.connectedCallback();
+    subscribe(
+      this,
+      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));
   }
 
-  _computeEmptyThreadsMessage(threads: CommentThread[]) {
-    return !threads.length ? 'No comments' : 'No unresolved comments';
+  override willUpdate(changed: PropertyValues) {
+    if (changed.has('commentTabState')) this.onCommentTabStateUpdate();
+    if (changed.has('scrollCommentId')) this.onScrollCommentIdUpdate();
   }
 
-  _showPartyPopper(threads: CommentThread[]) {
-    return !!threads.length;
-  }
-
-  _computeResolvedCommentsMessage(
-    threads: CommentThread[],
-    displayedThreads: CommentThread[],
-    unresolvedOnly: boolean,
-    onlyShowRobotCommentsWithHumanReply: boolean
-  ) {
-    if (onlyShowRobotCommentsWithHumanReply) {
-      threads = this.filterRobotThreadsWithoutHumanReply(threads) ?? [];
+  private onCommentTabStateUpdate() {
+    switch (this.commentTabState?.commentTab) {
+      case CommentTabState.UNRESOLVED:
+        this.handleOnlyUnresolved();
+        break;
+      case CommentTabState.DRAFTS:
+        this.handleOnlyDrafts();
+        break;
+      case CommentTabState.SHOW_ALL:
+        this.handleAllComments();
+        break;
     }
-    if (unresolvedOnly && threads.length && !displayedThreads.length) {
-      return `Show ${pluralize(threads.length, 'resolved comment')}`;
-    }
-    return '';
   }
 
-  _showResolvedCommentsButton(
-    threads: CommentThread[],
-    displayedThreads: CommentThread[],
-    unresolvedOnly: boolean
-  ) {
-    return unresolvedOnly && threads.length && !displayedThreads.length;
+  /**
+   * When user wants to scroll to a comment, render all comments so that the
+   * appropriate comment can be scrolled into view.
+   */
+  private onScrollCommentIdUpdate() {
+    if (this.scrollCommentId) this.handleAllComments();
   }
 
-  _handleResolvedCommentsMessageClick() {
-    this.unresolvedOnly = !this.unresolvedOnly;
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        #threads {
+          display: block;
+        }
+        gr-comment-thread {
+          display: block;
+          margin-bottom: var(--spacing-m);
+        }
+        .header {
+          align-items: center;
+          background-color: var(--background-color-primary);
+          border-bottom: 1px solid var(--border-color);
+          border-top: 1px solid var(--border-color);
+          display: flex;
+          justify-content: left;
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
+        .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
+        .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
+          display: block;
+        }
+        .thread-separator {
+          border-top: 1px solid var(--border-color);
+          margin-top: var(--spacing-xl);
+        }
+        .show-resolved-comments {
+          box-shadow: none;
+          padding-left: var(--spacing-m);
+        }
+        .partypopper {
+          margin-right: var(--spacing-s);
+        }
+        gr-dropdown-list {
+          --trigger-style-text-color: var(--primary-text-color);
+          --trigger-style-font-family: var(--font-family);
+        }
+        .filter-text,
+        .sort-text,
+        .author-text {
+          margin-right: var(--spacing-s);
+          color: var(--deemphasized-text-color);
+        }
+        .author-text {
+          margin-left: var(--spacing-m);
+        }
+        gr-account-label {
+          --account-max-length: 120px;
+          display: inline-block;
+          user-select: none;
+          --label-border-radius: 8px;
+          margin: 0 var(--spacing-xs);
+          padding: var(--spacing-xs) var(--spacing-m);
+          line-height: var(--line-height-normal);
+          cursor: pointer;
+        }
+        gr-account-label:focus {
+          outline: none;
+        }
+        gr-account-label:hover,
+        gr-account-label:hover {
+          box-shadow: var(--elevation-level-1);
+          cursor: pointer;
+        }
+      `,
+    ];
   }
 
-  getSortDropdownEntires() {
+  override render() {
+    return html`
+      ${this.renderDropdown()}
+      <div id="threads" part="threads">
+        ${this.renderEmptyThreadsMessage()} ${this.renderCommentThreads()}
+      </div>
+    `;
+  }
+
+  private renderDropdown() {
+    if (this.hideDropdown) return;
+    return html`
+      <div class="header">
+        <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()}
+        >
+        </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()}
+        >
+        </gr-dropdown-list>
+        ${this.renderAuthorChips()}
+      </div>
+    `;
+  }
+
+  private renderEmptyThreadsMessage() {
+    const threads = this.getAllThreads();
+    const threadsEmpty = threads.length === 0;
+    const displayedEmpty = this.getDisplayedThreads().length === 0;
+    if (!displayedEmpty) return;
+    const showPopper = this.unresolvedOnly && !threadsEmpty;
+    const popper = html`<span class="partypopper">&#x1F389;</span>`;
+    const showButton = this.unresolvedOnly && !threadsEmpty;
+    const button = html`
+      <gr-button
+        class="show-resolved-comments"
+        link
+        @click=${this.handleAllComments}
+        >Show ${pluralize(threads.length, 'resolved comment')}</gr-button
+      >
+    `;
+    return html`
+      <div>
+        <span>
+          ${showPopper ? popper : undefined}
+          ${threadsEmpty ? 'No comments' : 'No unresolved comments'}
+          ${showButton ? button : undefined}
+        </span>
+      </div>
+    `;
+  }
+
+  private renderCommentThreads() {
+    const threads = this.getDisplayedThreads();
+    return repeat(
+      threads,
+      thread => thread.rootId,
+      (thread, index) => {
+        const isFirst =
+          index === 0 || threads[index - 1].path !== threads[index].path;
+        const separator =
+          index !== 0 && isFirst
+            ? html`<div class="thread-separator"></div>`
+            : undefined;
+        const commentThread = this.renderCommentThread(thread, isFirst);
+        return html`${separator}${commentThread}`;
+      }
+    );
+  }
+
+  private renderCommentThread(thread: CommentThread, isFirst: boolean) {
+    return html`
+      <gr-comment-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=${() => {
+          this.requestUpdate();
+        }}
+      ></gr-comment-thread>
+    `;
+  }
+
+  private renderAuthorChips() {
+    const authors = getCommentAuthors(this.getDisplayedThreads(), this.account);
+    if (authors.length === 0) return;
+    return html`<span class="author-text">From:</span>${authors.map(author =>
+        this.renderAccountChip(author)
+      )}`;
+  }
+
+  private renderAccountChip(account: AccountInfo) {
+    const selected = this.selectedAuthors.some(
+      a => a._account_id === account._account_id
+    );
+    return html`
+      <gr-account-label
+        .account=${account}
+        @click=${this.handleAccountClicked}
+        selectionChipStyle
+        noStatusIcons
+        ?selected=${selected}
+      ></gr-account-label>
+    `;
+  }
+
+  private getCommentsDropdownValue() {
+    if (this.draftsOnly) return CommentTabState.DRAFTS;
+    if (this.unresolvedOnly) return CommentTabState.UNRESOLVED;
+    return CommentTabState.SHOW_ALL;
+  }
+
+  private getSortDropdownEntries() {
     return [
       {text: SortDropdownState.FILES, value: SortDropdownState.FILES},
       {text: SortDropdownState.TIMESTAMP, value: SortDropdownState.TIMESTAMP},
     ];
   }
 
-  getCommentsDropdownEntires(threads: CommentThread[], loggedIn?: boolean) {
-    const items: DropdownItem[] = [
-      {
-        text: `Unresolved (${this._countUnresolved(threads)})`,
-        value: CommentTabState.UNRESOLVED,
-      },
-      {
-        text: `All (${this._countAllThreads(threads)})`,
-        value: CommentTabState.SHOW_ALL,
-      },
-    ];
-    if (loggedIn)
-      items.splice(1, 0, {
-        text: `Drafts (${this._countDrafts(threads)})`,
+  // private, but visible for testing
+  getCommentsDropdownEntries() {
+    const items: DropdownItem[] = [];
+    const threads = this.getAllThreads();
+    items.push({
+      text: `Unresolved (${threads.filter(isUnresolved).length})`,
+      value: CommentTabState.UNRESOLVED,
+    });
+    if (this.account) {
+      items.push({
+        text: `Drafts (${threads.filter(isDraftThread).length})`,
         value: CommentTabState.DRAFTS,
       });
+    }
+    items.push({
+      text: `All (${threads.length})`,
+      value: CommentTabState.SHOW_ALL,
+    });
     return items;
   }
 
-  getCommentAuthors(threads?: CommentThread[], account?: AccountDetailInfo) {
-    return getCommentAuthors(threads, account);
-  }
-
-  handleAccountClicked(e: MouseEvent) {
+  private handleAccountClicked(e: MouseEvent) {
     const account = (e.target as GrAccountChip).account;
     assertIsDefined(account, 'account');
-    const index = this.selectedAuthors.findIndex(
-      author => author._account_id === account._account_id
-    );
-    if (index === -1) this.push('selectedAuthors', account);
-    else this.splice('selectedAuthors', index, 1);
-    // re-assign so that isSelected template method is called
-    this.selectedAuthors = [...this.selectedAuthors];
+    const predicate = (a: AccountInfo) => a._account_id === account._account_id;
+    const found = this.selectedAuthors.find(predicate);
+    if (found) {
+      this.selectedAuthors = this.selectedAuthors.filter(a => !predicate(a));
+    } else {
+      this.selectedAuthors = [...this.selectedAuthors, account];
+    }
   }
 
-  isSelected(author: AccountInfo, selectedAuthors: AccountInfo[]) {
-    return selectedAuthors.some(a => a._account_id === author._account_id);
-  }
-
-  computeShouldScrollIntoView(
-    comments: UIComment[],
-    scrollCommentId?: UrlEncodedCommentId
-  ) {
-    const comment = comments?.[0];
-    if (!comment) return false;
-    return computeId(comment) === scrollCommentId;
-  }
-
-  handleSortDropdownValueChange(e: CustomEvent) {
-    this.sortDropdownValue = e.detail.value;
-    /*
-     * Ideally we would have updateSortedThreads observe on sortDropdownValue
-     * but the method triggered re-render only when the length of threads
-     * changes, hence keep the explicit resortThreads method
-     */
-    this.resortThreads(this.threads);
-  }
-
+  // private, but visible for testing
   handleCommentsDropdownValueChange(e: CustomEvent) {
     const value = e.detail.value;
-    if (value === CommentTabState.UNRESOLVED) this._handleOnlyUnresolved();
-    else if (value === CommentTabState.DRAFTS) this._handleOnlyDrafts();
-    else this._handleAllComments();
-  }
-
-  _compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
-    if (
-      this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
-      !this.hideDropdown
-    ) {
-      if (c1.updated && c2.updated) return c1.updated > c2.updated ? -1 : 1;
+    switch (value) {
+      case CommentTabState.UNRESOLVED:
+        this.handleOnlyUnresolved();
+        break;
+      case CommentTabState.DRAFTS:
+        this.handleOnlyDrafts();
+        break;
+      default:
+        this.handleAllComments();
     }
-
-    if (c1.thread.path !== c2.thread.path) {
-      // '/PATCHSET' will not come before '/COMMIT' when sorting
-      // alphabetically so move it to the front explicitly
-      if (c1.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-        return -1;
-      }
-      if (c2.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-        return 1;
-      }
-      return c1.thread.path.localeCompare(c2.thread.path);
-    }
-
-    // Patchset comments have no line/range associated with them
-    if (c1.thread.line !== c2.thread.line) {
-      if (!c1.thread.line || !c2.thread.line) {
-        // one of them is a file level comment, show first
-        return c1.thread.line ? 1 : -1;
-      }
-      return c1.thread.line < c2.thread.line ? -1 : 1;
-    }
-
-    if (c1.thread.patchNum !== c2.thread.patchNum) {
-      if (!c1.thread.patchNum) return 1;
-      if (!c2.thread.patchNum) return -1;
-      // Threads left on Base when comparing Base vs X have patchNum = X
-      // and CommentSide = PARENT
-      // Threads left on 'edit' have patchNum set as latestPatchNum
-      return c1.thread.patchNum > c2.thread.patchNum ? -1 : 1;
-    }
-
-    if (c2.unresolved !== c1.unresolved) {
-      if (!c1.unresolved) return 1;
-      if (!c2.unresolved) return -1;
-    }
-
-    if (c2.hasDraft !== c1.hasDraft) {
-      if (!c1.hasDraft) return 1;
-      if (!c2.hasDraft) return -1;
-    }
-
-    if (c2.updated !== c1.updated) {
-      if (!c1.updated) return 1;
-      if (!c2.updated) return -1;
-      return c2.updated.getTime() - c1.updated.getTime();
-    }
-
-    if (c2.thread.rootId !== c1.thread.rootId) {
-      if (!c1.thread.rootId) return 1;
-      if (!c2.thread.rootId) return -1;
-      return c1.thread.rootId.localeCompare(c2.thread.rootId);
-    }
-
-    return 0;
-  }
-
-  resortThreads(threads: CommentThread[]) {
-    const threadsWithInfo = threads.map(thread =>
-      this._getThreadWithStatusInfo(thread)
-    );
-    this._sortedThreads = threadsWithInfo
-      .sort((t1, t2) => this._compareThreads(t1, t2))
-      .map(threadInfo => threadInfo.thread);
   }
 
   /**
-   * Observer on threads and update _sortedThreads when needed.
-   * Order as follows:
-   * - Patchset level threads (descending based on patchset number)
-   * - unresolved
-   * - comments with drafts
-   * - comments without drafts
-   * - resolved
-   * - comments with drafts
-   * - comments without drafts
-   * - File name
-   * - Line number
-   * - Unresolved (descending based on patchset number)
-   * - comments with drafts
-   * - comments without drafts
-   * - Resolved (descending based on patchset number)
-   * - comments with drafts
-   * - comments without drafts
-   *
-   * @param threads
-   * @param spliceRecord
+   * Returns all threads that the list may show.
    */
-  @observe('threads', 'threads.splices')
-  _updateSortedThreads(
-    threads: CommentThread[],
-    _: PolymerSpliceChange<CommentThread[]>
-  ) {
-    if (!threads || threads.length === 0) {
-      this._sortedThreads = [];
-      this._displayedThreads = [];
-      return;
-    }
-    // We only want to sort on thread additions / removals to avoid
-    // re-rendering on modifications (add new reply / edit draft etc.).
-    // https://polymer-library.polymer-project.org/3.0/docs/devguide/observers#array-observation
-    // TODO(TS): We have removed a buggy check of the splices here. A splice
-    // with addedCount > 0 or removed.length > 0 should also cause re-sorting
-    // and re-rendering, but apparently spliceRecord is always undefined for
-    // whatever reason.
-    // If there is an unsaved draftThread which is supposed to be replaced with
-    // a saved draftThread then resort all threads
-    const unsavedThread = this._sortedThreads.some(thread =>
-      thread.rootId?.includes('draft__')
-    );
-    if (this._sortedThreads.length === threads.length && !unsavedThread) {
-      // Instead of replacing the _sortedThreads which will trigger a re-render,
-      // we override all threads inside of it.
-      for (const thread of threads) {
-        const idxInSortedThreads = this._sortedThreads.findIndex(
-          t => t.rootId === thread.rootId
-        );
-        this.set(`_sortedThreads.${idxInSortedThreads}`, {...thread});
-      }
-      return;
-    }
-
-    this.resortThreads(threads);
-  }
-
-  _computeDisplayedThreads(
-    sortedThreadsRecord?: PolymerDeepPropertyChange<
-      CommentThread[],
-      CommentThread[]
-    >,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
-    return sortedThreadsRecord.base.filter(t =>
-      this._shouldShowThread(
-        t,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
+  // private, but visible for testing
+  getAllThreads() {
+    return this.threads.filter(
+      t =>
+        !this.onlyShowRobotCommentsWithHumanReply ||
+        !isRobotThread(t) ||
+        hasHumanReply(t)
     );
   }
 
-  _isFirstThreadWithFileName(
-    displayedThreads: CommentThread[],
-    thread: CommentThread,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    const threads = displayedThreads.filter(t =>
-      this._shouldShowThread(
-        t,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
-    );
-    const index = threads.findIndex(t => t.rootId === thread.rootId);
-    if (index === -1) {
-      return false;
-    }
-    return index === 0 || threads[index - 1].path !== threads[index].path;
+  /**
+   * Returns all threads that are currently shown in the list, respecting the
+   * currently selected filter.
+   */
+  // private, but visible for testing
+  getDisplayedThreads() {
+    const byTimestamp =
+      this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
+      !this.hideDropdown;
+    return this.getAllThreads()
+      .sort((t1, t2) => compareThreads(t1, t2, byTimestamp))
+      .filter(t => this.shouldShowThread(t));
   }
 
-  _shouldRenderSeparator(
-    displayedThreads: CommentThread[],
-    thread: CommentThread,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    const threads = displayedThreads.filter(t =>
-      this._shouldShowThread(
-        t,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
-    );
-    const index = threads.findIndex(t => t.rootId === thread.rootId);
-    if (index === -1) {
-      return false;
-    }
-    return (
-      index > 0 &&
-      this._isFirstThreadWithFileName(
-        displayedThreads,
-        thread,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
+  private isASelectedAuthor(account?: AccountInfo) {
+    if (!account) return false;
+    return this.selectedAuthors.some(
+      author => account._account_id === author._account_id
     );
   }
 
-  _shouldShowThread(
-    thread: CommentThread,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    if (
-      [
-        thread,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors,
-      ].includes(undefined)
-    ) {
-      return false;
+  private shouldShowThread(thread: CommentThread) {
+    // Never make a thread disappear while the user is editing it.
+    assertIsDefined(thread.rootId, 'thread.rootId');
+    const el = this.queryThreadElement(thread.rootId);
+    if (el?.editing) return true;
+
+    if (this.selectedAuthors.length > 0) {
+      const hasACommentFromASelectedAuthor = thread.comments.some(c =>
+        this.isASelectedAuthor(c.author)
+      );
+      if (!hasACommentFromASelectedAuthor) return false;
     }
 
-    if (selectedAuthors!.length) {
-      if (
-        !thread.comments.some(
-          c =>
-            c.author &&
-            selectedAuthors!.some(
-              author => c.author!._account_id === author._account_id
-            )
-        )
-      ) {
-        return false;
-      }
+    // This is probably redundant, because getAllThreads() filters this out.
+    if (this.onlyShowRobotCommentsWithHumanReply) {
+      if (isRobotThread(thread) && !hasHumanReply(thread)) return false;
     }
 
-    if (
-      !draftsOnly &&
-      !unresolvedOnly &&
-      !onlyShowRobotCommentsWithHumanReply
-    ) {
-      return true;
-    }
+    if (this.draftsOnly && !isDraftThread(thread)) return false;
+    if (this.unresolvedOnly && !isUnresolved(thread)) return false;
 
-    const threadInfo = this._getThreadWithStatusInfo(thread);
-
-    if (threadInfo.isEditing) {
-      return true;
-    }
-
-    if (
-      threadInfo.hasRobotComment &&
-      onlyShowRobotCommentsWithHumanReply &&
-      !threadInfo.hasHumanReplyToRobotComment
-    ) {
-      return false;
-    }
-
-    let filtersCheck = true;
-    if (draftsOnly && unresolvedOnly) {
-      filtersCheck = threadInfo.hasDraft && threadInfo.unresolved;
-    } else if (draftsOnly) {
-      filtersCheck = threadInfo.hasDraft;
-    } else if (unresolvedOnly) {
-      filtersCheck = threadInfo.unresolved;
-    }
-
-    return filtersCheck;
+    return true;
   }
 
-  _getThreadWithStatusInfo(thread: CommentThread): CommentThreadWithInfo {
-    const comments = thread.comments;
-    const lastComment = comments.length
-      ? comments[comments.length - 1]
-      : undefined;
-    const hasRobotComment = isRobotThread(thread);
-    const hasHumanReplyToRobotComment =
-      hasRobotComment && hasHumanReply(thread);
-    let updated = undefined;
-    if (lastComment) {
-      if (isDraft(lastComment)) updated = lastComment.__date;
-      if (lastComment.updated) updated = parseDate(lastComment.updated);
-    }
-
-    return {
-      thread,
-      hasRobotComment,
-      hasHumanReplyToRobotComment,
-      unresolved: !!lastComment && !!lastComment.unresolved,
-      isEditing: isDraft(lastComment) && !!lastComment.__editing,
-      hasDraft: !!lastComment && isDraft(lastComment),
-      updated,
-    };
-  }
-
-  _isOnParent(side?: CommentSide) {
-    // TODO(TS): That looks like a bug? CommentSide.REVISION will also be
-    // classified as parent??
-    return !!side;
-  }
-
-  _handleOnlyUnresolved() {
+  private handleOnlyUnresolved() {
     this.unresolvedOnly = true;
-    this._draftsOnly = false;
+    this.draftsOnly = false;
   }
 
-  _handleOnlyDrafts() {
-    this._draftsOnly = true;
+  private handleOnlyDrafts() {
+    this.draftsOnly = true;
     this.unresolvedOnly = false;
   }
 
-  _handleAllComments() {
-    this._draftsOnly = false;
+  private handleAllComments() {
+    this.draftsOnly = false;
     this.unresolvedOnly = false;
   }
 
-  _showAllComments(draftsOnly?: boolean, unresolvedOnly?: boolean) {
-    return !draftsOnly && !unresolvedOnly;
-  }
-
-  _countUnresolved(threads?: CommentThread[]) {
-    return (
-      this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isUnresolved)
-        .length ?? 0
-    );
-  }
-
-  _countAllThreads(threads?: CommentThread[]) {
-    return this.filterRobotThreadsWithoutHumanReply(threads)?.length ?? 0;
-  }
-
-  _countDrafts(threads?: CommentThread[]) {
-    return (
-      this.filterRobotThreadsWithoutHumanReply(threads)?.filter(isDraftThread)
-        .length ?? 0
-    );
-  }
-
-  filterRobotThreadsWithoutHumanReply(threads?: CommentThread[]) {
-    return threads?.filter(t => !isRobotThread(t) || hasHumanReply(t));
-  }
-
-  _commentTabStateChange(
-    newValue?: CommentTabState,
-    oldValue?: CommentTabState
-  ) {
-    if (!newValue || newValue === oldValue) return;
-    let focusTo: string | undefined;
-    switch (newValue) {
-      case CommentTabState.UNRESOLVED:
-        this._handleOnlyUnresolved();
-        // input is null because it's not rendered yet.
-        focusTo = '#unresolvedRadio';
-        break;
-      case CommentTabState.DRAFTS:
-        this._handleOnlyDrafts();
-        focusTo = '#draftsRadio';
-        break;
-      case CommentTabState.SHOW_ALL:
-        this._handleAllComments();
-        focusTo = '#allRadio';
-        break;
-      default:
-        assertNever(newValue, 'Unsupported preferred state');
-    }
-    const selector = focusTo;
-    window.setTimeout(() => {
-      const input = this.shadowRoot?.querySelector<HTMLInputElement>(selector);
-      input?.focus();
-    }, 0);
+  private queryThreadElement(rootId: string): GrCommentThread | undefined {
+    const els = [...(this.threadElements ?? [])] as GrCommentThread[];
+    return els.find(el => el.rootId === rootId);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
deleted file mode 100644
index 3eb28c9..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ /dev/null
@@ -1,170 +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">
-    #threads {
-      display: block;
-    }
-    gr-comment-thread {
-      display: block;
-      margin-bottom: var(--spacing-m);
-    }
-    .header {
-      align-items: center;
-      background-color: var(--background-color-primary);
-      border-bottom: 1px solid var(--border-color);
-      border-top: 1px solid var(--border-color);
-      display: flex;
-      justify-content: left;
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
-    .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
-    .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
-      display: block;
-    }
-    .thread-separator {
-      border-top: 1px solid var(--border-color);
-      margin-top: var(--spacing-xl);
-    }
-    .show-resolved-comments {
-      box-shadow: none;
-      padding-left: var(--spacing-m);
-    }
-    .partypopper{
-      margin-right: var(--spacing-s);
-    }
-    gr-dropdown-list {
-      --trigger-style-text-color: var(--primary-text-color);
-      --trigger-style-font-family: var(--font-family);
-    }
-    .filter-text, .sort-text, .author-text {
-      margin-right: var(--spacing-s);
-      color: var(--deemphasized-text-color);
-    }
-    .author-text {
-      margin-left: var(--spacing-m);
-    }
-    gr-account-label {
-      --account-max-length: 120px;
-      display: inline-block;
-      user-select: none;
-      --label-border-radius: 8px;
-      margin: 0 var(--spacing-xs);
-      padding: var(--spacing-xs) var(--spacing-m);
-      line-height: var(--line-height-normal);
-      cursor: pointer;
-    }
-    gr-account-label:focus {
-      outline: none;
-    }
-    gr-account-label:hover,
-    gr-account-label:hover {
-      box-shadow: var(--elevation-level-1);
-      cursor: pointer;
-    }
-  </style>
-  <template is="dom-if" if="[[!hideDropdown]]">
-    <div class="header">
-      <span class="sort-text">Sort By:</span>
-      <gr-dropdown-list
-        id="sortDropdown"
-        value="[[sortDropdownValue]]"
-        on-value-change="handleSortDropdownValueChange"
-        items="[[getSortDropdownEntires()]]"
-      >
-      </gr-dropdown-list>
-      <span class="separator"></span>
-      <span class="filter-text">Filter By:</span>
-      <gr-dropdown-list
-        id="filterDropdown"
-        value="[[commentsDropdownValue]]"
-        on-value-change="handleCommentsDropdownValueChange"
-        items="[[getCommentsDropdownEntires(threads, loggedIn)]]"
-      >
-      </gr-dropdown-list>
-      <template is="dom-if" if="[[_displayedThreads.length]]">
-        <span class="author-text">From:</span>
-        <template is="dom-repeat" items="[[getCommentAuthors(_displayedThreads, account)]]">
-          <gr-account-label
-            account="[[item]]"
-            on-click="handleAccountClicked"
-            selectionChipStyle
-            selected="[[isSelected(item, selectedAuthors)]]"
-          > </gr-account-label>
-        </template>
-      </template>
-    </div>
-  </template>
-  <div id="threads" part="threads">
-    <template
-      is="dom-if"
-      if="[[_showEmptyThreadsMessage(threads, _displayedThreads, unresolvedOnly)]]"
-    >
-      <div>
-        <span>
-          <template is="dom-if" if="[[_showPartyPopper(threads)]]">
-            <span class="partypopper">\&#x1F389</span>
-          </template>
-          [[_computeEmptyThreadsMessage(threads, _displayedThreads,
-          unresolvedOnly)]]
-          <template is="dom-if" if="[[_showResolvedCommentsButton(threads, _displayedThreads, unresolvedOnly)]]">
-            <gr-button
-              class="show-resolved-comments"
-              link
-              on-click="_handleResolvedCommentsMessageClick">
-                [[_computeResolvedCommentsMessage(threads, _displayedThreads,
-                unresolvedOnly, onlyShowRobotCommentsWithHumanReply)]]
-            </gr-button>
-          </template>
-        </span>
-      </div>
-    </template>
-    <template
-      is="dom-repeat"
-      items="[[_displayedThreads]]"
-      as="thread"
-      initial-count="10"
-      target-framerate="60"
-    >
-      <template
-        is="dom-if"
-        if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
-      >
-        <div class="thread-separator"></div>
-      </template>
-      <gr-comment-thread
-        show-file-path=""
-        show-ported-comment="[[thread.ported]]"
-        show-comment-context="[[showCommentContext]]"
-        change-num="[[changeNum]]"
-        comments="[[thread.comments]]"
-        diff-side="[[thread.diffSide]]"
-        show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
-        project-name="[[change.project]]"
-        is-on-parent="[[_isOnParent(thread.commentSide)]]"
-        line-num="[[thread.line]]"
-        patch-num="[[thread.patchNum]]"
-        path="[[thread.path]]"
-        root-id="{{thread.rootId}}"
-        should-scroll-into-view="[[computeShouldScrollIntoView(thread.comments, scrollCommentId)]]"
-      ></gr-comment-thread>
-    </template>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
deleted file mode 100644
index aab5cee..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ /dev/null
@@ -1,673 +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-thread-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {CommentTabState} from '../../../types/events.js';
-import {__testOnly_SortDropdownState} from './gr-thread-list.js';
-import {queryAll} from '../../../test/test-utils.js';
-import {accountOrGroupKey} from '../../../utils/account-util.js';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
-import {createAccountDetailWithId} from '../../../test/test-data-generators.js';
-
-const basicFixture = fixtureFromElement('gr-thread-list');
-
-suite('gr-thread-list tests', () => {
-  let element;
-
-  function getVisibleThreads() {
-    return [...dom(element.root)
-        .querySelectorAll('gr-comment-thread')]
-        .filter(e => e.style.display !== 'none');
-  }
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    element.changeNum = 123;
-    element.change = {
-      project: 'testRepo',
-    };
-    element.threads = [
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000001,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'ecf0b9fa_fe1a5f62',
-            line: 5,
-            updated: '1',
-            message: 'test',
-            unresolved: true,
-          },
-          {
-            id: '503008e2_0ab203ee',
-            path: '/COMMIT_MSG',
-            line: 5,
-            in_reply_to: 'ecf0b9fa_fe1a5f62',
-            updated: '1',
-            message: 'draft',
-            unresolved: true,
-            __draft: true,
-            __draftID: '0.m683trwff68',
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 5,
-        rootId: 'ecf0b9fa_fe1a5f62',
-        updated: '1',
-      },
-      {
-        comments: [
-          {
-            path: 'test.txt',
-            author: {
-              _account_id: 1000002,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 3,
-            id: '09a9fb0a_1484e6cf',
-            side: 'PARENT',
-            updated: '2',
-            message: 'Some comment on another patchset.',
-            unresolved: false,
-          },
-        ],
-        patchNum: 3,
-        path: 'test.txt',
-        rootId: '09a9fb0a_1484e6cf',
-        updated: '2',
-        commentSide: 'PARENT',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000002,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 2,
-            id: '8caddf38_44770ec1',
-            updated: '3',
-            message: 'Another unresolved comment',
-            unresolved: false,
-          },
-        ],
-        patchNum: 2,
-        path: '/COMMIT_MSG',
-        rootId: '8caddf38_44770ec1',
-        updated: '3',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000003,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 2,
-            id: 'scaddf38_44770ec1',
-            line: 4,
-            updated: '4',
-            message: 'Yet another unresolved comment',
-            unresolved: true,
-          },
-        ],
-        patchNum: 2,
-        path: '/COMMIT_MSG',
-        line: 4,
-        rootId: 'scaddf38_44770ec1',
-        updated: '4',
-      },
-      {
-        comments: [
-          {
-            id: 'zcf0b9fa_fe1a5f62',
-            path: '/COMMIT_MSG',
-            line: 6,
-            updated: '5',
-            message: 'resolved draft',
-            unresolved: false,
-            __draft: true,
-            __draftID: '0.m683trwff69',
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 6,
-        rootId: 'zcf0b9fa_fe1a5f62',
-        updated: '5',
-      },
-      {
-        comments: [
-          {
-            id: 'patchset_level_1',
-            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-            updated: '6',
-            message: 'patchset comment 1',
-            unresolved: false,
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 2,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        rootId: 'patchset_level_1',
-        updated: '6',
-      },
-      {
-        comments: [
-          {
-            id: 'patchset_level_2',
-            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-            updated: '7',
-            message: 'patchset comment 2',
-            unresolved: false,
-            __editing: false,
-            patch_set: '3',
-          },
-        ],
-        patchNum: 3,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        rootId: 'patchset_level_2',
-        updated: '7',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'rc1',
-            line: 5,
-            updated: '8',
-            message: 'test',
-            unresolved: true,
-            robot_id: 'rc1',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 5,
-        rootId: 'rc1',
-        updated: '8',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'rc2',
-            line: 7,
-            updated: '9',
-            message: 'test',
-            unresolved: true,
-            robot_id: 'rc2',
-          },
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'c2_1',
-            line: 5,
-            updated: '10',
-            message: 'test',
-            unresolved: true,
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 7,
-        rootId: 'rc2',
-        updated: '10',
-      },
-    ];
-
-    // use flush to render all (bypass initial-count set on dom-repeat)
-    await flush();
-  });
-
-  test('draft dropdown item only appears when logged in', () => {
-    element.loggedIn = false;
-    flush();
-    assert.equal(element.getCommentsDropdownEntires(element.threads,
-        element.loggedIn).length, 2);
-    element.loggedIn = true;
-    flush();
-    assert.equal(element.getCommentsDropdownEntires(element.threads,
-        element.loggedIn).length, 3);
-  });
-
-  test('show all threads by default', () => {
-    assert.equal(dom(element.root)
-        .querySelectorAll('gr-comment-thread').length, element.threads.length);
-    assert.equal(getVisibleThreads().length, element.threads.length);
-  });
-
-  test('show unresolved threads if unresolvedOnly is set', async () => {
-    element.unresolvedOnly = true;
-    await flush();
-    const unresolvedThreads = element.threads.filter(t => t.comments.some(
-        c => c.unresolved
-    ));
-    assert.equal(getVisibleThreads().length, unresolvedThreads.length);
-  });
-
-  test('showing file name takes visible threads into account', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
-        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
-        element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
-    true);
-    element.unresolvedOnly = true;
-    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
-        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
-        element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
-    false);
-  });
-
-  test('onlyShowRobotCommentsWithHumanReply ', () => {
-    element.onlyShowRobotCommentsWithHumanReply = true;
-    flush();
-    assert.equal(
-        getVisibleThreads().length,
-        element.threads.length - 1);
-    assert.isNotOk(getVisibleThreads().find(th => th.rootId === 'rc1'));
-  });
-
-  suite('_compareThreads', () => {
-    setup(() => {
-      element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    });
-
-    test('patchset comes before any other file', () => {
-      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS}};
-      const t2 = {thread: {path: SpecialFilePath.COMMIT_MESSAGE}};
-
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-
-      // assigning values to properties such that t2 should come first
-      t1.patchNum = 1;
-      t2.patchNum = 2;
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('file path is compared lexicographically', () => {
-      const t1 = {thread: {path: 'a.txt'}};
-      const t2 = {thread: {path: 'b.txt'}};
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-
-      t1.patchNum = 1;
-      t2.patchNum = 2;
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('patchset comments sorted by reverse patchset', () => {
-      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 1}};
-      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 2}};
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-    });
-
-    test('patchset comments with same patchset picks unresolved first', () => {
-      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 1}, unresolved: true};
-      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 1}, unresolved: false};
-      t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('file level comment before line', () => {
-      const t1 = {thread: {path: 'a.txt', line: 2}};
-      const t2 = {thread: {path: 'a.txt'}};
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-
-      // give preference to t1 in unresolved/draft properties
-      t1.unresolved = t1.hasDraft = true;
-      t2.unresolved = t2.unresolved = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-    });
-
-    test('comments sorted by line', () => {
-      const t1 = {thread: {path: 'a.txt', line: 2}};
-      const t2 = {thread: {path: 'a.txt', line: 3}};
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('comments on same line sorted by reverse patchset', () => {
-      const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1}};
-      const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 2}};
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-
-      // give preference to t1 in unresolved/draft properties
-      t1.unresolved = t1.hasDraft = true;
-      t2.unresolved = t2.unresolved = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-    });
-
-    test('comments on same line & patchset sorted by unresolved first',
-        () => {
-          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: true};
-          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: false};
-          t1.patchNum = t2.patchNum = 1;
-          assert.equal(element._compareThreads(t1, t2), -1);
-          assert.equal(element._compareThreads(t2, t1), 1);
-
-          t2.hasDraft = true;
-          t1.hasDraft = false;
-          assert.equal(element._compareThreads(t1, t2), -1);
-          assert.equal(element._compareThreads(t2, t1), 1);
-        });
-
-    test('comments on same line & patchset & unresolved sorted by draft',
-        () => {
-          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: true, hasDraft: false};
-          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: true, hasDraft: true};
-          t1.patchNum = t2.patchNum = 1;
-          assert.equal(element._compareThreads(t1, t2), 1);
-          assert.equal(element._compareThreads(t2, t1), -1);
-        });
-  });
-
-  test('_computeSortedThreads', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    assert.equal(element._sortedThreads.length, 9);
-    const expectedSortedRootIds = [
-      'patchset_level_2', // Posted on Patchset 3
-      'patchset_level_1', // Posted on Patchset 2
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      'rc2', // Line 7 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('_computeSortedThreads with timestamp', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
-    element.resortThreads(element.threads);
-    assert.equal(element._sortedThreads.length, 9);
-    const expectedSortedRootIds = [
-      'rc2',
-      'rc1',
-      'patchset_level_2',
-      'patchset_level_1',
-      'zcf0b9fa_fe1a5f62',
-      'scaddf38_44770ec1',
-      '8caddf38_44770ec1',
-      '09a9fb0a_1484e6cf',
-      'ecf0b9fa_fe1a5f62',
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('tapping single author chips', () => {
-    element.account = createAccountDetailWithId(1);
-    flush();
-    const chips = Array.from(queryAll(element, 'gr-account-label'));
-    const authors = chips.map(
-        chip => accountOrGroupKey(chip.account))
-        .sort();
-    assert.deepEqual(authors, [1, 1000000, 1000001, 1000002, 1000003]);
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 9);
-
-    // accountId 1000001
-    const chip = chips.find(chip => chip.account._account_id === 1000001);
-
-    tap(chip);
-    flush();
-
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 1);
-    assert.equal(element._displayedThreads[0].comments[0].author._account_id,
-        1000001);
-
-    tap(chip); // tapping again resets
-    flush();
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 9);
-  });
-
-  test('tapping multiple author chips', () => {
-    element.account = createAccountDetailWithId(1);
-    flush();
-    const chips = Array.from(queryAll(element, 'gr-account-label'));
-
-    tap(chips.find(chip => chip.account._account_id === 1000001));
-    tap(chips.find(chip => chip.account._account_id === 1000002));
-    flush();
-
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 3);
-    assert.equal(element._displayedThreads[0].comments[0].author._account_id,
-        1000002);
-    assert.equal(element._displayedThreads[1].comments[0].author._account_id,
-        1000002);
-    assert.equal(element._displayedThreads[2].comments[0].author._account_id,
-        1000001);
-  });
-
-  test('thread removal and sort again', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    const index = element.threads.findIndex(t => t.rootId === 'rc2');
-    element.threads.splice(index, 1);
-    element.threads = [...element.threads]; // trigger observers
-    flush();
-    assert.equal(element._sortedThreads.length, 8);
-    const expectedSortedRootIds = [
-      'patchset_level_2',
-      'patchset_level_1',
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('modification on thread shold not trigger sort again', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    const currentSortedThreads = [...element._sortedThreads];
-    for (const thread of currentSortedThreads) {
-      thread.comments = [...thread.comments];
-    }
-    const modifiedThreads = [...element.threads];
-    modifiedThreads[5] = {...modifiedThreads[5]};
-    modifiedThreads[5].comments = [...modifiedThreads[5].comments, {
-      ...modifiedThreads[5].comments[0],
-      unresolved: false,
-    }];
-    element.threads = modifiedThreads;
-    assert.notDeepEqual(currentSortedThreads, element._sortedThreads);
-
-    // exact same order as in _computeSortedThreads
-    const expectedSortedRootIds = [
-      'patchset_level_2',
-      'patchset_level_1',
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      'rc2', // Line 7 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('reset sortedThreads when threads set to undefiend', () => {
-    element.threads = undefined;
-    assert.deepEqual(element._sortedThreads, []);
-  });
-
-  test('non-equal length of sortThreads and threads' +
-    ' should trigger sort again', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    const modifiedThreads = [...element.threads];
-    const currentSortedThreads = [...element._sortedThreads];
-    element._sortedThreads = [];
-    element.threads = modifiedThreads;
-    assert.deepEqual(currentSortedThreads, element._sortedThreads);
-
-    // exact same order as in _computeSortedThreads
-    const expectedSortedRootIds = [
-      'patchset_level_2',
-      'patchset_level_1',
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      'rc2', // Line 7 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('show all comments', () => {
-    element.handleCommentsDropdownValueChange({detail: {
-      value: CommentTabState.SHOW_ALL}});
-    flush();
-    assert.equal(getVisibleThreads().length, 9);
-  });
-
-  test('unresolved shows all unresolved comments', () => {
-    element.handleCommentsDropdownValueChange({detail: {
-      value: CommentTabState.UNRESOLVED}});
-    flush();
-    assert.equal(getVisibleThreads().length, 4);
-  });
-
-  test('toggle drafts only shows threads with draft comments', () => {
-    element.handleCommentsDropdownValueChange({detail: {
-      value: CommentTabState.DRAFTS}});
-    flush();
-    assert.equal(getVisibleThreads().length, 2);
-  });
-
-  suite('hideDropdown', () => {
-    setup(async () => {
-      element.hideDropdown = true;
-      await flush();
-    });
-
-    test('toggle buttons are hidden', () => {
-      assert.equal(element.shadowRoot.querySelector('.header').style.display,
-          'none');
-    });
-  });
-
-  suite('empty thread', () => {
-    setup(async () => {
-      element.threads = [];
-      await flush();
-    });
-
-    test('default empty message should show', () => {
-      assert.isTrue(
-          element.shadowRoot.querySelector('#threads').textContent.trim()
-              .includes('No comments'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
new file mode 100644
index 0000000..33a4e21
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
@@ -0,0 +1,553 @@
+/**
+ * @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';
+import './gr-thread-list';
+import {CommentSide, SpecialFilePath} from '../../../constants/constants';
+import {CommentTabState} from '../../../types/events';
+import {
+  compareThreads,
+  GrThreadList,
+  __testOnly_SortDropdownState,
+} from './gr-thread-list';
+import {queryAll} from '../../../test/test-utils';
+import {accountOrGroupKey} from '../../../utils/account-util';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  createAccountDetailWithId,
+  createParsedChange,
+  createThread,
+} from '../../../test/test-data-generators';
+import {
+  AccountId,
+  NumericChangeId,
+  PatchSetNum,
+  Timestamp,
+} from '../../../api/rest-api';
+import {RobotId, UrlEncodedCommentId} from '../../../types/common';
+import {CommentThread} from '../../../utils/comment-util';
+import {query, queryAndAssert} from '../../../utils/common-util';
+import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
+
+const basicFixture = fixtureFromElement('gr-thread-list');
+
+suite('gr-thread-list tests', () => {
+  let element: GrThreadList;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    element.changeNum = 123 as NumericChangeId;
+    element.change = createParsedChange();
+    element.account = createAccountDetailWithId();
+    element.threads = [
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000001 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4 as PatchSetNum,
+            id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+            line: 5,
+            updated: '2015-12-01 15:15:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+          },
+          {
+            id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+            path: '/COMMIT_MSG',
+            line: 5,
+            in_reply_to: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+            updated: '2015-12-01 15:16:15.000000000' as Timestamp,
+            message: 'draft',
+            unresolved: true,
+            __draft: true,
+            patch_set: '2' as PatchSetNum,
+          },
+        ],
+        patchNum: 4 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: 'test.txt',
+            author: {
+              _account_id: 1000002 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 3 as PatchSetNum,
+            id: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+            updated: '2015-12-02 15:16:15.000000000' as Timestamp,
+            message: 'Some comment on another patchset.',
+            unresolved: false,
+          },
+        ],
+        patchNum: 3 as PatchSetNum,
+        path: 'test.txt',
+        rootId: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000002 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 2 as PatchSetNum,
+            id: '8caddf38_44770ec1' as UrlEncodedCommentId,
+            updated: '2015-12-03 15:16:15.000000000' as Timestamp,
+            message: 'Another unresolved comment',
+            unresolved: false,
+          },
+        ],
+        patchNum: 2 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000003 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 2 as PatchSetNum,
+            id: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+            line: 4,
+            updated: '2015-12-04 15:16:15.000000000' as Timestamp,
+            message: 'Yet another unresolved comment',
+            unresolved: true,
+          },
+        ],
+        patchNum: 2 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 4,
+        rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            id: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+            path: '/COMMIT_MSG',
+            line: 6,
+            updated: '2015-12-05 15:16:15.000000000' as Timestamp,
+            message: 'resolved draft',
+            unresolved: false,
+            __draft: true,
+            patch_set: '2' as PatchSetNum,
+          },
+        ],
+        patchNum: 4 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 6,
+        rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            id: 'patchset_level_1' as UrlEncodedCommentId,
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2015-12-06 15:16:15.000000000' as Timestamp,
+            message: 'patchset comment 1',
+            unresolved: false,
+            patch_set: '2' as PatchSetNum,
+          },
+        ],
+        patchNum: 2 as PatchSetNum,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            id: 'patchset_level_2' as UrlEncodedCommentId,
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2015-12-07 15:16:15.000000000' as Timestamp,
+            message: 'patchset comment 2',
+            unresolved: false,
+            patch_set: '3' as PatchSetNum,
+          },
+        ],
+        patchNum: 3 as PatchSetNum,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_2' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4 as PatchSetNum,
+            id: 'rc1' as UrlEncodedCommentId,
+            line: 5,
+            updated: '2015-12-08 15:16:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc1' as RobotId,
+          },
+        ],
+        patchNum: 4 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'rc1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4 as PatchSetNum,
+            id: 'rc2' as UrlEncodedCommentId,
+            line: 7,
+            updated: '2015-12-09 15:16:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc2' as RobotId,
+          },
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000 as AccountId,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4 as PatchSetNum,
+            id: 'c2_1' as UrlEncodedCommentId,
+            line: 5,
+            updated: '2015-12-10 15:16:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+          },
+        ],
+        patchNum: 4 as PatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 7,
+        rootId: 'rc2' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+    ];
+    await element.updateComplete;
+  });
+
+  suite('sort threads', () => {
+    test('sort all threads', () => {
+      element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
+      assert.equal(element.getDisplayedThreads().length, 9);
+      const expected: UrlEncodedCommentId[] = [
+        'patchset_level_2' as UrlEncodedCommentId, // Posted on Patchset 3
+        'patchset_level_1' as UrlEncodedCommentId, // Posted on Patchset 2
+        '8caddf38_44770ec1' as UrlEncodedCommentId, // File level on COMMIT_MSG
+        'scaddf38_44770ec1' as UrlEncodedCommentId, // Line 4 on COMMIT_MSG
+        'rc1' as UrlEncodedCommentId, // Line 5 on COMMIT_MESSAGE newer
+        'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId, // Line 5 on COMMIT_MESSAGE older
+        'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId, // Line 6 on COMMIT_MSG
+        'rc2' as UrlEncodedCommentId, // Line 7 on COMMIT_MSG
+        '09a9fb0a_1484e6cf' as UrlEncodedCommentId, // File level on test.txt
+      ];
+      const actual = element.getDisplayedThreads().map(t => t.rootId);
+      assert.sameOrderedMembers(actual, expected);
+    });
+
+    test('sort all threads by timestamp', () => {
+      element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
+      assert.equal(element.getDisplayedThreads().length, 9);
+      const expected: UrlEncodedCommentId[] = [
+        'rc2' as UrlEncodedCommentId,
+        'rc1' as UrlEncodedCommentId,
+        'patchset_level_2' as UrlEncodedCommentId,
+        'patchset_level_1' as UrlEncodedCommentId,
+        'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        'scaddf38_44770ec1' as UrlEncodedCommentId,
+        '8caddf38_44770ec1' as UrlEncodedCommentId,
+        '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+        'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+      ];
+      const actual = element.getDisplayedThreads().map(t => t.rootId);
+      assert.sameOrderedMembers(actual, expected);
+    });
+  });
+
+  test('renders', async () => {
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="header">
+        <span class="sort-text">Sort By:</span>
+        <gr-dropdown-list id="sortDropdown"></gr-dropdown-list>
+        <span class="separator"></span>
+        <span class="filter-text">Filter By:</span>
+        <gr-dropdown-list id="filterDropdown"></gr-dropdown-list>
+        <span class="author-text">From:</span>
+        <gr-account-label
+          deselected=""
+          selectionchipstyle=""
+          nostatusicons=""
+        ></gr-account-label>
+        <gr-account-label
+          deselected=""
+          selectionchipstyle=""
+          nostatusicons=""
+        ></gr-account-label>
+        <gr-account-label
+          deselected=""
+          selectionchipstyle=""
+          nostatusicons=""
+        ></gr-account-label>
+        <gr-account-label
+          deselected=""
+          selectionchipstyle=""
+          nostatusicons=""
+        ></gr-account-label>
+        <gr-account-label
+          deselected=""
+          selectionchipstyle=""
+          nostatusicons=""
+        ></gr-account-label>
+      </div>
+      <div id="threads" part="threads">
+        <gr-comment-thread
+          show-file-name=""
+          show-file-path=""
+        ></gr-comment-thread>
+        <gr-comment-thread show-file-path=""></gr-comment-thread>
+        <div class="thread-separator"></div>
+        <gr-comment-thread
+          show-file-name=""
+          show-file-path=""
+        ></gr-comment-thread>
+        <gr-comment-thread show-file-path=""></gr-comment-thread>
+        <div class="thread-separator"></div>
+        <gr-comment-thread
+          has-draft=""
+          show-file-name=""
+          show-file-path=""
+        ></gr-comment-thread>
+        <gr-comment-thread show-file-path=""></gr-comment-thread>
+        <gr-comment-thread show-file-path=""></gr-comment-thread>
+        <div class="thread-separator"></div>
+        <gr-comment-thread
+          show-file-name=""
+          show-file-path=""
+        ></gr-comment-thread>
+        <div class="thread-separator"></div>
+        <gr-comment-thread
+          has-draft=""
+          show-file-name=""
+          show-file-path=""
+        ></gr-comment-thread>
+      </div>
+    `);
+  });
+
+  test('renders empty', async () => {
+    element.threads = [];
+    await element.updateComplete;
+    expect(queryAndAssert(element, 'div#threads')).dom.to.equal(/* HTML */ `
+      <div id="threads" part="threads">
+        <div><span>No comments</span></div>
+      </div>
+    `);
+  });
+
+  test('tapping single author chips', async () => {
+    element.account = createAccountDetailWithId(1);
+    await element.updateComplete;
+    const chips = Array.from(
+      queryAll<GrAccountLabel>(element, 'gr-account-label')
+    );
+    const authors = chips.map(chip => accountOrGroupKey(chip.account!)).sort();
+    assert.deepEqual(authors, [
+      1 as AccountId,
+      1000000 as AccountId,
+      1000001 as AccountId,
+      1000002 as AccountId,
+      1000003 as AccountId,
+    ]);
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 9);
+
+    const chip = chips.find(chip => chip.account!._account_id === 1000001);
+    tap(chip!);
+    await element.updateComplete;
+
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 1);
+    assert.equal(
+      element.getDisplayedThreads()[0].comments[0].author?._account_id,
+      1000001 as AccountId
+    );
+
+    tap(chip!);
+    await element.updateComplete;
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 9);
+  });
+
+  test('tapping multiple author chips', async () => {
+    element.account = createAccountDetailWithId(1);
+    await element.updateComplete;
+    const chips = Array.from(
+      queryAll<GrAccountLabel>(element, 'gr-account-label')
+    );
+
+    tap(chips.find(chip => chip.account?._account_id === 1000001)!);
+    tap(chips.find(chip => chip.account?._account_id === 1000002)!);
+    await element.updateComplete;
+
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 3);
+    assert.equal(
+      element.getDisplayedThreads()[0].comments[0].author?._account_id,
+      1000002 as AccountId
+    );
+    assert.equal(
+      element.getDisplayedThreads()[1].comments[0].author?._account_id,
+      1000002 as AccountId
+    );
+    assert.equal(
+      element.getDisplayedThreads()[2].comments[0].author?._account_id,
+      1000001 as AccountId
+    );
+  });
+
+  test('show all comments', async () => {
+    const event = new CustomEvent('value-changed', {
+      detail: {value: CommentTabState.SHOW_ALL},
+    });
+    element.handleCommentsDropdownValueChange(event);
+    await element.updateComplete;
+    assert.equal(element.getDisplayedThreads().length, 9);
+  });
+
+  test('unresolved shows all unresolved comments', async () => {
+    const event = new CustomEvent('value-changed', {
+      detail: {value: CommentTabState.UNRESOLVED},
+    });
+    element.handleCommentsDropdownValueChange(event);
+    await element.updateComplete;
+    assert.equal(element.getDisplayedThreads().length, 4);
+  });
+
+  test('toggle drafts only shows threads with draft comments', async () => {
+    const event = new CustomEvent('value-changed', {
+      detail: {value: CommentTabState.DRAFTS},
+    });
+    element.handleCommentsDropdownValueChange(event);
+    await element.updateComplete;
+    assert.equal(element.getDisplayedThreads().length, 2);
+  });
+
+  suite('hideDropdown', () => {
+    test('header hidden for hideDropdown=true', async () => {
+      element.hideDropdown = true;
+      await element.updateComplete;
+      assert.isUndefined(query(element, '.header'));
+    });
+
+    test('header shown for hideDropdown=false', async () => {
+      element.hideDropdown = false;
+      await element.updateComplete;
+      assert.isDefined(query(element, '.header'));
+    });
+  });
+
+  suite('empty thread', () => {
+    setup(async () => {
+      element.threads = [];
+      await element.updateComplete;
+    });
+
+    test('default empty message should show', () => {
+      const threadsEl = queryAndAssert(element, '#threads');
+      assert.isTrue(threadsEl.textContent?.trim().includes('No comments'));
+    });
+  });
+});
+
+suite('compareThreads', () => {
+  let t1: CommentThread;
+  let t2: CommentThread;
+
+  const sortPredicate = (thread1: CommentThread, thread2: CommentThread) =>
+    compareThreads(thread1, thread2);
+
+  const checkOrder = (expected: CommentThread[]) => {
+    assert.sameOrderedMembers([t1, t2].sort(sortPredicate), expected);
+    assert.sameOrderedMembers([t2, t1].sort(sortPredicate), expected);
+  };
+
+  setup(() => {
+    t1 = createThread({});
+    t2 = createThread({});
+  });
+
+  test('patchset-level before file comments', () => {
+    t1.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+    t2.path = SpecialFilePath.COMMIT_MESSAGE;
+    checkOrder([t1, t2]);
+  });
+
+  test('paths lexicographically', () => {
+    t1.path = 'a.txt';
+    t2.path = 'b.txt';
+    checkOrder([t1, t2]);
+  });
+
+  test('patchsets in reverse order', () => {
+    t1.patchNum = 2 as PatchSetNum;
+    t2.patchNum = 3 as PatchSetNum;
+    checkOrder([t2, t1]);
+  });
+
+  test('file level comment before line', () => {
+    t1.line = 123;
+    t2.line = 'FILE';
+    checkOrder([t2, t1]);
+  });
+
+  test('comments sorted by line', () => {
+    t1.line = 123;
+    t2.line = 321;
+    checkOrder([t1, t2]);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
index 552cc69..33c2eac 100644
--- a/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
@@ -18,6 +18,7 @@
 import {css, html, LitElement} from 'lit';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {LabelInfo} from '../../../api/rest-api';
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
 const base = HovercardMixin(LitElement);
@@ -27,6 +28,9 @@
   @property()
   labelName?: string;
 
+  @property({type: Object})
+  labelInfo?: LabelInfo;
+
   static override get styles() {
     return [
       fontStyles,
@@ -83,6 +87,18 @@
           </div>
         </div>
       </div>
+      ${this.renderDescription()}
+    </div>`;
+  }
+
+  private renderDescription() {
+    const description = this.labelInfo?.description;
+    if (!description) return;
+    return html`<div class="section description">
+      <div class="sectionIcon">
+        <iron-icon icon="gr-icons:description"></iron-icon>
+      </div>
+      <div class="sectionContent">${description}</div>
     </div>`;
   }
 }
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
new file mode 100644
index 0000000..f2bd350
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
@@ -0,0 +1,152 @@
+/**
+ * @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 '../../shared/gr-label-info/gr-label-info';
+import '../../shared/gr-vote-chip/gr-vote-chip';
+import '../gr-trigger-vote-hovercard/gr-trigger-vote-hovercard';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {ParsedChangeInfo} from '../../../types/types';
+import {
+  AccountInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+  LabelInfo,
+} from '../../../api/rest-api';
+import {
+  getAllUniqueApprovals,
+  hasNeutralStatus,
+} from '../../../utils/label-util';
+
+@customElement('gr-trigger-vote')
+export class GrTriggerVote extends LitElement {
+  @property()
+  label?: string;
+
+  @property({type: Object})
+  labelInfo?: LabelInfo;
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  /**
+   * If defined, trigger-vote is shown with this value instead of the latest
+   * vote. This is useful for change log.
+   */
+  @property()
+  displayValue?: string;
+
+  @property({type: Boolean})
+  mutable?: boolean;
+
+  @property({type: Boolean, attribute: 'disable-hovercards'})
+  disableHovercards = false;
+
+  static override get styles() {
+    return css`
+      :host {
+        display: block;
+      }
+      .container {
+        box-sizing: border-box;
+        border: 1px solid var(--border-color);
+        border-radius: calc(var(--border-radius) + 2px);
+        background-color: var(--background-color-primary);
+        display: flex;
+        padding: 0;
+        padding-left: var(--spacing-s);
+        padding-right: var(--spacing-xxs);
+        align-items: center;
+      }
+      .label {
+        padding-right: var(--spacing-s);
+        font-weight: var(--font-weight-bold);
+      }
+      gr-vote-chip {
+        --gr-vote-chip-width: 14px;
+        --gr-vote-chip-height: 14px;
+        margin-right: 0px;
+        margin-left: var(--spacing-xs);
+      }
+      gr-vote-chip:first-of-type {
+        margin-left: 0px;
+      }
+    `;
+  }
+
+  override render() {
+    if (!this.labelInfo) return;
+    return html`
+      <div class="container">
+        ${this.renderHovercard()}
+        <span class="label">${this.label}</span>
+        ${this.renderVotes()}
+      </div>
+    `;
+  }
+
+  private renderHovercard() {
+    if (this.disableHovercards) return;
+    return html`<gr-trigger-vote-hovercard
+      .labelName=${this.label}
+      .labelInfo=${this.labelInfo}
+    >
+      <gr-label-info
+        slot="label-info"
+        .change=${this.change}
+        .account=${this.account}
+        .mutable=${this.mutable}
+        .label=${this.label}
+        .labelInfo=${this.labelInfo}
+        .showAllReviewers=${false}
+      ></gr-label-info>
+    </gr-trigger-vote-hovercard>`;
+  }
+
+  private renderVotes() {
+    const {labelInfo} = this;
+    if (!labelInfo) return;
+    if (this.displayValue)
+      return html`<gr-vote-chip
+        .displayValue=${this.displayValue}
+        .label=${labelInfo}
+      ></gr-vote-chip>`;
+    if (isDetailedLabelInfo(labelInfo)) {
+      const approvals = getAllUniqueApprovals(labelInfo).filter(
+        approval => !hasNeutralStatus(labelInfo, approval)
+      );
+      return approvals.map(
+        approvalInfo => html`<gr-vote-chip
+          .vote=${approvalInfo}
+          .label=${labelInfo}
+        ></gr-vote-chip>`
+      );
+    } else if (isQuickLabelInfo(labelInfo)) {
+      return [html`<gr-vote-chip .label=${this.labelInfo}></gr-vote-chip>`];
+    } else {
+      return html``;
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-trigger-vote': GrTriggerVote;
+  }
+}
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
new file mode 100644
index 0000000..8497ff4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
@@ -0,0 +1,84 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import './gr-trigger-vote';
+import {GrTriggerVote} from './gr-trigger-vote';
+import {
+  createAccountWithIdNameAndEmail,
+  createApproval,
+  createDetailedLabelInfo,
+  createParsedChange,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import {SubmitRequirementResultInfo} from '../../../api/rest-api';
+import {ParsedChangeInfo} from '../../../types/types';
+
+suite('gr-trigger-vote tests', () => {
+  let element: GrTriggerVote;
+  setup(async () => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      description: 'Test Description',
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
+      labels: {
+        Verified: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      },
+    };
+    const account = createAccountWithIdNameAndEmail();
+    const label = 'Verified';
+    const labelInfo = change?.labels?.[label];
+    element = await fixture<GrTriggerVote>(
+      html`<gr-trigger-vote
+        .label=${label}
+        .labelInfo=${labelInfo}
+        .change=${change}
+        .account=${account}
+        .mutable=${false}
+      ></gr-trigger-vote>`
+    );
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ ` <div class="container">
+      <gr-trigger-vote-hovercard>
+        <gr-label-info slot="label-info"></gr-label-info>
+      </gr-trigger-vote-hovercard>
+      <span class="label"> Verified </span>
+      <gr-vote-chip> </gr-vote-chip>
+    </div>`);
+  });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
index 859fd33..6097cde 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -18,8 +18,8 @@
 import {customElement, property} from 'lit/decorators';
 import {Action} from '../../api/checks';
 import {checkRequiredProperty} from '../../utils/common-util';
-import {appContext} from '../../services/app-context';
-
+import {resolve} from '../../models/dependency';
+import {checksModelToken} from '../../models/checks/checks-model';
 @customElement('gr-checks-action')
 export class GrChecksAction extends LitElement {
   @property({type: Object})
@@ -28,7 +28,11 @@
   @property({type: Object})
   eventTarget: HTMLElement | null = null;
 
-  private checksService = appContext.checksService;
+  /** In what context is <gr-checks-action> rendered? Just for reporting. */
+  @property({type: String})
+  context = 'unknown';
+
+  private getChecksModel = resolve(this, checksModelToken);
 
   override connectedCallback() {
     super.connectedCallback();
@@ -59,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>
@@ -72,7 +76,7 @@
   private renderTooltip() {
     if (!this.action.tooltip) return;
     return html`
-      <paper-tooltip offset="5" fit-to-visible-bounds>
+      <paper-tooltip offset="5" ?fitToVisibleBounds=${true}>
         ${this.action.tooltip}
       </paper-tooltip>
     `;
@@ -80,7 +84,7 @@
 
   handleClick(e: Event) {
     e.stopPropagation();
-    this.checksService.triggerAction(this.action);
+    this.getChecksModel().triggerAction(this.action, undefined, this.context);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
index 69152b2..a8ce40f 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
@@ -16,7 +16,7 @@
  */
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
-import {CheckRun} from '../../services/checks/checks-model';
+import {CheckRun} from '../../models/checks/checks-model';
 import {ordinal} from '../../utils/string-util';
 
 @customElement('gr-checks-attempt')
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 9c27cdb..9ea29b0 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -32,14 +32,7 @@
   Tag,
 } from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
-import {
-  CheckRun,
-  checksSelectedPatchsetNumber$,
-  RunResult,
-  someProvidersAreLoadingSelected$,
-  topLevelActionsSelected$,
-  topLevelLinksSelected$,
-} from '../../services/checks/checks-model';
+import {CheckRun, RunResult} from '../../models/checks/checks-model';
 import {
   allResults,
   firstPrimaryLink,
@@ -50,7 +43,7 @@
   otherPrimaryLinks,
   secondaryLinks,
   tooltipForLink,
-} from '../../services/checks/checks-util';
+} from '../../models/checks/checks-util';
 import {assertIsDefined, check} from '../../utils/common-util';
 import {modifierPressed, toggleClass, whenVisible} from '../../utils/dom-util';
 import {durationString} from '../../utils/date-util';
@@ -62,9 +55,6 @@
   LabelNameToInfoMap,
   PatchSetNumber,
 } from '../../types/common';
-import {labels$, latestPatchNum$} from '../../services/change/change-model';
-import {appContext} from '../../services/app-context';
-import {repoConfig$} from '../../services/config/config-model';
 import {spinnerStyles} from '../../styles/gr-spinner-styles';
 import {
   getLabelStatus,
@@ -75,6 +65,28 @@
 import {DropdownLink} from '../shared/gr-dropdown/gr-dropdown';
 import {subscribe} from '../lit/subscription-controller';
 import {fontStyles} from '../../styles/gr-font-styles';
+import {fire} from '../../utils/event-util';
+import {resolve} from '../../models/dependency';
+import {configModelToken} from '../../models/config/config-model';
+import {checksModelToken} from '../../models/checks/checks-model';
+import {Interaction} from '../../constants/reporting';
+import {Deduping} from '../../api/reporting';
+import {changeModelToken} from '../../models/change/change-model';
+import {getAppContext} from '../../services/app-context';
+
+/**
+ * Firing this event sets the regular expression of the results filter.
+ */
+export interface ChecksResultsFilterDetail {
+  filterRegExp?: string;
+}
+export type ChecksResultsFilterEvent = CustomEvent<ChecksResultsFilterDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'checks-results-filter': ChecksResultsFilterEvent;
+  }
+}
 
 @customElement('gr-result-row')
 class GrResultRow extends LitElement {
@@ -96,11 +108,15 @@
   @state()
   labels?: LabelNameToInfoMap;
 
-  private checksService = appContext.checksService;
+  private getChangeModel = resolve(this, changeModelToken);
 
-  constructor() {
-    super();
-    subscribe(this, labels$, x => (this.labels = x));
+  private getChecksModel = resolve(this, checksModelToken);
+
+  private readonly reporting = getAppContext().reportingService;
+
+  override connectedCallback() {
+    super.connectedCallback();
+    subscribe(this, this.getChangeModel().labels$, x => (this.labels = x));
   }
 
   static override get styles() {
@@ -215,6 +231,7 @@
           background-color: var(--tag-background);
           padding: 0 var(--spacing-m);
           margin-left: var(--spacing-s);
+          cursor: pointer;
         }
         td .summary-cell .tag.gray {
           background-color: var(--tag-gray);
@@ -316,16 +333,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>
@@ -336,7 +353,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()}
@@ -346,27 +363,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>
     `;
@@ -375,7 +392,7 @@
   private renderExpanded() {
     if (!this.isExpanded) return;
     return html`<gr-result-expanded
-      .result="${this.result}"
+      .result=${this.result}
     ></gr-result-expanded>`;
   }
 
@@ -386,6 +403,16 @@
     this.toggleExpanded();
   }
 
+  private tagClick(e: MouseEvent, tagName: string) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.reporting.reportInteraction(Interaction.CHECKS_TAG_CLICKED, {
+      tagName,
+      checkName: this.result?.checkName,
+    });
+    fire(this, 'checks-results-filter', {filterRegExp: tagName});
+  }
+
   private toggleExpandedPress(e: KeyboardEvent) {
     if (!this.isExpandable) return;
     if (modifierPressed(e)) return;
@@ -399,6 +426,10 @@
   private toggleExpanded() {
     if (!this.isExpandable) return;
     this.isExpanded = !this.isExpanded;
+    this.reporting.reportInteraction(Interaction.CHECKS_RESULT_ROW_TOGGLE, {
+      expanded: this.isExpanded,
+      checkName: this.result?.checkName,
+    });
   }
 
   renderSummary(text?: string) {
@@ -406,7 +437,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>
     `;
   }
 
@@ -426,7 +457,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>
@@ -453,7 +484,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"
@@ -479,12 +510,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>
@@ -494,12 +525,19 @@
   }
 
   private handleAction(e: CustomEvent<Action>) {
-    this.checksService.triggerAction(e.detail);
+    this.getChecksModel().triggerAction(
+      e.detail,
+      this.result,
+      'result-row-dropdown'
+    );
   }
 
   private renderAction(action?: Action) {
     if (!action) return;
-    return html`<gr-checks-action .action="${action}"></gr-checks-action>`;
+    return html`<gr-checks-action
+      context="result-row"
+      .action=${action}
+    ></gr-checks-action>`;
   }
 
   renderPrimaryActions() {
@@ -521,12 +559,16 @@
   }
 
   renderTag(tag: Tag) {
-    return html`<div class="tag ${tag.color}">
+    return html`<button
+      class="tag ${tag.color}"
+      @click=${(e: MouseEvent) => this.tagClick(e, tag.name)}
+    >
       <span>${tag.name}</span>
-      <paper-tooltip offset="5" ?fitToVisibleBounds="${true}">
-        ${tag.tooltip ?? 'A category tag for this check result'}
+      <paper-tooltip offset="5" ?fitToVisibleBounds=${true}>
+        ${tag.tooltip ??
+        'A category tag for this check result. Click to filter.'}
       </paper-tooltip>
-    </div>`;
+    </button>`;
   }
 }
 
@@ -535,10 +577,15 @@
   @property({attribute: false})
   result?: RunResult;
 
+  @property({type: Boolean})
+  hideCodePointers = false;
+
   @state()
   repoConfig?: ConfigInfo;
 
-  private changeService = appContext.changeService;
+  private getChangeModel = resolve(this, changeModelToken);
+
+  private getConfigModel = resolve(this, configModelToken);
 
   static override get styles() {
     return [
@@ -561,9 +608,13 @@
     ];
   }
 
-  constructor() {
-    super();
-    subscribe(this, repoConfig$, x => (this.repoConfig = x));
+  override connectedCallback() {
+    super.connectedCallback();
+    subscribe(
+      this,
+      this.getConfigModel().repoConfig$,
+      x => (this.repoConfig = x)
+    );
   }
 
   override render() {
@@ -573,21 +624,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>
     `;
@@ -616,6 +664,7 @@
   }
 
   private renderCodePointers() {
+    if (this.hideCodePointers) return;
     const pointers = this.result?.codePointers ?? [];
     if (pointers.length === 0) return;
     const links = pointers.map(pointer => {
@@ -624,7 +673,7 @@
       const end = pointer?.range?.end_line;
       if (start) rangeText += `#${start}`;
       if (end && start !== end) rangeText += `-${end}`;
-      const change = this.changeService.getChange();
+      const change = this.getChangeModel().getChange();
       assertIsDefined(change);
       const path = pointer.path;
       const patchset = this.result?.patchset as PatchSetNumber | undefined;
@@ -645,7 +694,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)}"
@@ -732,21 +781,37 @@
    */
   private isSectionExpandedByUser = new Map<Category, boolean>();
 
-  private readonly checksService = appContext.checksService;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  constructor() {
-    super();
-    subscribe(this, topLevelActionsSelected$, x => (this.actions = x));
-    subscribe(this, topLevelLinksSelected$, x => (this.links = x));
+  private readonly getChecksModel = resolve(this, checksModelToken);
+
+  private readonly reporting = getAppContext().reportingService;
+
+  override connectedCallback() {
+    super.connectedCallback();
     subscribe(
       this,
-      checksSelectedPatchsetNumber$,
+      this.getChecksModel().topLevelActionsSelected$,
+      x => (this.actions = x)
+    );
+    subscribe(
+      this,
+      this.getChecksModel().topLevelLinksSelected$,
+      x => (this.links = x)
+    );
+    subscribe(
+      this,
+      this.getChecksModel().checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
-    subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
     subscribe(
       this,
-      someProvidersAreLoadingSelected$,
+      this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchsetNumber = x)
+    );
+    subscribe(
+      this,
+      this.getChecksModel().someProvidersAreLoadingSelected$,
       x => (this.someProvidersAreLoading = x)
     );
   }
@@ -883,6 +948,9 @@
         .categoryHeader .statusIcon.success {
           color: var(--success-foreground);
         }
+        .categoryHeader.empty iron-icon.statusIcon {
+          color: var(--deemphasized-text-color);
+        }
         .categoryHeader .filtered {
           color: var(--deemphasized-text-color);
         }
@@ -917,8 +985,13 @@
           padding: var(--spacing-s);
         }
         tr.headerRow th.nameCol {
-          width: 200px;
           padding-left: var(--spacing-l);
+          width: 200px;
+        }
+        @media screen and (min-width: 1400px) {
+          tr.headerRow th.nameCol.longNames {
+            width: 300px;
+          }
         }
         tr.headerRow th.summaryCol {
           width: 99%;
@@ -980,27 +1053,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>
@@ -1065,9 +1138,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
@@ -1083,9 +1156,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>
@@ -1095,22 +1168,37 @@
   }
 
   private handleAction(e: CustomEvent<Action>) {
-    this.checksService.triggerAction(e.detail);
+    this.getChecksModel().triggerAction(
+      e.detail,
+      undefined,
+      'results-dropdown'
+    );
+  }
+
+  private handleFilter(e: ChecksResultsFilterEvent) {
+    if (!this.filterInput) return;
+    const oldValue = this.filterInput.value ?? '';
+    const newValue = e.detail.filterRegExp ?? '';
+    this.filterInput.value = oldValue === newValue ? '' : newValue;
+    this.onFilterInputChange();
   }
 
   private renderAction(action?: Action) {
     if (!action) return;
-    return html`<gr-checks-action .action="${action}"></gr-checks-action>`;
+    return html`<gr-checks-action
+      context="results"
+      .action=${action}
+    ></gr-checks-action>`;
   }
 
   private onPatchsetSelected(e: CustomEvent<{value: string}>) {
     const patchset = Number(e.detail.value);
     check(!isNaN(patchset), 'selected patchset must be a number');
-    this.checksService.setPatchset(patchset as PatchSetNumber);
+    this.getChecksModel().setPatchset(patchset as PatchSetNumber);
   }
 
   private goToLatestPatchset() {
-    this.checksService.setPatchset(undefined);
+    this.getChecksModel().setPatchset(undefined);
   }
 
   private createPatchsetDropdownItems() {
@@ -1149,15 +1237,20 @@
         <input
           id="filterInput"
           type="text"
-          placeholder="Filter results by regular expression"
-          @input="${this.onInput}"
+          placeholder="Filter results by tag or regular expression"
+          @input=${this.onFilterInputChange}
         />
       </div>
     `;
   }
 
-  onInput() {
+  onFilterInputChange() {
     assertIsDefined(this.filterInput, 'filter <input> element');
+    this.reporting.reportInteraction(
+      Interaction.CHECKS_RESULT_FILTER_CHANGED,
+      {},
+      {deduping: Deduping.EVENT_ONCE_PER_CHANGE}
+    );
     this.filterRegExp = new RegExp(this.filterInput.value, 'i');
   }
 
@@ -1190,6 +1283,7 @@
     const icon = expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
     const isShowAll = this.isShowAll.get(category) ?? false;
     const resultCount = filtered.length;
+    const empty = resultCount === 0 ? 'empty' : '';
     const resultLimit = isShowAll ? 1000 : 20;
     const showAllButton = this.renderShowAllButton(
       category,
@@ -1198,12 +1292,12 @@
       resultCount
     );
     return html`
-      <div class="${expandedClass}">
+      <div class=${expandedClass}>
         <h3
-          class="categoryHeader ${catString} heading-3"
-          @click="${() => this.toggleExpanded(category)}"
+          class="categoryHeader ${catString} ${empty} heading-3"
+          @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)}"
@@ -1239,7 +1333,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>
@@ -1250,6 +1344,13 @@
   toggleShowAll(category: Category) {
     const current = this.isShowAll.get(category) ?? false;
     this.isShowAll.set(category, !current);
+    this.reporting.reportInteraction(
+      Interaction.CHECKS_RESULT_SECTION_SHOW_ALL,
+      {
+        category,
+        showAll: !current,
+      }
+    );
     this.requestUpdate();
   }
 
@@ -1274,23 +1375,27 @@
       </div>`;
     }
     filtered = filtered.slice(0, limit);
+    // Some hosts/plugins use really long check names. If we have space and the
+    // check names are indeed very long, then set a more generous nameCol width.
+    const longestNameLength = Math.max(...all.map(r => r.checkName.length));
+    const nameColClasses = {nameCol: true, longNames: longestNameLength > 25};
     return html`
       <table class="resultsTable">
         <thead>
           <tr class="headerRow">
-            <th class="nameCol">Run</th>
+            <th class=${classMap(nameColClasses)}>Run</th>
             <th class="summaryCol">Summary</th>
             <th class="expanderCol"></th>
           </tr>
         </thead>
-        <tbody>
+        <tbody @checks-results-filter=${this.handleFilter}>
           ${repeat(
             filtered,
             result => result.internalResultId,
             (result?: RunResult) => html`
               <gr-result-row
-                class="${charsOnly(result!.checkName)}"
-                .result="${result}"
+                class=${charsOnly(result!.checkName)}
+                .result=${result}
               ></gr-result-row>
             `
           )}
@@ -1312,6 +1417,10 @@
     assertIsDefined(expanded, 'expanded must have been set in initial render');
     this.isSectionExpanded.set(category, !expanded);
     this.isSectionExpandedByUser.set(category, true);
+    this.reporting.reportInteraction(Interaction.CHECKS_RESULT_SECTION_TOGGLE, {
+      expanded: !expanded,
+      category,
+    });
     this.requestUpdate();
   }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index a643c18..b18a5ff 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -31,13 +31,14 @@
   PRIMARY_STATUS_ACTIONS,
   primaryRunAction,
   worstCategory,
-} from '../../services/checks/checks-util';
+} from '../../models/checks/checks-util';
 import {
-  allRunsSelectedPatchset$,
   CheckRun,
   ChecksPatchset,
   ErrorMessages,
-  errorMessagesLatest$,
+} from '../../models/checks/checks-model';
+import {
+  clearAllFakeRuns,
   fakeActions,
   fakeLinks,
   fakeRun0,
@@ -45,9 +46,9 @@
   fakeRun2,
   fakeRun3,
   fakeRun4Att,
-  loginCallbackLatest$,
-  updateStateSetResults,
-} from '../../services/checks/checks-model';
+  fakeRun5,
+  setAllFakeRuns,
+} from '../../models/checks/checks-fakes';
 import {assertIsDefined} from '../../utils/common-util';
 import {modifierPressed, whenVisible} from '../../utils/dom-util';
 import {
@@ -57,10 +58,15 @@
 } from './gr-checks-util';
 import {ChecksTabState} from '../../types/events';
 import {charsOnly} from '../../utils/string-util';
-import {appContext} from '../../services/app-context';
+import {getAppContext} from '../../services/app-context';
 import {KnownExperimentId} from '../../services/flags/flags';
 import {subscribe} from '../lit/subscription-controller';
 import {fontStyles} from '../../styles/gr-font-styles';
+import {durationString} from '../../utils/date-util';
+import {resolve} from '../../models/dependency';
+import {checksModelToken} from '../../models/checks/checks-model';
+import {Interaction} from '../../constants/reporting';
+import {Deduping} from '../../api/reporting';
 
 @customElement('gr-checks-run')
 export class GrChecksRun extends LitElement {
@@ -98,6 +104,10 @@
         .name {
           font-weight: var(--font-weight-bold);
         }
+        .eta {
+          color: var(--deemphasized-text-color);
+          padding-left: var(--spacing-s);
+        }
         .chip.error {
           border-left: var(--thick-border) solid var(--error-foreground);
         }
@@ -110,7 +120,8 @@
         .chip.check-circle-outline {
           border-left: var(--thick-border) solid var(--success-foreground);
         }
-        .chip.timelapse {
+        .chip.timelapse,
+        .chip.scheduled {
           border-left: var(--thick-border) solid var(--border-color);
         }
         .chip.placeholder {
@@ -198,6 +209,8 @@
   @state()
   shouldRender = false;
 
+  private readonly reporting = getAppContext().reportingService;
+
   override firstUpdated() {
     assertIsDefined(this.chipElement, 'chip element');
     whenVisible(this.chipElement, () => (this.shouldRender = true), 200);
@@ -232,31 +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 .action="${action}"></gr-checks-action>`
+            ? html`<gr-checks-action
+                context="runs"
+                .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>
@@ -278,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>`;
@@ -297,11 +314,20 @@
     }
   }
 
+  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>`;
+  }
+
   renderStatusLink() {
     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"
@@ -315,6 +341,10 @@
   private onLinkClick(e: MouseEvent) {
     // Prevents handleChipClick() from reacting to <a> link clicks.
     e.stopPropagation();
+    this.reporting.reportInteraction(Interaction.CHECKS_RUN_LINK_CLICKED, {
+      checkName: this.run.checkName,
+      status: this.run.status,
+    });
   }
 
   renderFilterIcon() {
@@ -334,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>
     `;
   }
 
@@ -359,8 +389,12 @@
   @query('#filterInput')
   filterInput?: HTMLInputElement;
 
+  /**
+   * We prefer `undefined` over a RegExp with '', because `.source` yields
+   * a strange '(?:)' for ''.
+   */
   @state()
-  filterRegExp = new RegExp('');
+  filterRegExp?: RegExp;
 
   @property({attribute: false})
   runs: CheckRun[] = [];
@@ -389,15 +423,29 @@
 
   private isSectionExpanded = new Map<RunStatus, boolean>();
 
-  private flagService = appContext.flagsService;
+  private flagService = getAppContext().flagsService;
 
-  private checksService = appContext.checksService;
+  private getChecksModel = resolve(this, checksModelToken);
 
-  constructor() {
-    super();
-    subscribe(this, allRunsSelectedPatchset$, x => (this.runs = x));
-    subscribe(this, errorMessagesLatest$, x => (this.errorMessages = x));
-    subscribe(this, loginCallbackLatest$, x => (this.loginCallback = x));
+  private readonly reporting = getAppContext().reportingService;
+
+  override connectedCallback(): void {
+    super.connectedCallback();
+    subscribe(
+      this,
+      this.getChecksModel().allRunsSelectedPatchset$,
+      x => (this.runs = x)
+    );
+    subscribe(
+      this,
+      this.getChecksModel().errorMessagesLatest$,
+      x => (this.errorMessages = x)
+    );
+    subscribe(
+      this,
+      this.getChecksModel().loginCallbackLatest$,
+      x => (this.loginCallback = x)
+    );
   }
 
   static override get styles() {
@@ -508,7 +556,20 @@
 
   protected override updated(changedProperties: PropertyValues) {
     super.updated(changedProperties);
+    // This update is done is response to setting this.filterRegExp below, but
+    // this.filterInput not yet being available at that point.
+    if (this.filterInput && !this.filterInput.value && this.filterRegExp) {
+      this.filterInput.value = this.filterRegExp.source;
+    }
     if (changedProperties.has('tabState') && this.tabState) {
+      // Note that tabState.select and tabState.attempt are processed by
+      // <gr-checks-tab>.
+      if (
+        this.tabState.filter &&
+        this.tabState.filter !== this.filterRegExp?.source
+      ) {
+        this.filterRegExp = new RegExp(this.tabState.filter, 'i');
+      }
       const {statusOrCategory} = this.tabState;
       if (
         statusOrCategory === RunStatus.RUNNING ||
@@ -538,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)}
@@ -582,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>
     `;
@@ -605,22 +666,32 @@
       <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="${() => {
-            actions.forEach(action => this.checksService.triggerAction(action));
-          }}"
+          @click=${() => {
+            actions.forEach(action => {
+              if (!action) return;
+              this.getChecksModel().triggerAction(
+                action,
+                undefined,
+                'run-selected'
+              );
+            });
+            this.reporting.reportInteraction(
+              Interaction.CHECKS_RUNS_SELECTED_TRIGGERED
+            );
+          }}
           >Run Selected</gr-button
         >
       </gr-tooltip-content>
@@ -631,67 +702,63 @@
     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.collapsed = !this.collapsed)}"
+            : '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>
     `;
   }
 
+  private toggleCollapsed() {
+    this.collapsed = !this.collapsed;
+    this.reporting.reportInteraction(Interaction.CHECKS_RUNS_PANEL_TOGGLE, {
+      collapsed: this.collapsed,
+    });
+  }
+
   onInput() {
     assertIsDefined(this.filterInput, 'filter <input> element');
-    this.filterRegExp = new RegExp(this.filterInput.value, 'i');
-  }
-
-  none() {
-    updateStateSetResults('f0', [], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f1', [], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f2', [], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f3', [], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f4', [], [], [], ChecksPatchset.LATEST);
-  }
-
-  all() {
-    updateStateSetResults(
-      'f0',
-      [fakeRun0],
-      fakeActions,
-      fakeLinks,
-      ChecksPatchset.LATEST
+    this.reporting.reportInteraction(
+      Interaction.CHECKS_RUN_FILTER_CHANGED,
+      {},
+      {deduping: Deduping.EVENT_ONCE_PER_CHANGE}
     );
-    updateStateSetResults('f1', [fakeRun1], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f2', [fakeRun2], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f3', [fakeRun3], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f4', fakeRun4Att, [], [], ChecksPatchset.LATEST);
+    if (this.filterInput.value) {
+      this.filterRegExp = new RegExp(this.filterInput.value, 'i');
+    } else {
+      this.filterRegExp = undefined;
+    }
   }
 
   toggle(
     plugin: string,
     runs: CheckRun[],
     actions: Action[] = [],
-    links: Link[] = []
+    links: Link[] = [],
+    summaryMessage: string | undefined = undefined
   ) {
     const newRuns = this.runs.includes(runs[0]) ? [] : runs;
-    updateStateSetResults(
+    this.getChecksModel().updateStateSetResults(
       plugin,
       newRuns,
       actions,
       links,
+      summaryMessage,
       ChecksPatchset.LATEST
     );
   }
@@ -699,21 +766,26 @@
   renderSection(status: RunStatus) {
     const runs = this.runs
       .filter(r => r.isLatestAttempt)
-      .filter(r => r.status === status)
-      .filter(r => this.filterRegExp.test(r.checkName))
+      .filter(
+        r =>
+          r.status === status ||
+          (status === RunStatus.RUNNING && r.status === RunStatus.SCHEDULED)
+      )
+      .filter(r => !this.filterRegExp || this.filterRegExp.test(r.checkName))
       .sort(compareByWorstCategory);
     if (runs.length === 0) return;
     const expanded = this.isSectionExpanded.get(status) ?? true;
     const expandedClass = expanded ? 'expanded' : 'collapsed';
     const icon = expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
+    let header = headerForStatus(status);
+    if (runs.some(r => r.status === RunStatus.SCHEDULED)) {
+      header = `${header} / ${headerForStatus(RunStatus.SCHEDULED)}`;
+    }
     return html`
       <div class="${status.toLowerCase()} ${expandedClass}">
-        <div
-          class="sectionHeader"
-          @click="${() => this.toggleExpanded(status)}"
-        >
-          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
-          <h3 class="heading-3">${headerForStatus(status)}</h3>
+        <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>
       </div>
@@ -723,6 +795,10 @@
   toggleExpanded(status: RunStatus) {
     const expanded = this.isSectionExpanded.get(status) ?? true;
     this.isSectionExpanded.set(status, !expanded);
+    this.reporting.reportInteraction(Interaction.CHECKS_RUN_SECTION_TOGGLE, {
+      status,
+      expanded: !expanded,
+    });
     this.requestUpdate();
   }
 
@@ -731,19 +807,15 @@
     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>`;
   }
 
   showFilter(): boolean {
-    const show = this.runs.length > 10;
-    if (!show && this.filterRegExp.source.length > 0) {
-      this.filterRegExp = new RegExp('');
-    }
-    return show;
+    return this.runs.length > 10 || !!this.filterRegExp;
   }
 
   renderFakeControls() {
@@ -751,26 +823,33 @@
     return html`
       <div class="testing">
         <div>Toggle fake runs by clicking buttons:</div>
-        <gr-button link @click="${this.none}">none</gr-button>
+        <gr-button link @click=${() => clearAllFakeRuns(this.getChecksModel())}
+          >none</gr-button
+        >
         <gr-button
           link
-          @click="${() =>
-            this.toggle('f0', [fakeRun0], fakeActions, fakeLinks)}"
+          @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.all}">all</gr-button>
+        <gr-button link @click=${() => this.toggle('f5', [fakeRun5])}
+          >5</gr-button
+        >
+        <gr-button link @click=${() => setAllFakeRuns(this.getChecksModel())}
+          >all</gr-button
+        >
       </div>
     `;
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
index 4d54200..a6d4585 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -14,13 +14,101 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../test/common-test-setup-karma';
+import './gr-checks-runs';
 import {GrChecksRuns} from './gr-checks-runs';
+import {html} from 'lit';
+import {fixture} from '@open-wc/testing-helpers';
+import {checksModelToken} from '../../models/checks/checks-model';
+import {setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {resolve} from '../../models/dependency';
 
 suite('gr-checks-runs test', () => {
-  test('is defined', () => {
-    const el = document.createElement('gr-checks-runs');
-    assert.instanceOf(el, GrChecksRuns);
+  let element: GrChecksRuns;
+
+  setup(async () => {
+    element = await fixture<GrChecksRuns>(
+      html`<gr-checks-runs></gr-checks-runs>`
+    );
+    const getChecksModel = resolve(element, checksModelToken);
+    setAllFakeRuns(getChecksModel());
+  });
+
+  test('tabState filter', async () => {
+    element.tabState = {filter: 'fff'};
+    await element.updateComplete;
+    assert.equal(element.filterRegExp?.source, 'fff');
+  });
+
+  test('renders', async () => {
+    await element.updateComplete;
+    assert.equal(element.runs.length, 44);
+    expect(element).shadowDom.to.equal(
+      /* HTML */ `
+        <h2 class="title">
+          <div class="heading-2">Runs</div>
+          <div class="flex-space"></div>
+          <gr-tooltip-content has-tooltip="" title="Collapse runs panel">
+            <gr-button
+              aria-checked="false"
+              aria-label="Collapse runs panel"
+              class="expandButton"
+              link=""
+              role="switch"
+            >
+              <iron-icon
+                class="expandIcon"
+                icon="gr-icons:chevron-left"
+              ></iron-icon>
+            </gr-button>
+          </gr-tooltip-content>
+        </h2>
+        <input
+          id="filterInput"
+          placeholder="Filter runs by regular expression"
+          type="text"
+        />
+        <div class="expanded running">
+          <div class="sectionHeader">
+            <iron-icon
+              class="expandIcon"
+              icon="gr-icons:expand-less"
+            ></iron-icon>
+            <h3 class="heading-3">Running / Scheduled</h3>
+          </div>
+          <div class="sectionRuns">
+            <gr-checks-run></gr-checks-run>
+            <gr-checks-run></gr-checks-run>
+          </div>
+        </div>
+        <div class="completed expanded">
+          <div class="sectionHeader">
+            <iron-icon
+              class="expandIcon"
+              icon="gr-icons:expand-less"
+            ></iron-icon>
+            <h3 class="heading-3">Completed</h3>
+          </div>
+          <div class="sectionRuns">
+            <gr-checks-run></gr-checks-run>
+            <gr-checks-run></gr-checks-run>
+            <gr-checks-run></gr-checks-run>
+          </div>
+        </div>
+        <div class="expanded runnable">
+          <div class="sectionHeader">
+            <iron-icon
+              class="expandIcon"
+              icon="gr-icons:expand-less"
+            ></iron-icon>
+            <h3 class="heading-3">Not run</h3>
+          </div>
+          <div class="sectionRuns">
+            <gr-checks-run></gr-checks-run>
+          </div>
+        </div>
+      `,
+      {ignoreAttributes: ['tabindex', 'aria-disabled']}
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-styles.ts b/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
index dbc2bec..65e5aae 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
@@ -14,14 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {css} from 'lit';
 
-// 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 {};
-
 const $_documentContainer = document.createElement('template');
 
 export const checksStyles = css`
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index ed6117a..d808d11 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -16,23 +16,22 @@
  */
 import {LitElement, css, html, PropertyValues} from 'lit';
 import {customElement, property, state} from 'lit/decorators';
-import {Action} from '../../api/checks';
 import {
   CheckResult,
   CheckRun,
-  allResultsSelected$,
-  checksSelectedPatchsetNumber$,
-  allRunsSelectedPatchset$,
-} from '../../services/checks/checks-model';
+  checksModelToken,
+} from '../../models/checks/checks-model';
+import {changeModelToken} from '../../models/change/change-model';
 import './gr-checks-runs';
 import './gr-checks-results';
-import {changeNum$, latestPatchNum$} from '../../services/change/change-model';
 import {NumericChangeId, PatchSetNumber} from '../../types/common';
-import {ActionTriggeredEvent} from '../../services/checks/checks-util';
 import {AttemptSelectedEvent, RunSelectedEvent} from './gr-checks-util';
-import {ChecksTabState} from '../../types/events';
-import {appContext} from '../../services/app-context';
+import {TabState} from '../../types/events';
+import {getAppContext} from '../../services/app-context';
 import {subscribe} from '../lit/subscription-controller';
+import {Deduping} from '../../api/reporting';
+import {Interaction} from '../../constants/reporting';
+import {resolve} from '../../models/dependency';
 
 /**
  * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
@@ -47,7 +46,7 @@
   results: CheckResult[] = [];
 
   @property({type: Object})
-  tabState?: ChecksTabState;
+  tabState?: TabState;
 
   @state()
   checksPatchsetNumber: PatchSetNumber | undefined = undefined;
@@ -68,22 +67,38 @@
     number | undefined
   >();
 
-  private readonly checksService = appContext.checksService;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  constructor() {
-    super();
-    subscribe(this, allRunsSelectedPatchset$, x => (this.runs = x));
-    subscribe(this, allResultsSelected$, x => (this.results = x));
+  private readonly getChecksModel = resolve(this, checksModelToken);
+
+  private readonly reporting = getAppContext().reportingService;
+
+  override connectedCallback(): void {
+    super.connectedCallback();
     subscribe(
       this,
-      checksSelectedPatchsetNumber$,
+      this.getChecksModel().allRunsSelectedPatchset$,
+      x => (this.runs = x)
+    );
+    subscribe(
+      this,
+      this.getChecksModel().allResultsSelected$,
+      x => (this.results = x)
+    );
+    subscribe(
+      this,
+      this.getChecksModel().checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
-    subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
-    subscribe(this, changeNum$, x => (this.changeNum = x));
-
-    this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
-      this.handleActionTriggered(e.detail.action, e.detail.run)
+    subscribe(
+      this,
+      this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchsetNumber = x)
+    );
+    subscribe(
+      this,
+      this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
     );
   }
 
@@ -106,25 +121,33 @@
   }
 
   override render() {
+    this.reporting.reportInteraction(
+      Interaction.CHECKS_TAB_RENDERED,
+      {
+        checkName: this.tabState?.checksTab?.checkName,
+        statusOrCategory: this.tabState?.checksTab?.statusOrCategory,
+      },
+      {deduping: Deduping.DETAILS_ONCE_PER_CHANGE}
+    );
     return html`
       <div class="container">
         <gr-checks-runs
           class="runs"
-          ?collapsed="${this.offsetWidth < 1000}"
-          .runs="${this.runs}"
-          .selectedRuns="${this.selectedRuns}"
-          .selectedAttempts="${this.selectedAttempts}"
-          .tabState="${this.tabState}"
-          @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}"
-          .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>
     `;
@@ -132,18 +155,60 @@
 
   protected override updated(changedProperties: PropertyValues) {
     super.updated(changedProperties);
-    if (changedProperties.has('tabState')) {
-      if (this.tabState) {
-        this.selectedRuns = [];
-      }
-    }
+    if (changedProperties.has('tabState')) this.applyTabState();
+    if (changedProperties.has('runs')) this.applyTabState();
   }
 
-  handleActionTriggered(action: Action, run?: CheckRun) {
-    this.checksService.triggerAction(action, run);
+  /**
+   * Clearing the tabState means that from now on the user interaction counts,
+   * not the content of the URL (which is where tabState is populated from).
+   */
+  private clearTabState() {
+    this.tabState = {};
+  }
+
+  /**
+   * We want to keep applying the tabState to newly incoming check runs until
+   * the user explicitly interacts with the selection or the attempts, which
+   * will result in clearTabState() being called.
+   */
+  private applyTabState() {
+    if (!this.tabState?.checksTab) return;
+    // Note that .filter is processed by <gr-checks-runs>.
+    const {select, filter, attempt} = this.tabState?.checksTab;
+    if (!select) {
+      this.selectedRuns = [];
+      this.selectedAttempts = new Map<string, number>();
+      return;
+    }
+    const regexpSelect = new RegExp(select, 'i');
+    // We do not allow selection of runs that are invisible because of the
+    // filter.
+    const regexpFilter = new RegExp(filter ?? '', 'i');
+    const selectedRuns = this.runs.filter(
+      run =>
+        regexpSelect.test(run.checkName) && regexpFilter.test(run.checkName)
+    );
+    this.selectedRuns = selectedRuns.map(run => run.checkName);
+    const selectedAttempts = new Map<string, number>();
+    if (attempt) {
+      for (const run of selectedRuns) {
+        if (run.isSingleAttempt) continue;
+        const hasAttempt = run.attemptDetails.some(
+          detail => detail.attempt === attempt
+        );
+        if (hasAttempt) selectedAttempts.set(run.checkName, attempt);
+      }
+    }
+    this.selectedAttempts = selectedAttempts;
   }
 
   handleRunSelected(e: RunSelectedEvent) {
+    this.clearTabState();
+    this.reporting.reportInteraction(Interaction.CHECKS_RUN_SELECTED, {
+      checkName: e.detail.checkName,
+      reset: e.detail.reset,
+    });
     if (e.detail.reset) {
       this.selectedRuns = [];
       this.selectedAttempts = new Map();
@@ -155,6 +220,11 @@
   }
 
   handleAttemptSelected(e: AttemptSelectedEvent) {
+    this.clearTabState();
+    this.reporting.reportInteraction(Interaction.CHECKS_ATTEMPT_SELECTED, {
+      checkName: e.detail.checkName,
+      attempt: e.detail.attempt,
+    });
     const {checkName, attempt} = e.detail;
     this.selectedAttempts.set(checkName, attempt);
     // Force property update.
@@ -162,6 +232,7 @@
   }
 
   toggleSelected(checkName: string) {
+    this.clearTabState();
     if (this.selectedRuns.includes(checkName)) {
       this.selectedRuns = this.selectedRuns.filter(r => r !== checkName);
       this.selectedAttempts.set(checkName, undefined);
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
index 85183ed..9092f60 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
@@ -14,13 +14,47 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../test/common-test-setup-karma';
+import {html} from 'lit';
+import './gr-checks-tab';
 import {GrChecksTab} from './gr-checks-tab';
+import {fixture} from '@open-wc/testing-helpers';
+import {checksModelToken} from '../../models/checks/checks-model';
+import {fakeRun4_3, setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {resolve} from '../../models/dependency';
+import {Category} from '../../api/checks';
 
 suite('gr-checks-tab test', () => {
-  test('is defined', () => {
-    const el = document.createElement('gr-checks-tab');
-    assert.instanceOf(el, GrChecksTab);
+  let element: GrChecksTab;
+
+  setup(async () => {
+    element = await fixture<GrChecksTab>(html`<gr-checks-tab></gr-checks-tab>`);
+    const getChecksModel = resolve(element, checksModelToken);
+    setAllFakeRuns(getChecksModel());
+  });
+
+  test('renders', async () => {
+    await element.updateComplete;
+    assert.equal(element.runs.length, 44);
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="container">
+        <gr-checks-runs class="runs" collapsed=""> </gr-checks-runs>
+        <gr-checks-results class="results"> </gr-checks-results>
+      </div>
+    `);
+  });
+
+  test('select from tab state', async () => {
+    element.tabState = {
+      checksTab: {
+        statusOrCategory: Category.ERROR,
+        filter: 'elim',
+        select: 'fake',
+        attempt: 3,
+      },
+    };
+    await element.updateComplete;
+    assert.equal(element.selectedRuns.length, 39);
+    assert.equal(element.selectedAttempts.get(fakeRun4_3.checkName), 3);
   });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util.ts b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
index e9bbb22..acbf8d0 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {CheckRun, RunResult} from '../../services/checks/checks-model';
+import {CheckRun, RunResult} from '../../models/checks/checks-model';
 
 export interface AttemptSelectedEventDetail {
   checkName: string;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-util_test.ts
index 698a4a1..6e23ae7 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util_test.ts
@@ -17,7 +17,7 @@
 import '../../test/common-test-setup-karma';
 import {createRunResult} from '../../test/test-data-generators';
 import {matches} from './gr-checks-util';
-import {RunResult} from '../../services/checks/checks-model';
+import {RunResult} from '../../models/checks/checks-model';
 
 suite('gr-checks-util test', () => {
   test('regexp filter matching results', () => {
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
new file mode 100644
index 0000000..a602c72
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -0,0 +1,227 @@
+/**
+ * @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 '@polymer/paper-tooltip/paper-tooltip';
+import '@polymer/iron-icon/iron-icon';
+import {LitElement, css, html, PropertyValues, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {RunResult} from '../../models/checks/checks-model';
+import {iconFor} from '../../models/checks/checks-util';
+import {modifierPressed} from '../../utils/dom-util';
+import './gr-checks-results';
+import './gr-hovercard-run';
+import {fontStyles} from '../../styles/gr-font-styles';
+
+@customElement('gr-diff-check-result')
+export class GrDiffCheckResult extends LitElement {
+  @property({attribute: false})
+  result?: RunResult;
+
+  /**
+   * This is required by <gr-diff> as an identifier for this component. It will
+   * be set to the internalResultId of the check result.
+   */
+  @property({type: String})
+  rootId?: string;
+
+  @state()
+  isExpanded = false;
+
+  @state()
+  isExpandable = false;
+
+  static override get styles() {
+    return [
+      fontStyles,
+      css`
+        .container {
+          font-family: var(--font-family);
+          margin: 0 var(--spacing-s) var(--spacing-s);
+          background-color: var(--unresolved-comment-background-color);
+          box-shadow: var(--elevation-level-2);
+          border-radius: var(--border-radius);
+          padding: var(--spacing-xs) var(--spacing-m);
+          border: 1px solid #888;
+        }
+        .container.info {
+          border-color: var(--info-foreground);
+          background-color: var(--info-background);
+        }
+        .container.info .icon {
+          color: var(--info-foreground);
+        }
+        .container.warning {
+          border-color: var(--warning-foreground);
+          background-color: var(--warning-background);
+        }
+        .container.warning .icon {
+          color: var(--warning-foreground);
+        }
+        .container.error {
+          border-color: var(--error-foreground);
+          background-color: var(--error-background);
+        }
+        .container.error .icon {
+          color: var(--error-foreground);
+        }
+        .header {
+          display: flex;
+          white-space: nowrap;
+          cursor: pointer;
+        }
+        .icon {
+          margin-right: var(--spacing-s);
+        }
+        .name {
+          margin-right: var(--spacing-m);
+        }
+        .summary {
+          font-weight: var(--font-weight-bold);
+          flex-shrink: 1;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          margin-right: var(--spacing-s);
+        }
+        .message {
+          flex-grow: 1;
+          /* Looks a bit unexpected, but the idea is that .message shrinks
+             first, and only when that has shrunken to 0, then .summary should
+             also start shrinking (substantially). */
+          flex-shrink: 1000000;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          color: var(--deemphasized-text-color);
+        }
+        gr-result-expanded {
+          display: block;
+          margin-top: var(--spacing-m);
+        }
+        iron-icon {
+          width: var(--line-height-normal);
+          height: var(--line-height-normal);
+          vertical-align: top;
+        }
+        .icon iron-icon {
+          width: calc(var(--line-height-normal) - 4px);
+          height: calc(var(--line-height-normal) - 4px);
+          position: relative;
+          top: 2px;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.result) return;
+    const cat = this.result.category.toLowerCase();
+    return html`
+      <div class="${cat} container font-normal">
+        <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>
+            <div
+              class="name"
+              role="button"
+              tabindex="0"
+              @keydown=${this.toggleExpandedPress}
+            >
+              ${this.result.checkName}
+            </div>
+          </div>
+          <!-- The &nbsp; is for being able to shrink a tiny amount without
+                the text itself getting shrunk with an ellipsis. -->
+          <div class="summary">${this.result.summary}&nbsp;</div>
+          <div class="message">
+            ${this.isExpanded ? nothing : this.result.message}
+          </div>
+          ${this.renderToggle()}
+        </div>
+        <div class="details">${this.renderExpanded()}</div>
+      </div>
+    `;
+  }
+
+  private renderToggle() {
+    if (!this.isExpandable) return nothing;
+    return html`
+      <div
+        class="show-hide"
+        role="switch"
+        tabindex="0"
+        aria-checked=${this.isExpanded ? 'true' : 'false'}
+        aria-label=${this.isExpanded
+          ? 'Collapse result row'
+          : 'Expand result row'}
+        @keydown=${this.toggleExpandedPress}
+      >
+        <iron-icon
+          icon=${this.isExpanded
+            ? 'gr-icons:expand-less'
+            : 'gr-icons:expand-more'}
+        ></iron-icon>
+      </div>
+    `;
+  }
+
+  private renderExpanded() {
+    if (!this.isExpanded) return nothing;
+    return html`
+      <gr-result-expanded
+        hidecodepointers
+        .result=${this.result}
+      ></gr-result-expanded>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('result')) {
+      this.isExpandable = !!this.result?.summary && !!this.result?.message;
+    }
+  }
+
+  private toggleExpandedClick(e: MouseEvent) {
+    if (!this.isExpandable) return;
+    e.preventDefault();
+    e.stopPropagation();
+    this.toggleExpanded();
+  }
+
+  private toggleExpandedPress(e: KeyboardEvent) {
+    if (!this.isExpandable) return;
+    if (modifierPressed(e)) return;
+    // Only react to `return` and `space`.
+    if (e.keyCode !== 13 && e.keyCode !== 32) return;
+    e.preventDefault();
+    e.stopPropagation();
+    this.toggleExpanded();
+  }
+
+  private toggleExpanded() {
+    if (!this.isExpandable) return;
+    this.isExpanded = !this.isExpanded;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-check-result': GrDiffCheckResult;
+  }
+}
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
new file mode 100644
index 0000000..1be249d
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -0,0 +1,60 @@
+/**
+ * @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 {fakeRun1} from '../../models/checks/checks-fakes';
+import {RunResult} from '../../models/checks/checks-model';
+import '../../test/common-test-setup-karma';
+import './gr-diff-check-result';
+import {GrDiffCheckResult} from './gr-diff-check-result';
+
+suite('gr-diff-check-result tests', () => {
+  let element: GrDiffCheckResult;
+
+  setup(async () => {
+    element = document.createElement('gr-diff-check-result');
+    document.body.appendChild(element);
+    await element.updateComplete;
+  });
+
+  teardown(() => {
+    if (element) element.remove();
+  });
+
+  test('renders', async () => {
+    element.result = {...fakeRun1, ...fakeRun1.results?.[0]} as RunResult;
+    await element.updateComplete;
+    // cannot use /* HTML */ because formatted long message will not match.
+    expect(element).shadowDom.to.equal(`
+      <div class="container font-normal warning">
+        <div class="header">
+          <div class="icon">
+            <iron-icon icon="gr-icons:warning"> </iron-icon>
+          </div>
+          <div class="name">
+            <gr-hovercard-run> </gr-hovercard-run>
+            <div class="name" role="button" tabindex="0">FAKE Super Check</div>
+          </div>
+          <div class="summary">We think that you could improve this.</div>
+          <div class="message">
+            There is a lot to be said. A lot. I say, a lot.
+                So please keep reading.
+          </div>
+        </div>
+        <div class="details"></div>
+      </div>
+    `);
+  });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index 95b7157..cc38693 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -17,13 +17,13 @@
 import {fontStyles} from '../../styles/gr-font-styles';
 import {customElement, property} from 'lit/decorators';
 import './gr-checks-action';
-import {CheckRun} from '../../services/checks/checks-model';
+import {CheckRun} from '../../models/checks/checks-model';
 import {
   AttemptDetail,
   iconFor,
   runActions,
   worstCategory,
-} from '../../services/checks/checks-util';
+} from '../../models/checks/checks-util';
 import {durationString, fromNow} from '../../utils/date-util';
 import {RunStatus} from '../../api/checks';
 import {ordinal} from '../../utils/string-util';
@@ -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>
@@ -240,28 +240,62 @@
     )
       return;
 
+    const scheduled =
+      this.run.scheduledTimestamp && !this.run.startedTimestamp
+        ? html`<div class="row">
+            <div class="title">Scheduled</div>
+            <div>${fromNow(this.run.scheduledTimestamp)}</div>
+          </div>`
+        : '';
+
+    const started = this.run.startedTimestamp
+      ? html`<div class="row">
+          <div class="title">Started</div>
+          <div>${fromNow(this.run.startedTimestamp)}</div>
+        </div>`
+      : '';
+
+    const finished =
+      this.run.finishedTimestamp && this.run.status === RunStatus.COMPLETED
+        ? html`<div class="row">
+            <div class="title">Ended</div>
+            <div>${fromNow(this.run.finishedTimestamp)}</div>
+          </div>`
+        : '';
+
+    const completed =
+      this.run.startedTimestamp &&
+      this.run.finishedTimestamp &&
+      this.run.status === RunStatus.COMPLETED
+        ? html`<div class="row">
+            <div class="title">Completion</div>
+            <div>
+              ${durationString(
+                this.run.startedTimestamp,
+                this.run.finishedTimestamp,
+                true
+              )}
+            </div>
+          </div>`
+        : '';
+
+    const eta =
+      this.run.finishedTimestamp && this.run.status === RunStatus.RUNNING
+        ? html`<div class="row">
+            <div class="title">ETA</div>
+            <div>
+              ${durationString(new Date(), this.run.finishedTimestamp, true)}
+            </div>
+          </div>`
+        : '';
+
     return html`
       <div class="section">
         <div class="sectionIcon">
           <iron-icon class="small" icon="gr-icons:schedule"></iron-icon>
         </div>
         <div class="sectionContent">
-          <div ?hidden="${this.hideScheduled()}" class="row">
-            <div class="title">Scheduled</div>
-            <div>${this.computeDuration(this.run.scheduledTimestamp)}</div>
-          </div>
-          <div ?hidden="${!this.run.startedTimestamp}" class="row">
-            <div class="title">Started</div>
-            <div>${this.computeDuration(this.run.startedTimestamp)}</div>
-          </div>
-          <div ?hidden="${!this.run.finishedTimestamp}" class="row">
-            <div class="title">Ended</div>
-            <div>${this.computeDuration(this.run.finishedTimestamp)}</div>
-          </div>
-          <div ?hidden="${this.hideCompletion()}" class="row">
-            <div class="title">Completion</div>
-            <div>${this.computeCompletionDuration()}</div>
-          </div>
+          ${scheduled} ${started} ${finished} ${completed} ${eta}
         </div>
       </div>
     `;
@@ -286,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"
@@ -309,8 +343,9 @@
         html`
           <div class="action">
             <gr-checks-action
-              .eventTarget="${this._target}"
-              .action="${action}"
+              context="hovercard"
+              .eventTarget=${this._target}
+              .action=${action}
             ></gr-checks-action>
           </div>
         `
@@ -334,24 +369,18 @@
   }
 
   private computeChipIcon() {
-    if (this.run?.status === RunStatus.COMPLETED) return 'check';
-    if (this.run?.status === RunStatus.RUNNING) return 'timelapse';
+    if (this.run?.status === RunStatus.COMPLETED) {
+      return 'check';
+    }
+    if (this.run?.status === RunStatus.RUNNING) {
+      return iconFor(RunStatus.RUNNING);
+    }
+    if (this.run?.status === RunStatus.SCHEDULED) {
+      return iconFor(RunStatus.SCHEDULED);
+    }
     return '';
   }
 
-  private computeCompletionDuration() {
-    if (!this.run?.finishedTimestamp || !this.run?.startedTimestamp) return '';
-    return durationString(
-      this.run.startedTimestamp,
-      this.run.finishedTimestamp,
-      true
-    );
-  }
-
-  private computeDuration(date?: Date) {
-    return date ? fromNow(date) : '';
-  }
-
   private computeHostName(link?: string) {
     return link ? new URL(link).hostname : '';
   }
@@ -360,14 +389,6 @@
     const attemptCount = this.run?.attemptDetails?.length;
     return attemptCount === undefined || attemptCount < 2;
   }
-
-  private hideScheduled() {
-    return !this.run?.scheduledTimestamp || !!this.run?.startedTimestamp;
-  }
-
-  private hideCompletion() {
-    return !this.run?.startedTimestamp || !this.run?.finishedTimestamp;
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
index 352219a..bec2b92 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
@@ -17,23 +17,21 @@
 
 import '../../test/common-test-setup-karma';
 import './gr-hovercard-run';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {fixture, html} from '@open-wc/testing-helpers';
 import {GrHovercardRun} from './gr-hovercard-run';
 
-const basicFixture = fixtureFromTemplate(html`
-  <gr-hovercard-run class="hovered"></gr-hovercard-run>
-`);
-
 suite('gr-hovercard-run tests', () => {
   let element: GrHovercardRun;
 
   setup(async () => {
-    element = basicFixture.instantiate() as GrHovercardRun;
+    element = await fixture<GrHovercardRun>(html`
+      <gr-hovercard-run class="hovered"></gr-hovercard-run>
+    `);
     await flush();
   });
 
   teardown(() => {
-    element.hide(new MouseEvent('click'));
+    element.mouseHide(new MouseEvent('click'));
   });
 
   test('hovercard is shown', () => {
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 03fb4d5..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
@@ -18,7 +18,7 @@
 import '../../shared/gr-avatar/gr-avatar';
 import {getUserName} from '../../../utils/display-name-util';
 import {AccountInfo, ServerInfo} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {fireEvent} from '../../../utils/event-util';
 import {
   DropdownContent,
@@ -53,7 +53,7 @@
   @property({type: String})
   _switchAccountUrl = '';
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
@@ -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 63b5bc8..509901e 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
@@ -15,10 +15,9 @@
  * limitations under the License.
  */
 import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-error-dialog_html';
-import {customElement, property} from '@polymer/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -27,11 +26,7 @@
 }
 
 @customElement('gr-error-dialog')
-export class GrErrorDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrErrorDialog extends LitElement {
   /**
    * Fired when the dismiss button is pressed.
    *
@@ -44,10 +39,64 @@
   @property({type: String})
   loginUrl = '/login';
 
+  @property({type: String})
+  loginText = 'Sign in';
+
   @property({type: Boolean})
   showSignInButton = false;
 
-  _handleConfirm() {
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        .main {
+          max-height: 40em;
+          max-width: 60em;
+          overflow-y: auto;
+          white-space: pre-wrap;
+        }
+        @media screen and (max-width: 50em) {
+          .main {
+            max-height: none;
+            max-width: 50em;
+          }
+        }
+        .signInLink {
+          text-decoration: none;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <gr-dialog
+        id="dialog"
+        cancel-label=""
+        @confirm=${() => {
+          this.handleConfirm();
+        }}
+        confirm-label="Dismiss"
+        confirm-on-enter=""
+      >
+        <div class="header" slot="header">An error occurred</div>
+        <div class="main" slot="main">${this.text}</div>
+        ${this.renderSignButton()}
+      </gr-dialog>
+    `;
+  }
+
+  private renderSignButton() {
+    if (!this.showSignInButton) return;
+
+    return html`
+      <gr-button id="signIn" class="signInLink" link="" slot="footer">
+        <a class="signInLink" href=${this.loginUrl}>${this.loginText}</a>
+      </gr-button>
+    `;
+  }
+
+  private handleConfirm() {
     this.dispatchEvent(new CustomEvent('dismiss'));
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts
deleted file mode 100644
index 10476cd..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts
+++ /dev/null
@@ -1,56 +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">
-    .main {
-      max-height: 40em;
-      max-width: 60em;
-      overflow-y: auto;
-      white-space: pre-wrap;
-    }
-    @media screen and (max-width: 50em) {
-      .main {
-        max-height: none;
-        max-width: 50em;
-      }
-    }
-    .signInLink {
-      text-decoration: none;
-    }
-  </style>
-  <gr-dialog
-    id="dialog"
-    cancel-label=""
-    on-confirm="_handleConfirm"
-    confirm-label="Dismiss"
-    confirm-on-enter=""
-  >
-    <div class="header" slot="header">An error occurred</div>
-    <div class="main" slot="main">[[text]]</div>
-    <gr-button
-      id="signIn"
-      class$="signInLink"
-      hidden$="[[!showSignInButton]]"
-      link=""
-      slot="footer"
-    >
-      <a href$="[[loginUrl]]" class="signInLink">Sign in</a>
-    </gr-button>
-  </gr-dialog>
-`;
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 c51988e..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';
@@ -28,14 +27,14 @@
 
   setup(async () => {
     element = basicFixture.instantiate();
-    await flush();
+    await element.updateComplete;
   });
 
   test('dismiss tap fires event', async () => {
     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 3b09d8c..369e806 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 {appContext} from '../../../services/app-context';
+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;
@@ -47,10 +46,6 @@
 const SIGN_IN_WIDTH_PX = 690;
 const SIGN_IN_HEIGHT_PX = 500;
 const TOO_MANY_FILES = 'too many files to find conflicts';
-/* TODO: This error is suppressed to allow rolling upgrades.
- * Remove on stable-3.6 */
-const CONFLICTS_OPERATOR_IS_NOT_SUPPORTED =
-  "'conflicts:' operator is not supported by server";
 const AUTHENTICATION_REQUIRED = 'Authentication required\n';
 
 // Bigger number has higher priority
@@ -71,14 +66,6 @@
 
 export const __testOnly_ErrorType = ErrorType;
 
-export interface GrErrorManager {
-  $: {
-    noInteractionOverlay: GrOverlay;
-    errorDialog: GrErrorDialog;
-    errorOverlay: GrOverlay;
-  };
-}
-
 export function constructServerErrorMsg({
   errorText,
   status,
@@ -111,45 +98,45 @@
 }
 
 @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';
 
-  private readonly reporting = appContext.reportingService;
+  @property({type: String})
+  loginText = 'Sign in';
 
-  private readonly _authService = appContext.authService;
+  private readonly reporting = getAppContext().reportingService;
 
-  private readonly eventEmitter = appContext.eventEmitter;
+  private readonly _authService = getAppContext().authService;
 
-  _authErrorHandlerDeregistrationHook?: Function;
+  private readonly eventEmitter = getAppContext().eventEmitter;
 
-  private readonly restApiService = appContext.restApiService;
+  private authErrorHandlerDeregistrationHook?: Function;
+
+  private readonly restApiService = getAppContext().restApiService;
 
   private checkLoggedInTask?: DelayedTask;
 
@@ -163,10 +150,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);
       }
     );
 
@@ -176,7 +163,7 @@
   }
 
   override disconnectedCallback() {
-    this._clearHideAlertHandle();
+    this.clearHideAlertHandle();
     document.removeEventListener(
       EventType.SERVER_ERROR,
       this.handleServerError
@@ -195,29 +182,47 @@
     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) {
-    return (
-      msg.includes(TOO_MANY_FILES) ||
-      msg.includes(CONFLICTS_OPERATOR_IS_NOT_SUPPORTED)
-    );
+  override render() {
+    return html`
+      <gr-overlay with-backdrop="" id="errorOverlay">
+        <gr-error-dialog
+          id="errorDialog"
+          @dismiss=${() => this.errorOverlay.close()}
+          .loginUrl=${this.loginUrl}
+          .loginText=${this.loginText}
+        ></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);
     });
   }
 
@@ -244,11 +249,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,
@@ -256,9 +261,9 @@
             trace,
           });
         } else if (response.status === 429) {
-          this._showQuotaExceeded({status, statusText});
+          this.showQuotaExceeded({status, statusText});
         } else {
-          this._showErrorDialog(
+          this.showErrorDialog(
             constructServerErrorMsg({
               status,
               statusText,
@@ -273,7 +278,7 @@
     });
   };
 
-  _showNotFoundMessageWithTip({
+  private showNotFoundMessageWithTip({
     status,
     statusText,
     errorText,
@@ -284,7 +289,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,
@@ -300,10 +305,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,
@@ -331,7 +336,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];
   }
 
@@ -343,70 +349,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;
@@ -420,49 +428,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) {
@@ -470,7 +480,7 @@
                 oldAccount: !!this.knownAccountId,
                 newAccount: !!account?._account_id,
               });
-              this._reloadPage();
+              this.reloadPage();
               return;
             }
 
@@ -481,11 +491,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 = [
@@ -502,12 +512,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();
@@ -518,19 +529,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 80ebf2d..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
@@ -23,7 +23,7 @@
   __testOnly_ErrorType,
 } from './gr-error-manager';
 import {stubAuth, stubReporting, stubRestApi} from '../../../test/test-utils';
-import {appContext} from '../../../services/app-context';
+import {AppContext, getAppContext} from '../../../services/app-context';
 import {
   createAccountDetailWithId,
   createPreferences,
@@ -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;
@@ -41,20 +41,25 @@
     let toastSpy: sinon.SinonSpy;
     let fetchStub: sinon.SinonStub;
     let getLoggedInStub: sinon.SinonStub;
+    let appContext: AppContext;
 
-    setup(() => {
+    setup(async () => {
       fetchStub = stubAuth('fetch').returns(
         Promise.resolve({...new Response(), ok: true, status: 204})
       );
+      appContext = getAppContext();
       getLoggedInStub = stubRestApi('getLoggedIn').callsFake(() =>
         appContext.authService.authCheck()
       );
       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(() => {
@@ -64,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', {
@@ -85,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(
@@ -130,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,
@@ -146,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', {
@@ -217,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 () => {
@@ -241,24 +243,6 @@
       assert.isFalse(showAlertStub.called);
     });
 
-    test('suppress CONFLICTS_OPERATOR_IS_NOT_SUPPORTED error', async () => {
-      const showAlertStub = sinon.stub(element, '_showAlert');
-      const textSpy = sinon.spy(() =>
-        Promise.resolve("'conflicts:' operator is not supported by server")
-      );
-      element.dispatchEvent(
-        new CustomEvent('server-error', {
-          detail: {response: {status: 500, text: textSpy}},
-          composed: true,
-          bubbles: true,
-        })
-      );
-
-      assert.isTrue(textSpy.called);
-      await flush();
-      assert.isFalse(showAlertStub.called);
-    });
-
     test('show network error', async () => {
       const showAlertStub = sinon.stub(element, '_showAlert');
       element.dispatchEvent(
@@ -275,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
         )
@@ -352,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,
@@ -376,7 +358,7 @@
 
       clock.tick(1000);
       element.knownAccountId = 5 as AccountId;
-      element._checkSignedIn();
+      element.checkSignedIn();
       await flush();
 
       assert.isTrue(refreshStub.called);
@@ -541,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;
 
@@ -557,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 () => {
@@ -565,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);
@@ -583,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';
@@ -606,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,
@@ -624,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();
 
@@ -645,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(() => {
@@ -658,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-key-binding-display/gr-key-binding-display.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
index 1ae0992..2a3936f 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
@@ -25,6 +25,9 @@
 
 @customElement('gr-key-binding-display')
 export class GrKeyBindingDisplay extends LitElement {
+  @property({type: Array})
+  binding: string[][] = [];
+
   static override get styles() {
     return [
       css`
@@ -53,9 +56,6 @@
     return html`${items}`;
   }
 
-  @property({type: Array})
-  binding: string[][] = [];
-
   _computeModifiers(binding: string[]) {
     return binding.slice(0, binding.length - 1);
   }
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 8610999..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
@@ -16,16 +16,15 @@
  */
 import '../../shared/gr-button/gr-button';
 import '../gr-key-binding-display/gr-key-binding-display';
-import '../../../styles/shared-styles';
-import '../../../styles/gr-font-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-keyboard-shortcuts-dialog_html';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 import {
   ShortcutSection,
   SectionView,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {property, customElement} from '@polymer/decorators';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ShortcutViewListener} from '../../../services/shortcuts/shortcuts-service';
 
 declare global {
@@ -40,11 +39,7 @@
 }
 
 @customElement('gr-keyboard-shortcuts-dialog')
-export class GrKeyboardShortcutsDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrKeyboardShortcutsDialog extends LitElement {
   /**
    * Fired when the user presses the close button.
    *
@@ -59,7 +54,7 @@
 
   private readonly shortcutListener: ShortcutViewListener;
 
-  private readonly shortcuts = appContext.shortcutsService;
+  private readonly shortcuts = getAppContext().shortcutsService;
 
   constructor() {
     super();
@@ -67,9 +62,103 @@
       this._onDirectoryUpdated(d);
   }
 
-  override ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      css`
+        :host {
+          display: block;
+          max-height: 100vh;
+          min-width: 60vw;
+          overflow-y: auto;
+        }
+        main {
+          display: flex;
+          padding: 0 var(--spacing-xxl) var(--spacing-xxl);
+        }
+        .column {
+          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 {
+          padding-top: var(--spacing-l);
+          text-align: left;
+        }
+        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;
+        }
+        td:last-child,
+        th:last-child {
+          text-align: left;
+        }
+        td:last-child {
+          color: var(--deemphasized-text-color);
+        }
+        th {
+          color: var(--deemphasized-text-color);
+        }
+        .modifier {
+          font-weight: var(--font-weight-normal);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <header>
+        <h3 class="heading-2">Keyboard shortcuts</h3>
+        <gr-button link="" @click=${this.handleCloseTap}>Close</gr-button>
+      </header>
+      <main>
+        <div class="column">
+          ${this._left?.map(section => this.renderSection(section))}
+        </div>
+        <div class="column">
+          ${this._right?.map(section => this.renderSection(section))}
+        </div>
+      </main>
+      <footer></footer>
+    `;
+  }
+
+  private renderSection(section: SectionShortcut) {
+    return html`<table>
+      <caption class="heading-3">
+        ${section.section}
+      </caption>
+      <thead>
+        <tr>
+          <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>
+          </tr>`
+        )}
+      </tbody>
+    </table>`;
   }
 
   override connectedCallback() {
@@ -82,7 +171,7 @@
     super.disconnectedCallback();
   }
 
-  _handleCloseTap(e: MouseEvent) {
+  private handleCloseTap(e: MouseEvent) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -122,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),
       });
@@ -142,7 +231,7 @@
       });
     }
 
-    this.set('_left', left);
-    this.set('_right', right);
+    this._right = right;
+    this._left = left;
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
deleted file mode 100644
index 4992daa..0000000
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
+++ /dev/null
@@ -1,137 +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;
-      max-height: 100vh;
-      overflow-y: auto;
-    }
-    header {
-      padding: var(--spacing-l);
-    }
-    main {
-      display: flex;
-      padding: 0 var(--spacing-xxl) var(--spacing-xxl);
-    }
-    .column {
-      flex: 50%;
-    }
-    header {
-      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;
-    }
-    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;
-    }
-    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);
-    }
-  </style>
-  <header>
-    <h3 class="heading-3">Keyboard shortcuts</h3>
-    <gr-button link="" on-click="_handleCloseTap">Close</gr-button>
-  </header>
-  <main>
-    <div class="column">
-      <template is="dom-repeat" items="[[_left]]">
-        <table>
-          <caption>
-            [[item.section]]
-          </caption>
-          <thead>
-            <tr>
-              <th>Key</th>
-              <th>Action</th>
-            </tr>
-          </thead>
-          <tbody>
-            <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
-              <tr>
-                <td>
-                  <gr-key-binding-display binding="[[shortcut.binding]]">
-                  </gr-key-binding-display>
-                </td>
-                <td>[[shortcut.text]]</td>
-              </tr>
-            </template>
-          </tbody>
-        </table>
-      </template>
-    </div>
-    <div class="column">
-      <template is="dom-repeat" items="[[_right]]">
-        <table>
-          <caption>
-            [[item.section]]
-          </caption>
-          <thead>
-            <tr>
-              <th>Key</th>
-              <th>Action</th>
-            </tr>
-          </thead>
-          <tbody>
-            <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
-              <tr>
-                <td>
-                  <gr-key-binding-display binding="[[shortcut.binding]]">
-                  </gr-key-binding-display>
-                </td>
-                <td>[[shortcut.text]]</td>
-              </tr>
-            </template>
-          </tbody>
-        </table>
-      </template>
-    </div>
-  </main>
-  <footer></footer>
-`;
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 2c76704..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
@@ -16,6 +16,7 @@
  */
 
 import '../../../test/common-test-setup-karma';
+import './gr-keyboard-shortcuts-dialog';
 import {GrKeyboardShortcutsDialog} from './gr-keyboard-shortcuts-dialog';
 import {
   SectionView,
@@ -27,8 +28,9 @@
 suite('gr-keyboard-shortcuts-dialog tests', () => {
   let element: GrKeyboardShortcutsDialog;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await flush();
   });
 
   function update(directory: Map<ShortcutSection, SectionView>) {
@@ -78,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 9d34929..1382bb8 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
@@ -14,17 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {Subscription} from 'rxjs';
+import {map, distinctUntilChanged} from 'rxjs/operators';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-icons/gr-icons';
 import '../gr-account-dropdown/gr-account-dropdown';
 import '../gr-smart-search/gr-smart-search';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-main-header_html';
 import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAdminLinks, NavLink} from '../../../utils/admin-nav-util';
-import {customElement, property} from '@polymer/decorators';
 import {
   AccountDetailInfo,
   RequireProperties,
@@ -34,12 +33,13 @@
 } from '../../../types/common';
 import {AuthType} from '../../../constants/constants';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
-import {appContext} from '../../../services/app-context';
-import {Subject} from 'rxjs';
-import {serverConfig$} from '../../../services/config/config-model';
-import {takeUntil} from 'rxjs/operators';
-import {myTopMenuItems$} from '../../../services/user/user-model';
-import {assertIsDefined} from '../../../utils/common-util';
+import {getAppContext} from '../../../services/app-context';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {fireEvent} from '../../../utils/event-util';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
 
@@ -103,107 +103,397 @@
   AuthType.CUSTOM_EXTENSION,
 ]);
 
-@customElement('gr-main-header')
-export class GrMainHeader extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-main-header': GrMainHeader;
   }
+}
 
-  @property({type: String, notify: true})
+@customElement('gr-main-header')
+export class GrMainHeader extends LitElement {
+  @property({type: String})
   searchQuery = '';
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   loggedIn?: boolean;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   loading?: boolean;
 
-  @property({type: Object})
-  _account?: AccountDetailInfo;
-
-  @property({type: Array})
-  _adminLinks: NavLink[] = [];
-
-  @property({type: String})
-  _docBaseUrl: string | null = null;
-
-  @property({
-    type: Array,
-    computed: '_computeLinks(_userLinks, _adminLinks, _topMenus, _docBaseUrl)',
-  })
-  _links?: MainHeaderLinkGroup[];
-
   @property({type: String})
   loginUrl = '/login';
 
-  @property({type: Array})
-  _userLinks: MainHeaderLink[] = [];
-
-  @property({type: Array})
-  _topMenus?: TopMenuEntryInfo[] = [];
-
   @property({type: String})
-  _registerText = 'Sign up';
-
-  // Empty string means that the register <div> will be hidden.
-  @property({type: String})
-  _registerURL = '';
-
-  @property({type: String})
-  _feedbackURL = '';
+  loginText = 'Sign in';
 
   @property({type: Boolean})
   mobileSearchHidden = false;
 
-  private readonly restApiService = appContext.restApiService;
+  // private but used in test
+  @state() account?: AccountDetailInfo;
 
-  private readonly jsAPI = appContext.jsApiService;
+  @state() private adminLinks: NavLink[] = [];
 
-  private readonly disconnected$ = new Subject();
+  @state() private docBaseUrl: string | null = null;
 
-  override ready() {
-    super.ready();
-    this._ensureAttribute('role', 'banner');
-  }
+  @state() private userLinks: MainHeaderLink[] = [];
+
+  @state() private topMenus?: TopMenuEntryInfo[] = [];
+
+  // private but used in test
+  @state() registerText = 'Sign up';
+
+  // Empty string means that the register <div> will be hidden.
+  // private but used in test
+  @state() registerURL = '';
+
+  // private but used in test
+  @state() feedbackURL = '';
+
+  @state() private serverConfig?: ServerInfo;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly jsAPI = getAppContext().jsApiService;
+
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly configModel = resolve(this, configModelToken);
+
+  private subscriptions: Subscription[] = [];
 
   override connectedCallback() {
-    // TODO(brohlfs): This just ensures that the userService is instantiated at
-    // all. We need the service to manage the model, but we are not making any
-    // direct calls. Will need to find a better solution to this problem ...
-    assertIsDefined(appContext.userService);
-
     super.connectedCallback();
-    this._loadAccount();
+    this.loadAccount();
 
-    myTopMenuItems$.pipe(takeUntil(this.disconnected$)).subscribe(items => {
-      this._userLinks = items.map(this._createHeaderLink);
-    });
-
-    serverConfig$.pipe(takeUntil(this.disconnected$)).subscribe(config => {
-      if (!config) return;
-      this._retrieveFeedbackURL(config);
-      this._retrieveRegisterURL(config);
-      getDocsBaseUrl(config, this.restApiService).then(docBaseUrl => {
-        this._docBaseUrl = docBaseUrl;
-      });
-    });
+    this.subscriptions.push(
+      this.userModel.preferences$
+        .pipe(
+          map(preferences => preferences?.my ?? []),
+          distinctUntilChanged()
+        )
+        .subscribe(items => {
+          this.userLinks = items.map(this.createHeaderLink);
+        })
+    );
+    this.subscriptions.push(
+      this.configModel().serverConfig$.subscribe(config => {
+        if (!config) return;
+        this.serverConfig = config;
+        this.retrieveFeedbackURL(config);
+        this.retrieveRegisterURL(config);
+        getDocsBaseUrl(config, this.restApiService).then(docBaseUrl => {
+          this.docBaseUrl = docBaseUrl;
+        });
+      })
+    );
   }
 
   override disconnectedCallback() {
-    this.disconnected$.next();
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
     super.disconnectedCallback();
   }
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        nav {
+          align-items: center;
+          display: flex;
+        }
+        .bigTitle {
+          color: var(--header-text-color);
+          font-size: var(--header-title-font-size);
+          text-decoration: none;
+        }
+        .bigTitle:hover {
+          text-decoration: underline;
+        }
+        .titleText::before {
+          background-image: var(--header-icon);
+          background-size: var(--header-icon-size) var(--header-icon-size);
+          background-repeat: no-repeat;
+          content: '';
+          display: inline-block;
+          height: var(--header-icon-size);
+          margin-right: calc(var(--header-icon-size) / 4);
+          vertical-align: text-bottom;
+          width: var(--header-icon-size);
+        }
+        .titleText::after {
+          content: var(--header-title-content);
+        }
+        ul {
+          list-style: none;
+          padding-left: var(--spacing-l);
+        }
+        .links > li {
+          cursor: default;
+          display: inline-block;
+          padding: 0;
+          position: relative;
+        }
+        .linksTitle {
+          display: inline-block;
+          font-weight: var(--font-weight-bold);
+          position: relative;
+          text-transform: uppercase;
+        }
+        .linksTitle:hover {
+          opacity: 0.75;
+        }
+        .rightItems {
+          align-items: center;
+          display: flex;
+          flex: 1;
+          justify-content: flex-end;
+        }
+        .rightItems gr-endpoint-decorator:not(:empty) {
+          margin-left: var(--spacing-l);
+        }
+        gr-smart-search {
+          flex-grow: 1;
+          margin: 0 var(--spacing-m);
+          max-width: 500px;
+          min-width: 150px;
+        }
+        gr-dropdown,
+        .browse {
+          padding: var(--spacing-m);
+        }
+        gr-dropdown {
+          --gr-dropdown-item-color: var(--primary-text-color);
+        }
+        .settingsButton {
+          margin-left: var(--spacing-m);
+        }
+        .feedbackButton {
+          margin-left: var(--spacing-s);
+        }
+        .browse {
+          color: var(--header-text-color);
+          /* Same as gr-button */
+          margin: 5px 4px;
+          text-decoration: none;
+        }
+        .invisible,
+        .settingsButton,
+        gr-account-dropdown {
+          display: none;
+        }
+        :host([loading]) .accountContainer,
+        :host([loggedIn]) .loginButton,
+        :host([loggedIn]) .registerButton {
+          display: none;
+        }
+        :host([loggedIn]) .settingsButton,
+        :host([loggedIn]) gr-account-dropdown {
+          display: inline;
+        }
+        .accountContainer {
+          align-items: center;
+          display: flex;
+          margin: 0 calc(0 - var(--spacing-m)) 0 var(--spacing-m);
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .loginButton,
+        .registerButton {
+          padding: var(--spacing-m) var(--spacing-l);
+        }
+        .dropdown-trigger {
+          text-decoration: none;
+        }
+        .dropdown-content {
+          background-color: var(--view-background-color);
+          box-shadow: var(--elevation-level-2);
+        }
+        /*
+           * We are not using :host to do this, because :host has a lowest css priority
+           * compared to others. This means that using :host to do this would break styles.
+           */
+        .linksTitle,
+        .bigTitle,
+        .loginButton,
+        .registerButton,
+        iron-icon,
+        gr-account-dropdown {
+          color: var(--header-text-color);
+        }
+        #mobileSearch {
+          display: none;
+        }
+        @media screen and (max-width: 50em) {
+          .bigTitle {
+            font-family: var(--header-font-family);
+            font-size: var(--font-size-h3);
+            font-weight: var(--font-weight-h3);
+            line-height: var(--line-height-h3);
+          }
+          gr-smart-search,
+          .browse,
+          .rightItems .hideOnMobile,
+          .links > li.hideOnMobile {
+            display: none;
+          }
+          #mobileSearch {
+            display: inline-flex;
+          }
+          .accountContainer {
+            margin-left: var(--spacing-m) !important;
+          }
+          gr-dropdown {
+            padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+  <nav>
+    <a href=${`//${window.location.host}${getBaseUrl()}/`} class="bigTitle">
+      <gr-endpoint-decorator name="header-title">
+        <span class="titleText"></span>
+      </gr-endpoint-decorator>
+    </a>
+    <ul class="links">
+      ${this.computeLinks(
+        this.userLinks,
+        this.adminLinks,
+        this.topMenus,
+        this.docBaseUrl
+      ).map(linkGroup => this.renderLinkGroup(linkGroup))}
+    </ul>
+    <div class="rightItems">
+      <gr-endpoint-decorator
+        class="hideOnMobile"
+        name="header-small-banner"
+      ></gr-endpoint-decorator>
+      <gr-smart-search
+        id="search"
+        label="Search for changes"
+        .searchQuery=${this.searchQuery}
+        .serverConfig=${this.serverConfig}
+      ></gr-smart-search>
+      <gr-endpoint-decorator
+        class="hideOnMobile"
+        name="header-browse-source"
+      ></gr-endpoint-decorator>
+      <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
+        ${this.renderFeedback()}
+      </gr-endpoint-decorator>
+      </div>
+      ${this.renderAccount()}
+    </div>
+  </nav>
+    `;
+  }
+
+  private renderLinkGroup(linkGroup: MainHeaderLinkGroup) {
+    return html`
+      <li class=${linkGroup.class ?? ''}>
+        <gr-dropdown
+          link
+          down-arrow
+          .items=${linkGroup.links}
+          horizontal-align="left"
+        >
+          <span class="linksTitle" id=${linkGroup.title}>
+            ${linkGroup.title}
+          </span>
+        </gr-dropdown>
+      </li>
+    `;
+  }
+
+  private renderFeedback() {
+    if (!this.feedbackURL) return;
+
+    return html`
+      <a
+        href=${this.feedbackURL}
+        title="File a bug"
+        aria-label="File a bug"
+        target="_blank"
+        role="button"
+      >
+        <iron-icon icon="gr-icons:bug"></iron-icon>
+      </a>
+    `;
+  }
+
+  private renderAccount() {
+    return html`
+      <div class="accountContainer" id="accountContainer">
+        <iron-icon
+          id="mobileSearch"
+          icon="gr-icons:search"
+          @click=${(e: Event) => {
+            this.onMobileSearchTap(e);
+          }}
+          role="button"
+          aria-label=${this.mobileSearchHidden
+            ? 'Show Searchbar'
+            : 'Hide Searchbar'}
+        ></iron-icon>
+        ${this.renderRegister()}
+        <a class="loginButton" href=${this.loginUrl}>${this.loginText}</a>
+        <a
+          class="settingsButton"
+          href="${getBaseUrl()}/settings/"
+          title="Settings"
+          aria-label="Settings"
+          role="button"
+        >
+          <iron-icon icon="gr-icons:settings"></iron-icon>
+        </a>
+        ${this.renderAccountDropdown()}
+      </div>
+    `;
+  }
+
+  private renderRegister() {
+    if (!this.registerURL) return;
+
+    return html`
+      <div class="registerDiv">
+        <a class="registerButton" href=${this.registerURL}>
+          ${this.registerText}
+        </a>
+      </div>
+    `;
+  }
+
+  private renderAccountDropdown() {
+    if (!this.account) return;
+
+    return html`
+      <gr-account-dropdown .account=${this.account}></gr-account-dropdown>
+    `;
+  }
+
+  override firstUpdated(changedProperties: PropertyValues) {
+    super.firstUpdated(changedProperties);
+    if (!this.getAttribute('role')) this.setAttribute('role', 'banner');
+  }
+
   reload() {
-    this._loadAccount();
+    this.loadAccount();
   }
 
-  _computeRelativeURL(path: string) {
-    return '//' + window.location.host + getBaseUrl() + path;
-  }
-
-  _computeLinks(
-    userLinks?: TopMenuItemInfo[],
+  // private but used in test
+  computeLinks(
+    userLinks?: MainHeaderLink[],
     adminLinks?: NavLink[],
     topMenus?: TopMenuEntryInfo[],
     docBaseUrl?: string | null,
@@ -217,7 +507,7 @@
       topMenus === undefined ||
       docBaseUrl === undefined
     ) {
-      return undefined;
+      return [];
     }
 
     const links: MainHeaderLinkGroup[] = defaultLinks.map(menu => {
@@ -232,7 +522,7 @@
         links: userLinks.slice(),
       });
     }
-    const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+    const docLinks = this.getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
     if (docLinks.length) {
       links.push({
         title: 'Documentation',
@@ -249,7 +539,7 @@
       topMenuLinks[link.title] = link.links;
     });
     for (const m of topMenus) {
-      const items = m.items.map(this._createHeaderLink).filter(
+      const items = m.items.map(this.createHeaderLink).filter(
         link =>
           // Ignore GWT project links
           !link.url.includes('${projectName}')
@@ -268,7 +558,8 @@
     return links;
   }
 
-  _getDocLinks(docBaseUrl: string | null, docLinks: MainHeaderLink[]) {
+  // private but used in test
+  getDocLinks(docBaseUrl: string | null, docLinks: MainHeaderLink[]) {
     if (!docBaseUrl) {
       return [];
     }
@@ -285,7 +576,8 @@
     });
   }
 
-  _loadAccount() {
+  // private but used in test
+  loadAccount() {
     this.loading = true;
 
     return Promise.all([
@@ -294,10 +586,10 @@
       getPluginLoader().awaitPluginsLoaded(),
     ]).then(result => {
       const account = result[0];
-      this._account = account;
+      this.account = account;
       this.loggedIn = !!account;
       this.loading = false;
-      this._topMenus = result[1];
+      this.topMenus = result[1];
 
       return getAdminLinks(
         account,
@@ -310,33 +602,32 @@
           }),
         () => this.jsAPI.getAdminMenuLinks()
       ).then(res => {
-        this._adminLinks = res.links;
+        this.adminLinks = res.links;
       });
     });
   }
 
-  _retrieveFeedbackURL(config: ServerInfo) {
+  // private but used in test
+  retrieveFeedbackURL(config: ServerInfo) {
     if (config.gerrit?.report_bug_url) {
-      this._feedbackURL = config.gerrit.report_bug_url;
+      this.feedbackURL = config.gerrit.report_bug_url;
     }
   }
 
-  _retrieveRegisterURL(config: ServerInfo) {
+  // private but used in test
+  retrieveRegisterURL(config: ServerInfo) {
     if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
-      this._registerURL = config.auth.register_url ?? '';
+      this.registerURL = config.auth.register_url ?? '';
       if (config.auth.register_text) {
-        this._registerText = config.auth.register_text;
+        this.registerText = config.auth.register_text;
       }
     }
   }
 
-  _computeRegisterHidden(registerURL: string) {
-    return !registerURL;
-  }
-
-  _createHeaderLink(linkObj: TopMenuItemInfo): MainHeaderLink {
+  // private but used in test
+  createHeaderLink(linkObj: TopMenuItemInfo): MainHeaderLink {
     // Delete target property due to complications of
-    // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
+    // https://issues.gerritcodereview.com/issues/40006107
     //
     // The server tries to guess whether URL is a view within the UI.
     // If not, it sets target='_blank' on the menu item. The server
@@ -353,36 +644,9 @@
     return headerLink;
   }
 
-  _generateSettingsLink() {
-    return getBaseUrl() + '/settings/';
-  }
-
-  _onMobileSearchTap(e: Event) {
+  private onMobileSearchTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('mobile-search', {
-        composed: true,
-        bubbles: false,
-      })
-    );
-  }
-
-  _computeLinkGroupClass(linkGroup: MainHeaderLinkGroup) {
-    return linkGroup.class ?? '';
-  }
-
-  _computeShowHideAriaLabel(mobileSearchHidden: boolean) {
-    if (mobileSearchHidden) {
-      return 'Show Searchbar';
-    } else {
-      return 'Hide Searchbar';
-    }
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-main-header': GrMainHeader;
+    fireEvent(this, 'mobile-search');
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
deleted file mode 100644
index 9ea9dc1..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
+++ /dev/null
@@ -1,259 +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;
-    }
-    nav {
-      align-items: center;
-      display: flex;
-    }
-    .bigTitle {
-      color: var(--header-text-color);
-      font-size: var(--header-title-font-size);
-      text-decoration: none;
-    }
-    .bigTitle:hover {
-      text-decoration: underline;
-    }
-    .titleText::before {
-      background-image: var(--header-icon);
-      background-size: var(--header-icon-size) var(--header-icon-size);
-      background-repeat: no-repeat;
-      content: '';
-      display: inline-block;
-      height: var(--header-icon-size);
-      margin-right: calc(var(--header-icon-size) / 4);
-      vertical-align: text-bottom;
-      width: var(--header-icon-size);
-    }
-    .titleText::after {
-      content: var(--header-title-content);
-    }
-    ul {
-      list-style: none;
-      padding-left: var(--spacing-l);
-    }
-    .links > li {
-      cursor: default;
-      display: inline-block;
-      padding: 0;
-      position: relative;
-    }
-    .linksTitle {
-      display: inline-block;
-      font-weight: var(--font-weight-bold);
-      position: relative;
-      text-transform: uppercase;
-    }
-    .linksTitle:hover {
-      opacity: 0.75;
-    }
-    .rightItems {
-      align-items: center;
-      display: flex;
-      flex: 1;
-      justify-content: flex-end;
-    }
-    .rightItems gr-endpoint-decorator:not(:empty) {
-      margin-left: var(--spacing-l);
-    }
-    gr-smart-search {
-      flex-grow: 1;
-      margin: 0 var(--spacing-m);
-      max-width: 500px;
-      min-width: 150px;
-    }
-    gr-dropdown,
-    .browse {
-      padding: var(--spacing-m);
-    }
-    gr-dropdown {
-      --gr-dropdown-item-color: var(--primary-text-color);
-    }
-    .settingsButton {
-      margin-left: var(--spacing-m);
-    }
-    .feedbackButton {
-      margin-left: var(--spacing-s);
-    }
-    .browse {
-      color: var(--header-text-color);
-      /* Same as gr-button */
-      margin: 5px 4px;
-      text-decoration: none;
-    }
-    .invisible,
-    .settingsButton,
-    gr-account-dropdown {
-      display: none;
-    }
-    :host([loading]) .accountContainer,
-    :host([logged-in]) .loginButton,
-    :host([logged-in]) .registerButton {
-      display: none;
-    }
-    :host([logged-in]) .settingsButton,
-    :host([logged-in]) gr-account-dropdown {
-      display: inline;
-    }
-    .accountContainer {
-      align-items: center;
-      display: flex;
-      margin: 0 calc(0 - var(--spacing-m)) 0 var(--spacing-m);
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    .loginButton,
-    .registerButton {
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    .dropdown-trigger {
-      text-decoration: none;
-    }
-    .dropdown-content {
-      background-color: var(--view-background-color);
-      box-shadow: var(--elevation-level-2);
-    }
-    /*
-       * We are not using :host to do this, because :host has a lowest css priority
-       * compared to others. This means that using :host to do this would break styles.
-       */
-    .linksTitle,
-    .bigTitle,
-    .loginButton,
-    .registerButton,
-    iron-icon,
-    gr-account-dropdown {
-      color: var(--header-text-color);
-    }
-    #mobileSearch {
-      display: none;
-    }
-    @media screen and (max-width: 50em) {
-      .bigTitle {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      gr-smart-search,
-      .browse,
-      .rightItems .hideOnMobile,
-      .links > li.hideOnMobile {
-        display: none;
-      }
-      #mobileSearch {
-        display: inline-flex;
-      }
-      .accountContainer {
-        margin-left: var(--spacing-m) !important;
-      }
-      gr-dropdown {
-        padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
-      }
-    }
-  </style>
-  <nav>
-    <a href$="[[_computeRelativeURL('/')]]" class="bigTitle">
-      <gr-endpoint-decorator name="header-title">
-        <span class="titleText"></span>
-      </gr-endpoint-decorator>
-    </a>
-    <ul class="links">
-      <template is="dom-repeat" items="[[_links]]" as="linkGroup">
-        <li class$="[[_computeLinkGroupClass(linkGroup)]]">
-          <gr-dropdown
-            link=""
-            down-arrow=""
-            items="[[linkGroup.links]]"
-            horizontal-align="left"
-          >
-            <span class="linksTitle" id="[[linkGroup.title]]">
-              [[linkGroup.title]]
-            </span>
-          </gr-dropdown>
-        </li>
-      </template>
-    </ul>
-    <div class="rightItems">
-      <gr-endpoint-decorator
-        class="hideOnMobile"
-        name="header-small-banner"
-      ></gr-endpoint-decorator>
-      <gr-smart-search
-        id="search"
-        label="Search for changes"
-        search-query="{{searchQuery}}"
-      ></gr-smart-search>
-      <gr-endpoint-decorator
-        class="hideOnMobile"
-        name="header-browse-source"
-      ></gr-endpoint-decorator>
-      <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
-        <template is="dom-if" if="[[_feedbackURL]]">
-          <a
-            href$="[[_feedbackURL]]"
-            title="File a bug"
-            aria-label="File a bug"
-            target="_blank"
-            role="button"
-          >
-            <iron-icon icon="gr-icons:bug"></iron-icon>
-          </a>
-        </template>
-      </gr-endpoint-decorator>
-      </div>
-      <div class="accountContainer" id="accountContainer">
-        <div>
-          <iron-icon
-            id="mobileSearch"
-            icon="gr-icons:search"
-            on-click="_onMobileSearchTap"
-            role="button"
-            aria-label="[[_computeShowHideAriaLabel(mobileSearchHidden)]]"
-          ></iron-icon>
-        </div>
-        <div
-          class="registerDiv"
-          hidden="[[_computeRegisterHidden(_registerURL)]]"
-        >
-          <a class="registerButton" href$="[[_registerURL]]">
-            [[_registerText]]
-          </a>
-        </div>
-        <a class="loginButton" href$="[[loginUrl]]">Sign in</a>
-        <a
-          class="settingsButton"
-          href$="[[_generateSettingsLink()]]"
-          title="Settings"
-          aria-label="Settings"
-          role="button"
-        >
-          <iron-icon icon="gr-icons:settings"></iron-icon>
-        </a>
-        <template is="dom-if" if="[[_account]]">
-          <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
-        </template>
-      </div>
-    </div>
-  </nav>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index 0f58ac0..4dcb755 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -33,29 +33,33 @@
 suite('gr-main-header tests', () => {
   let element: GrMainHeader;
 
-  setup(() => {
+  setup(async () => {
     stubRestApi('probePath').returns(Promise.resolve(false));
-    stub('gr-main-header', '_loadAccount').callsFake(() => Promise.resolve());
+    stub('gr-main-header', 'loadAccount').callsFake(() => Promise.resolve());
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
-  test('link visibility', () => {
+  test('link visibility', async () => {
     element.loading = true;
+    await element.updateComplete;
     assert.isTrue(isHidden(query(element, '.accountContainer')));
 
     element.loading = false;
     element.loggedIn = false;
+    await element.updateComplete;
     assert.isFalse(isHidden(query(element, '.accountContainer')));
     assert.isFalse(isHidden(query(element, '.loginButton')));
-    assert.isFalse(isHidden(query(element, '.registerButton')));
-    assert.isTrue(isHidden(query(element, '.registerDiv')));
+    assert.isNotOk(query(element, '.registerDiv'));
+    assert.isNotOk(query(element, '.registerButton'));
 
-    element._account = createAccountDetailWithId(1);
-    flush();
+    element.account = createAccountDetailWithId(1);
+    await element.updateComplete;
     assert.isTrue(isHidden(query(element, 'gr-account-dropdown')));
     assert.isTrue(isHidden(query(element, '.settingsButton')));
 
     element.loggedIn = true;
+    await element.updateComplete;
     assert.isTrue(isHidden(query(element, '.loginButton')));
     assert.isTrue(isHidden(query(element, '.registerButton')));
     assert.isFalse(isHidden(query(element, 'gr-account-dropdown')));
@@ -67,7 +71,7 @@
       [
         {url: 'https://awesometown.com/#hashyhash', name: '', target: ''},
         {url: 'url', name: '', target: '_blank'},
-      ].map(element._createHeaderLink),
+      ].map(element.createHeaderLink),
       [
         {url: 'https://awesometown.com/#hashyhash', name: ''},
         {url: 'url', name: ''},
@@ -99,13 +103,13 @@
         name: 'Repos',
         url: '/repos',
         noBaseUrl: true,
-        view: null,
+        view: undefined,
       },
     ];
 
     // When no admin links are passed, it should use the default.
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         /* userLinks= */ [],
         adminLinks,
         /* topMenus= */ [],
@@ -118,7 +122,7 @@
       })
     );
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         userLinks,
         adminLinks,
         /* topMenus= */ [],
@@ -146,11 +150,11 @@
       },
     ];
 
-    assert.deepEqual(element._getDocLinks(null, docLinks), []);
-    assert.deepEqual(element._getDocLinks('', docLinks), []);
-    assert.deepEqual(element._getDocLinks('base', []), []);
+    assert.deepEqual(element.getDocLinks(null, docLinks), []);
+    assert.deepEqual(element.getDocLinks('', docLinks), []);
+    assert.deepEqual(element.getDocLinks('base', []), []);
 
-    assert.deepEqual(element._getDocLinks('base', docLinks), [
+    assert.deepEqual(element.getDocLinks('base', docLinks), [
       {
         name: 'Table of Contents',
         target: '_blank',
@@ -158,7 +162,7 @@
       },
     ]);
 
-    assert.deepEqual(element._getDocLinks('base/', docLinks), [
+    assert.deepEqual(element.getDocLinks('base/', docLinks), [
       {
         name: 'Table of Contents',
         target: '_blank',
@@ -173,7 +177,7 @@
         name: 'Repos',
         url: '/repos',
         noBaseUrl: true,
-        view: null,
+        view: undefined,
       },
     ];
     const topMenus = [
@@ -189,7 +193,7 @@
       },
     ];
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         /* userLinks= */ [],
         adminLinks,
         topMenus,
@@ -220,7 +224,7 @@
         name: 'Repos',
         url: '/repos',
         noBaseUrl: true,
-        view: null,
+        view: undefined,
       },
     ];
     const topMenus = [
@@ -241,7 +245,7 @@
       },
     ];
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         /* userLinks= */ [],
         adminLinks,
         topMenus,
@@ -272,7 +276,7 @@
         name: 'Repos',
         url: '/repos',
         noBaseUrl: true,
-        view: null,
+        view: undefined,
       },
     ];
     const topMenus = [
@@ -298,7 +302,7 @@
       },
     ];
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         /* userLinks= */ [],
         adminLinks,
         topMenus,
@@ -352,7 +356,7 @@
       },
     ];
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         /* userLinks= */ [],
         /* adminLinks= */ [],
         topMenus,
@@ -398,7 +402,7 @@
       },
     ];
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         userLinks,
         /* adminLinks= */ [],
         topMenus,
@@ -434,7 +438,7 @@
         name: 'Repos',
         url: '/repos',
         noBaseUrl: true,
-        view: null,
+        view: undefined,
       },
     ];
     const topMenus = [
@@ -450,7 +454,7 @@
       },
     ];
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         /* userLinks= */ [],
         adminLinks,
         topMenus,
@@ -473,7 +477,7 @@
   });
 
   test('shows feedback icon when URL provided', async () => {
-    assert.isEmpty(element._feedbackURL);
+    assert.isEmpty(element.feedbackURL);
     assert.isNotOk(query(element, '.feedbackButton > a'));
 
     const url = 'report_bug_url';
@@ -484,14 +488,14 @@
         report_bug_url: url,
       },
     };
-    element._retrieveFeedbackURL(config);
-    await flush();
+    element.retrieveFeedbackURL(config);
+    await element.updateComplete;
 
-    assert.equal(element._feedbackURL, url);
+    assert.equal(element.feedbackURL, url);
     assert.ok(query(element, '.feedbackButton > a'));
   });
 
-  test('register URL', () => {
+  test('register URL', async () => {
     assert.isTrue(isHidden(query(element, '.registerDiv')));
     const config: ServerInfo = {
       ...createServerInfo(),
@@ -501,19 +505,21 @@
         editable_account_fields: [],
       },
     };
-    element._retrieveRegisterURL(config);
-    assert.equal(element._registerURL, config.auth.register_url);
-    assert.equal(element._registerText, 'Sign up');
+    element.retrieveRegisterURL(config);
+    await element.updateComplete;
+    assert.equal(element.registerURL, config.auth.register_url);
+    assert.equal(element.registerText, 'Sign up');
     assert.isFalse(isHidden(query(element, '.registerDiv')));
 
     config.auth.register_text = 'Create account';
-    element._retrieveRegisterURL(config);
-    assert.equal(element._registerURL, config.auth.register_url);
-    assert.equal(element._registerText, config.auth.register_text);
+    element.retrieveRegisterURL(config);
+    await element.updateComplete;
+    assert.equal(element.registerURL, config.auth.register_url);
+    assert.equal(element.registerText, config.auth.register_text);
     assert.isFalse(isHidden(query(element, '.registerDiv')));
   });
 
-  test('register URL ignored for wrong auth type', () => {
+  test('register URL ignored for wrong auth type', async () => {
     const config: ServerInfo = {
       ...createServerInfo(),
       auth: {
@@ -522,9 +528,10 @@
         editable_account_fields: [],
       },
     };
-    element._retrieveRegisterURL(config);
-    assert.equal(element._registerURL, '');
-    assert.equal(element._registerText, 'Sign up');
+    element.retrieveRegisterURL(config);
+    await element.updateComplete;
+    assert.equal(element.registerURL, '');
+    assert.equal(element.registerText, 'Sign up');
     assert.isTrue(isHidden(query(element, '.registerDiv')));
   });
 });
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 b8f2630..4453a7e 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -40,65 +40,7 @@
 //
 // Each object has a `view` property with a value from GerritNav.View. The
 // remaining properties depend on the value used for view.
-//
-//  - GerritNav.View.CHANGE:
-//    - `changeNum`, required, String: the numeric ID of the change.
-//    - `project`, optional, String: the project name.
-//    - `patchNum`, optional, Number: the patch for the right-hand-side of
-//        the diff.
-//    - `basePatchNum`, optional, Number: the patch for the left-hand-side
-//        of the diff. If `basePatchNum` is provided, then `patchNum` must
-//        also be provided.
-//    - `edit`, optional, Boolean: whether or not to load the file list with
-//        edit controls.
-//    - `messageHash`, optional, String: the hash of the change message to
-//        scroll to.
-//
-// - GerritNav.View.SEARCH:
-//    - `query`, optional, String: the literal search query. If provided,
-//        the string will be used as the query, and all other params will be
-//        ignored.
-//    - `owner`, optional, String: the owner name.
-//    - `project`, optional, String: the project name.
-//    - `branch`, optional, String: the branch name.
-//    - `topic`, optional, String: the topic name.
-//    - `hashtag`, optional, String: the hashtag name.
-//    - `statuses`, optional, Array<String>: the list of change statuses to
-//        search for. If more than one is provided, the search will OR them
-//        together.
-//    - `offset`, optional, Number: the offset for the query.
-//
-//  - GerritNav.View.DIFF:
-//    - `changeNum`, required, String: the numeric ID of the change.
-//    - `path`, required, String: the filepath of the diff.
-//    - `patchNum`, required, Number: the patch for the right-hand-side of
-//        the diff.
-//    - `basePatchNum`, optional, Number: the patch for the left-hand-side
-//        of the diff. If `basePatchNum` is provided, then `patchNum` must
-//        also be provided.
-//    - `lineNum`, optional, Number: the line number to be selected on load.
-//    - `leftSide`, optional, Boolean: if a `lineNum` is provided, a value
-//        of true selects the line from base of the patch range. False by
-//        default.
-//
-//  - GerritNav.View.GROUP:
-//    - `groupId`, required, String: the ID of the group.
-//    - `detail`, optional, String: the name of the group detail view.
-//      Takes any value from GerritNav.GroupDetailView.
-//
-//  - GerritNav.View.REPO:
-//    - `repoName`, required, String: the name of the repo
-//    - `detail`, optional, String: the name of the repo detail view.
-//      Takes any value from GerritNav.RepoDetailView.
-//
-//  - GerritNav.View.DASHBOARD
-//    - `repo`, optional, String.
-//    - `sections`, optional, Array of objects with `title` and `query`
-//      strings.
-//    - `user`, optional, String.
-//
-//  - GerritNav.View.ROOT:
-//    - no possible parameters.
+// GenerateUrlParameters lists all the possible view parameters.
 
 const uninitialized = () => {
   console.warn('Use of uninitialized routing');
@@ -132,8 +74,6 @@
   suffixForDashboard?: string;
   selfOnly?: boolean;
   hideIfEmpty?: boolean;
-  assigneeOnly?: boolean;
-  isOutgoing?: boolean;
   results?: ChangeInfo[];
 }
 
@@ -165,16 +105,6 @@
   hideIfEmpty: false,
   suffixForDashboard: 'limit:25',
 };
-const ASSIGNED: DashboardSection = {
-  // Changes that are assigned to the viewed user.
-  name: 'Assigned reviews',
-  query:
-    'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
-    'is:open -is:ignored',
-  hideIfEmpty: true,
-  suffixForDashboard: 'limit:25',
-  assigneeOnly: true,
-};
 const WIP: DashboardSection = {
   // WIP open changes owned by viewing user. This section is omitted when
   // viewing other users, so we don't need to filter anything out.
@@ -184,22 +114,19 @@
   hideIfEmpty: true,
   suffixForDashboard: 'limit:25',
 };
-const OUTGOING: DashboardSection = {
+export const OUTGOING: DashboardSection = {
   // Non-WIP open changes owned by viewed user. Filter out changes ignored
   // by the viewing user.
   name: 'Outgoing reviews',
   query: 'is:open owner:${user} -is:wip -is:ignored',
-  isOutgoing: true,
   suffixForDashboard: 'limit:25',
 };
 const INCOMING: DashboardSection = {
   // Non-WIP open changes not owned by the viewed user, that the viewed user
-  // is associated with (as either a reviewer or the assignee). Changes
-  // ignored by the viewing user are filtered out.
+  // is associated with as a reviewer. Changes ignored by the viewing user are
+  // filtered out.
   name: 'Incoming reviews',
-  query:
-    'is:open -owner:${user} -is:wip -is:ignored ' +
-    '(reviewer:${user} OR assignee:${user})',
+  query: 'is:open -owner:${user} -is:wip -is:ignored reviewer:${user}',
   suffixForDashboard: 'limit:25',
 };
 const CCED: DashboardSection = {
@@ -211,20 +138,18 @@
 };
 export const CLOSED: DashboardSection = {
   name: 'Recently closed',
-  // Closed changes where viewed user is owner, reviewer, or assignee.
+  // Closed changes where viewed user is owner or reviewer.
   // Changes ignored by the viewing user are filtered out, and so are WIP
   // changes not owned by the viewing user (the one instance of
   // 'owner:self' is intentional and implements this logic).
   query:
     'is:closed -is:ignored (-is:wip OR owner:self) ' +
-    '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
-    'OR cc:${user})',
+    '(owner:${user} OR reviewer:${user} OR cc:${user})',
   suffixForDashboard: '-age:4w limit:10',
 };
 const DEFAULT_SECTIONS: DashboardSection[] = [
   HAS_DRAFTS,
   YOUR_TURN,
-  ASSIGNED,
   WIP,
   OUTGOING,
   INCOMING,
@@ -256,11 +181,15 @@
   edit?: boolean;
   host?: string;
   messageHash?: string;
-  queryMap?: Map<string, string> | URLSearchParams;
   commentId?: UrlEncodedCommentId;
-
-  // TODO(TS): querystring isn't set anywhere, try to remove
-  querystring?: string;
+  forceReload?: boolean;
+  tab?: string;
+  /** regular expression for filtering check runs */
+  filter?: string;
+  /** regular expression for selecting check runs */
+  select?: string;
+  /** selected attempt for selected check runs */
+  attempt?: number;
 }
 
 export interface GenerateUrlRepoViewParameters {
@@ -435,6 +364,18 @@
   RESOLVE_CONFLICTS = 'resolve-conflicts',
 }
 
+interface NavigateToChangeParams {
+  patchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
+  isEdit?: boolean;
+  redirect?: boolean;
+  forceReload?: boolean;
+}
+
+interface ChangeUrlParams extends NavigateToChangeParams {
+  messageHash?: string;
+}
+
 // TODO(dmfilippov) Convert to class, extract consts, give better name and
 // expose as a service from appContext
 export const GerritNav = {
@@ -594,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'));
   },
 
   /**
@@ -609,11 +550,9 @@
    */
   getUrlForChange(
     change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
-    patchNum?: PatchSetNum,
-    basePatchNum?: BasePatchSetNum,
-    isEdit?: boolean,
-    messageHash?: string
+    options: ChangeUrlParams = {}
   ) {
+    let {patchNum, basePatchNum, isEdit, messageHash, forceReload} = options;
     if (basePatchNum === ParentPatchSetNum) {
       basePatchNum = undefined;
     }
@@ -628,6 +567,7 @@
       edit: isEdit,
       host: change.internalHost || undefined,
       messageHash,
+      forceReload,
     });
   },
 
@@ -649,17 +589,22 @@
    * @param redirect redirect to a change - if true, the current
    *     location (i.e. page which makes redirect) is not added to a history.
    *     I.e. back/forward buttons skip current location
-   *
+   * @param forceReload Some views are smart about how to handle the reload
+   *     of the view. In certain cases we want to force the view to reload
+   *     and re-render everything.
    */
   navigateToChange(
     change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
-    patchNum?: PatchSetNum,
-    basePatchNum?: BasePatchSetNum,
-    isEdit?: boolean,
-    redirect?: boolean
+    options: NavigateToChangeParams = {}
   ) {
+    const {patchNum, basePatchNum, isEdit, forceReload, redirect} = options;
     this._navigate(
-      this.getUrlForChange(change, patchNum, basePatchNum, isEdit),
+      this.getUrlForChange(change, {
+        patchNum,
+        basePatchNum,
+        isEdit,
+        forceReload,
+      }),
       redirect
     );
   },
@@ -1016,12 +961,9 @@
   getUserDashboard(
     user = 'self',
     sections = DEFAULT_SECTIONS,
-    title = '',
-    config: UserDashboardConfig = {}
+    title = ''
   ): UserDashboard {
-    const assigneeEnabled = config.change && !!config.change.enable_assignee;
     sections = sections
-      .filter(section => assigneeEnabled || !section.assigneeOnly)
       .filter(section => user === 'self' || !section.selfOnly)
       .map(section => {
         return {
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 93a1e9e..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', undefined, 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 6347847..8d137f0 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,
@@ -44,9 +42,8 @@
   RepoDetailView,
   WeblinkType,
 } from '../gr-navigation/gr-navigation';
-import {appContext} from '../../../services/app-context';
+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,
@@ -65,7 +62,7 @@
   AppElementParams,
 } from '../../gr-app-types';
 import {LocationChangeEventDetail} from '../../../types/events';
-import {GerritView, updateState} from '../../../services/router/router-model';
+import {GerritView} from '../../../services/router/router-model';
 import {firePageError} from '../../../utils/event-util';
 import {addQuotesWhen} from '../../../utils/string-util';
 import {windowLocationReload} from '../../../utils/dom-util';
@@ -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$/,
 
@@ -273,7 +270,7 @@
 // Setup listeners outside of the router component initialization.
 (function () {
   window.addEventListener('WebComponentsReady', () => {
-    appContext.reportingService.timeEnd(Timing.WEB_COMPONENTS_READY);
+    getAppContext().reportingService.timeEnd(Timing.WEB_COMPONENTS_READY);
   });
 })();
 
@@ -283,49 +280,50 @@
 
 type QueryStringItem = [string, string]; // [key, value]
 
-interface PatchRangeParams {
+export interface PatchRangeParams {
   patchNum?: PatchSetNum;
   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 = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly routerModel = getAppContext().routerModel;
+
+  private readonly restApiService = getAppContext().restApiService;
 
   start() {
     if (!this._app) {
       return;
     }
-    this._startRouter();
+    this.startRouter();
   }
 
-  _setParams(params: AppElementParams | GenerateUrlParameters) {
-    updateState(
-      params.view,
-      'changeNum' in params ? params.changeNum : undefined,
-      'patchNum' in params ? params.patchNum ?? undefined : undefined
-    );
-    this._appElement().params = params;
+  setParams(params: AppElementParams | GenerateUrlParameters) {
+    if (
+      'project' in params &&
+      params.project !== undefined &&
+      'changeNum' in params
+    )
+      this.restApiService.setInProjectLookup(params.changeNum, params.project);
+
+    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;
   }
 
-  _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
@@ -340,34 +338,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");
     }
@@ -375,33 +373,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 {
@@ -409,13 +407,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'];
@@ -430,7 +428,7 @@
     return null;
   }
 
-  _getBrowseCommitWeblink(weblinks?: GeneratedWebLink[], config?: ServerInfo) {
+  getBrowseCommitWeblink(weblinks?: GeneratedWebLink[], config?: ServerInfo) {
     if (!weblinks) {
       return null;
     }
@@ -441,7 +439,7 @@
       weblink = weblinks.find(weblink => weblink.name === primaryWeblinkName);
     }
     if (!weblink) {
-      weblink = this._firstCodeBrowserWeblink(weblinks);
+      weblink = this.firstCodeBrowserWeblink(weblinks);
     }
     if (!weblink) {
       return null;
@@ -449,13 +447,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 ||
@@ -464,15 +462,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}`;
@@ -495,7 +497,10 @@
     if (params.topic) {
       operators.push(
         'topic:' +
-          addQuotesWhen(encodeURL(params.topic, false), /\s/.test(params.topic))
+          addQuotesWhen(
+            encodeURL(params.topic, false),
+            /[\s:]/.test(params.topic)
+          )
       );
     }
     if (params.hashtag) {
@@ -503,7 +508,7 @@
         'hashtag:' +
           addQuotesWhen(
             encodeURL(params.hashtag.toLowerCase(), false),
-            /\s/.test(params.hashtag)
+            /[\s:]/.test(params.hashtag)
           )
       );
     }
@@ -524,23 +529,28 @@
     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;
     }
     let suffix = `${range}`;
-    if (params.querystring) {
-      suffix += '?' + params.querystring;
-    } else if (params.edit) {
-      suffix += ',edit';
+    let queryString = '';
+    if (params.forceReload) {
+      queryString = 'forceReload=true';
     }
-    if (params.messageHash) {
-      suffix += params.messageHash;
+    if (params.edit) {
+      suffix += ',edit';
     }
     if (params.commentId) {
       suffix = suffix + `/comments/${params.commentId}`;
     }
+    if (queryString) {
+      suffix += '?' + queryString;
+    }
+    if (params.messageHash) {
+      suffix += params.messageHash;
+    }
     if (params.project) {
       const encodedProject = encodeURL(params.project, true);
       return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
@@ -549,11 +559,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
       );
@@ -572,7 +582,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.
@@ -583,10 +596,10 @@
     });
   }
 
-  _generateDiffOrEditUrl(
+  private generateDiffOrEditUrl(
     params: GenerateUrlDiffViewParameters | GenerateUrlEditViewParameters
   ) {
-    let range = this._getPatchRangeExpression(params);
+    let range = this.getPatchRangeExpression(params);
     if (range.length) {
       range = '/' + range;
     }
@@ -617,7 +630,7 @@
     }
   }
 
-  _generateGroupUrl(params: GenerateUrlGroupViewParameters) {
+  private generateGroupUrl(params: GenerateUrlGroupViewParameters) {
     let url = `/admin/groups/${encodeURL(`${params.groupId}`, true)}`;
     if (params.detail === GroupDetailView.MEMBERS) {
       url += ',members';
@@ -627,7 +640,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';
@@ -645,7 +658,7 @@
     return url;
   }
 
-  _generateSettingsUrl() {
+  private generateSettingsUrl() {
     return '/settings';
   }
 
@@ -654,7 +667,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}`;
@@ -670,7 +683,7 @@
    * modified to fit the proper schema.
    *
    */
-  _normalizePatchRangeParams(params: PatchRangeParams) {
+  normalizePatchRangeParams(params: PatchRangeParams) {
     if (params.basePatchNum === undefined) {
       return false;
     }
@@ -695,7 +708,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)));
   }
@@ -707,11 +720,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;
@@ -730,26 +743,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();
   }
@@ -763,7 +776,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>();
@@ -780,36 +793,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);
@@ -823,8 +831,8 @@
           page.show(url);
         }
       },
-      params => this._generateUrl(params),
-      params => this._generateWeblinks(params),
+      params => this.generateUrl(params),
+      params => this.generateWeblinks(params),
       x => x
     );
 
@@ -847,7 +855,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;
         }
       }
@@ -862,7 +870,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;
       }
 
@@ -873,7 +881,7 @@
           hash: window.location.hash,
           pathname: window.location.pathname,
         };
-        this.dispatchEvent(
+        document.dispatchEvent(
           new CustomEvent('location-change', {
             detail,
             composed: true,
@@ -884,225 +892,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();
   }
@@ -1111,13 +1244,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
@@ -1135,14 +1268,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');
       }
     });
   }
@@ -1153,7 +1286,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, ' '));
   }
 
@@ -1165,7 +1298,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 [];
@@ -1176,11 +1309,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]);
@@ -1192,19 +1325,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],
         });
@@ -1218,11 +1351,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'
@@ -1256,7 +1389,7 @@
 
     if (sections.length > 0) {
       // Custom dashboard view.
-      this._setParams({
+      this.setParams({
         view: GerritView.DASHBOARD,
         user: 'self',
         sections,
@@ -1266,13 +1399,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,
@@ -1280,43 +1413,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,
@@ -1325,8 +1458,8 @@
     });
   }
 
-  _handleGroupListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleGroupListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-admin-group-list',
       offset: data.params['offset'],
@@ -1334,15 +1467,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]);
@@ -1351,12 +1484,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,
@@ -1364,9 +1497,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,
@@ -1374,9 +1507,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,
@@ -1384,9 +1517,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,
@@ -1394,8 +1527,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,
@@ -1404,8 +1537,8 @@
     });
   }
 
-  _handleBranchListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleBranchListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
       repo: data.params['repo'] as RepoName,
@@ -1414,8 +1547,8 @@
     });
   }
 
-  _handleBranchListFilterRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleBranchListFilterRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
       repo: data.params['repo'] as RepoName,
@@ -1423,8 +1556,8 @@
     });
   }
 
-  _handleTagListOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleTagListOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
       repo: data.params[0] as RepoName,
@@ -1433,8 +1566,8 @@
     });
   }
 
-  _handleTagListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleTagListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
       repo: data.params['repo'] as RepoName,
@@ -1443,8 +1576,8 @@
     });
   }
 
-  _handleTagListFilterRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleTagListFilterRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
       repo: data.params['repo'] as RepoName,
@@ -1452,8 +1585,8 @@
     });
   }
 
-  _handleRepoListOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleRepoListOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-repo-list',
       offset: data.params[1] || 0,
@@ -1462,8 +1595,8 @@
     });
   }
 
-  _handleRepoListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleRepoListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-repo-list',
       offset: data.params['offset'],
@@ -1471,32 +1604,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,
@@ -1504,8 +1637,8 @@
     });
   }
 
-  _handlePluginListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handlePluginListFilterOffsetRoute(data: PageContextWithQueryMap) {
+    this.setParams({
       view: GerritView.ADMIN,
       adminView: 'gr-plugin-list',
       offset: data.params['offset'],
@@ -1513,48 +1646,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 = {
@@ -1563,15 +1696,37 @@
       basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
       patchNum: convertToPatchSetNum(ctx.params[6]),
       view: GerritView.CHANGE,
-      queryMap: ctx.queryMap,
     };
 
+    if (ctx.queryMap.has('forceReload')) {
+      params.forceReload = true;
+      history.replaceState(
+        null,
+        '',
+        location.href.replace(/[?&]forceReload=true/, '')
+      );
+    }
+
+    const tab = ctx.queryMap.get('tab');
+    if (tab) params.tab = tab;
+    const filter = ctx.queryMap.get('filter');
+    if (filter) params.filter = filter;
+    const select = ctx.queryMap.get('select');
+    if (select) params.select = select;
+    const attempt = ctx.queryMap.get('attempt');
+    if (attempt) {
+      const attemptInt = parseInt(attempt);
+      if (!isNaN(attemptInt) && attemptInt > 0) {
+        params.attempt = attemptInt;
+      }
+    }
+
     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,
@@ -1582,10 +1737,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,
@@ -1595,10 +1750,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 = {
@@ -1609,42 +1764,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
@@ -1657,17 +1812,28 @@
     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;
-    this._redirectOrNavigate({
+    const params: GenerateUrlChangeViewParameters = {
       project,
       changeNum,
       patchNum: convertToPatchSetNum(ctx.params[3]),
       view: GerritView.CHANGE,
       edit: true,
-    });
+      tab: ctx.queryMap.get('tab') ?? '',
+    };
+    if (ctx.queryMap.has('forceReload')) {
+      params.forceReload = true;
+      history.replaceState(
+        null,
+        '',
+        location.href.replace(/[?&]forceReload=true/, '')
+      );
+    }
+    this.redirectOrNavigate(params);
+
     this.reporting.setRepoName(project);
     this.reporting.setChangeId(changeNum);
   }
@@ -1676,42 +1842,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.
@@ -1722,14 +1888,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();
   }
 
@@ -1737,66 +1903,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.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
deleted file mode 100644
index b91bf0c..0000000
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
+++ /dev/null
@@ -1,1607 +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-router.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import {GerritNav} from '../gr-navigation/gr-navigation.js';
-import {stubBaseUrl, stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
-import {_testOnly_RoutePattern} from './gr-router.js';
-import {GerritView} from '../../../services/router/router-model.js';
-import {ParentPatchSetNum} from '../../../types/common.js';
-
-const basicFixture = fixtureFromElement('gr-router');
-
-suite('gr-router tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_firstCodeBrowserWeblink', () => {
-    assert.deepEqual(element._firstCodeBrowserWeblink([
-      {name: 'gitweb'},
-      {name: 'gitiles'},
-      {name: 'browse'},
-      {name: 'test'}]), {name: 'gitiles'});
-
-    assert.deepEqual(element._firstCodeBrowserWeblink([
-      {name: 'gitweb'},
-      {name: 'test'}]), {name: 'gitweb'});
-  });
-
-  test('_getBrowseCommitWeblink', () => {
-    const browserLink = {name: 'browser', url: 'browser/url'};
-    const link = {name: 'test', url: 'test/url'};
-    const weblinks = [browserLink, link];
-    const config = {gerrit: {primary_weblink_name: browserLink.name}};
-    sinon.stub(element, '_firstCodeBrowserWeblink').returns(link);
-
-    assert.deepEqual(element._getBrowseCommitWeblink(weblinks, config),
-        browserLink);
-
-    assert.deepEqual(element._getBrowseCommitWeblink(weblinks, {}), link);
-  });
-
-  test('_getChangeWeblinks', () => {
-    const link = {name: 'test', url: 'test/url'};
-    const browserLink = {name: 'browser', url: 'browser/url'};
-    const mapLinksToConfig = weblinks => { return {options: {weblinks}}; };
-    sinon.stub(element, '_getBrowseCommitWeblink').returns(browserLink);
-
-    assert.deepEqual(
-        element._getChangeWeblinks(mapLinksToConfig([link, browserLink]))[0],
-        {name: 'test', url: 'test/url'});
-
-    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
-        {name: 'test', url: 'test/url'});
-
-    link.url = 'https://' + link.url;
-    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
-        {name: 'test', url: 'https://test/url'});
-  });
-
-  test('_getHashFromCanonicalPath', () => {
-    let url = '/foo/bar';
-    let hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, '');
-
-    url = '';
-    hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, '');
-
-    url = '/foo#bar';
-    hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, 'bar');
-
-    url = '/foo#bar#baz';
-    hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, 'bar#baz');
-
-    url = '#foo#bar#baz';
-    hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, 'foo#bar#baz');
-  });
-
-  suite('_parseLineAddress', () => {
-    test('returns null for empty and invalid hashes', () => {
-      let actual = element._parseLineAddress('');
-      assert.isNull(actual);
-
-      actual = element._parseLineAddress('foobar');
-      assert.isNull(actual);
-
-      actual = element._parseLineAddress('foo123');
-      assert.isNull(actual);
-
-      actual = element._parseLineAddress('123bar');
-      assert.isNull(actual);
-    });
-
-    test('parses correctly', () => {
-      let actual = element._parseLineAddress('1234');
-      assert.isOk(actual);
-      assert.equal(actual.lineNum, 1234);
-      assert.isFalse(actual.leftSide);
-
-      actual = element._parseLineAddress('a4');
-      assert.isOk(actual);
-      assert.equal(actual.lineNum, 4);
-      assert.isTrue(actual.leftSide);
-
-      actual = element._parseLineAddress('b77');
-      assert.isOk(actual);
-      assert.equal(actual.lineNum, 77);
-      assert.isTrue(actual.leftSide);
-    });
-  });
-
-  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.
-
-    const requiresAuth = {};
-    const doesNotRequireAuth = {};
-    sinon.stub(GerritNav, 'setup');
-    sinon.stub(page, 'start');
-    sinon.stub(page, 'base');
-    sinon.stub(element, '_mapRoute').callsFake(
-        (pattern, methodName, usesAuth) => {
-          if (usesAuth) {
-            requiresAuth[methodName] = true;
-          } else {
-            doesNotRequireAuth[methodName] = true;
-          }
-        });
-    element._startRouter();
-
-    const actualRequiresAuth = Object.keys(requiresAuth);
-    actualRequiresAuth.sort();
-    const actualDoesNotRequireAuth = Object.keys(doesNotRequireAuth);
-    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',
-    ];
-    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',
-    ];
-
-    // Handler names that check authentication themselves, and thus don't need
-    // it performed for them.
-    const selfAuthenticatingHandlers = [
-      '_handleDashboardRoute',
-      '_handleCustomDashboardRoute',
-      '_handleRootRoute',
-    ];
-
-    const shouldNotRequireAuth = unauthenticatedHandlers
-        .concat(selfAuthenticatingHandlers);
-    shouldNotRequireAuth.sort();
-    assert.deepEqual(actualDoesNotRequireAuth, shouldNotRequireAuth);
-  });
-
-  test('_redirectIfNotLoggedIn while logged in', () => {
-    stubRestApi('getLoggedIn')
-        .returns(Promise.resolve(true));
-    const data = {canonicalPath: ''};
-    const redirectStub = sinon.stub(element, '_redirectToLogin');
-    return element._redirectIfNotLoggedIn(data).then(() => {
-      assert.isFalse(redirectStub.called);
-    });
-  });
-
-  test('_redirectIfNotLoggedIn while logged out', () => {
-    stubRestApi('getLoggedIn')
-        .returns(Promise.resolve(false));
-    const redirectStub = sinon.stub(element, '_redirectToLogin');
-    const data = {canonicalPath: ''};
-    return new Promise(resolve => {
-      element._redirectIfNotLoggedIn(data)
-          .then(() => {
-            assert.isTrue(false, 'Should never execute');
-          })
-          .catch(() => {
-            assert.isTrue(redirectStub.calledOnce);
-            resolve();
-          });
-    });
-  });
-
-  suite('generateUrl', () => {
-    test('search', () => {
-      let params = {
-        view: GerritNav.View.SEARCH,
-        owner: 'a%b',
-        project: 'c%d',
-        branch: 'e%f',
-        topic: 'g%h',
-        statuses: ['op%en'],
-      };
-      assert.equal(element._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),
-          '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-          'topic:g%2525h+status:op%2525en,100');
-      delete params.offset;
-
-      // The presence of the query param overrides other params.
-      params.query = 'foo$bar';
-      assert.equal(element._generateUrl(params), '/q/foo%2524bar');
-
-      params.offset = 100;
-      assert.equal(element._generateUrl(params), '/q/foo%2524bar,100');
-
-      params = {
-        view: GerritNav.View.SEARCH,
-        statuses: ['a', 'b', 'c'],
-      };
-      assert.equal(element._generateUrl(params),
-          '/q/(status:a OR status:b OR status:c)');
-
-      params = {
-        view: GerritNav.View.SEARCH,
-        topic: 'test',
-      };
-      assert.equal(element._generateUrl(params),
-          '/q/topic:test');
-      params = {
-        view: GerritNav.View.SEARCH,
-        topic: 'test test',
-      };
-      assert.equal(element._generateUrl(params),
-          '/q/topic:"test+test"');
-    });
-
-    test('change', () => {
-      const params = {
-        view: GerritView.CHANGE,
-        changeNum: '1234',
-        project: 'test',
-      };
-      const paramsWithQuery = {
-        view: GerritView.CHANGE,
-        changeNum: '1234',
-        project: 'test',
-        querystring: 'revert&foo=bar',
-      };
-
-      assert.equal(element._generateUrl(params), '/c/test/+/1234');
-      assert.equal(element._generateUrl(paramsWithQuery),
-          '/c/test/+/1234?revert&foo=bar');
-
-      params.patchNum = 10;
-      assert.equal(element._generateUrl(params), '/c/test/+/1234/10');
-      paramsWithQuery.patchNum = 10;
-      assert.equal(element._generateUrl(paramsWithQuery),
-          '/c/test/+/1234/10?revert&foo=bar');
-
-      params.basePatchNum = 5;
-      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10');
-      paramsWithQuery.basePatchNum = 5;
-      assert.equal(element._generateUrl(paramsWithQuery),
-          '/c/test/+/1234/5..10?revert&foo=bar');
-
-      params.messageHash = '#123';
-      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10#123');
-    });
-
-    test('change with repo name encoding', () => {
-      const params = {
-        view: GerritView.CHANGE,
-        changeNum: '1234',
-        project: 'x+/y+/z+/w',
-      };
-      assert.equal(element._generateUrl(params),
-          '/c/x%252B/y%252B/z%252B/w/+/1234');
-    });
-
-    test('diff', () => {
-      const params = {
-        view: GerritView.DIFF,
-        changeNum: '42',
-        path: 'x+y/path.cpp',
-        patchNum: 12,
-      };
-      assert.equal(element._generateUrl(params),
-          '/c/42/12/x%252By/path.cpp');
-
-      params.project = 'test';
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/12/x%252By/path.cpp');
-
-      params.basePatchNum = 6;
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/6..12/x%252By/path.cpp');
-
-      params.path = 'foo bar/my+file.txt%';
-      params.patchNum = 2;
-      delete params.basePatchNum;
-      assert.equal(element._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');
-
-      params.leftSide = true;
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/2/file.cpp#b123');
-    });
-
-    test('diff with repo name encoding', () => {
-      const params = {
-        view: GerritView.DIFF,
-        changeNum: '42',
-        path: 'x+y/path.cpp',
-        patchNum: 12,
-        project: 'x+/y',
-      };
-      assert.equal(element._generateUrl(params),
-          '/c/x%252B/y/+/42/12/x%252By/path.cpp');
-    });
-
-    test('edit', () => {
-      const params = {
-        view: GerritNav.View.EDIT,
-        changeNum: '42',
-        project: 'test',
-        path: 'x+y/path.cpp',
-      };
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/x%252By/path.cpp,edit');
-    });
-
-    test('_getPatchRangeExpression', () => {
-      const params = {};
-      let actual = element._getPatchRangeExpression(params);
-      assert.equal(actual, '');
-
-      params.patchNum = 4;
-      actual = element._getPatchRangeExpression(params);
-      assert.equal(actual, '4');
-
-      params.basePatchNum = 2;
-      actual = element._getPatchRangeExpression(params);
-      assert.equal(actual, '2..4');
-
-      delete params.patchNum;
-      actual = element._getPatchRangeExpression(params);
-      assert.equal(actual, '2..');
-    });
-
-    suite('dashboard', () => {
-      test('self dashboard', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-        };
-        assert.equal(element._generateUrl(params), '/dashboard/self');
-      });
-
-      test('user dashboard', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          user: 'user',
-        };
-        assert.equal(element._generateUrl(params), '/dashboard/user');
-      });
-
-      test('custom self dashboard, no title', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          sections: [
-            {name: 'section 1', query: 'query 1'},
-            {name: 'section 2', query: 'query 2'},
-          ],
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/dashboard/?section%201=query%201&section%202=query%202');
-      });
-
-      test('custom repo dashboard', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          sections: [
-            {name: 'section 1', query: 'query 1 ${project}'},
-            {name: 'section 2', query: 'query 2 ${repo}'},
-          ],
-          repo: 'repo-name',
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/dashboard/?section%201=query%201%20repo-name&' +
-            'section%202=query%202%20repo-name');
-      });
-
-      test('custom user dashboard, with title', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          user: 'user',
-          sections: [{name: 'name', query: 'query'}],
-          title: 'custom dashboard',
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/dashboard/user?name=query&title=custom%20dashboard');
-      });
-
-      test('repo dashboard', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          repo: 'gerrit/repo',
-          dashboard: 'default:main',
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/p/gerrit/repo/+/dashboard/default:main');
-      });
-
-      test('project dashboard (legacy)', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          project: 'gerrit/project',
-          dashboard: 'default:main',
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/p/gerrit/project/+/dashboard/default:main');
-      });
-    });
-
-    suite('groups', () => {
-      test('group info', () => {
-        const params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-        };
-        assert.equal(element._generateUrl(params), '/admin/groups/1234');
-      });
-
-      test('group members', () => {
-        const params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-          detail: 'members',
-        };
-        assert.equal(element._generateUrl(params),
-            '/admin/groups/1234,members');
-      });
-
-      test('group audit log', () => {
-        const params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-          detail: 'log',
-        };
-        assert.equal(element._generateUrl(params),
-            '/admin/groups/1234,audit-log');
-      });
-    });
-  });
-
-  suite('param normalization', () => {
-    suite('_normalizePatchRangeParams', () => {
-      test('range n..n normalizes to n', () => {
-        const params = {basePatchNum: 4, patchNum: 4};
-        const needsRedirect = element._normalizePatchRangeParams(params);
-        assert.isTrue(needsRedirect);
-        assert.equal(params.basePatchNum, ParentPatchSetNum);
-        assert.equal(params.patchNum, 4);
-      });
-
-      test('range n.. normalizes to n', () => {
-        const params = {basePatchNum: 4};
-        const needsRedirect = element._normalizePatchRangeParams(params);
-        assert.isFalse(needsRedirect);
-        assert.equal(params.basePatchNum, ParentPatchSetNum);
-        assert.equal(params.patchNum, 4);
-      });
-    });
-  });
-
-  suite('route handlers', () => {
-    let redirectStub;
-    let setParamsStub;
-    let handlePassThroughRoute;
-
-    // Simple route handlers are direct mappings from parsed route data to a
-    // new set of app.params. This test helper asserts that passing `data`
-    // into `methodName` results in setting the params specified in `params`.
-    function assertDataToParams(data, methodName, params) {
-      element[methodName](data);
-      assert.deepEqual(setParamsStub.lastCall.args[0], params);
-    }
-
-    setup(() => {
-      redirectStub = sinon.stub(element, '_redirect');
-      setParamsStub = sinon.stub(element, '_setParams');
-      handlePassThroughRoute = sinon.stub(element, '_handlePassThroughRoute');
-    });
-
-    test('_handleLegacyProjectDashboardRoute', () => {
-      const params = {0: 'gerrit/project', 1: 'dashboard:main'};
-      element._handleLegacyProjectDashboardRoute({params});
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0],
-          '/p/gerrit/project/+/dashboard/dashboard:main');
-    });
-
-    test('_handleAgreementsRoute', () => {
-      const data = {params: {}};
-      element._handleAgreementsRoute(data);
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
-    });
-
-    test('_handleNewAgreementsRoute', () => {
-      element._handleNewAgreementsRoute({params: {}});
-      assert.isTrue(setParamsStub.calledOnce);
-      assert.equal(setParamsStub.lastCall.args[0].view,
-          GerritNav.View.AGREEMENTS);
-    });
-
-    test('_handleSettingsLegacyRoute', () => {
-      const data = {params: {0: 'my-token'}};
-      assertDataToParams(data, '_handleSettingsLegacyRoute', {
-        view: GerritNav.View.SETTINGS,
-        emailToken: 'my-token',
-      });
-    });
-
-    test('_handleSettingsLegacyRoute with +', () => {
-      const data = {params: {0: 'my-token test'}};
-      assertDataToParams(data, '_handleSettingsLegacyRoute', {
-        view: GerritNav.View.SETTINGS,
-        emailToken: 'my-token+test',
-      });
-    });
-
-    test('_handleSettingsRoute', () => {
-      const data = {};
-      assertDataToParams(data, '_handleSettingsRoute', {
-        view: GerritNav.View.SETTINGS,
-      });
-    });
-
-    test('_handleDefaultRoute on first load', () => {
-      const spy = sinon.spy();
-      addListenerForTest(document, 'page-error', spy);
-      element._handleDefaultRoute();
-      assert.isTrue(spy.calledOnce);
-      assert.equal(spy.lastCall.args[0].detail.response.status, 404);
-    });
-
-    test('_handleDefaultRoute after internal navigation', () => {
-      let onExit = null;
-      const onRegisteringExit = (match, _onExit) => {
-        onExit = _onExit;
-      };
-      sinon.stub(page, 'exit').callsFake( onRegisteringExit);
-      sinon.stub(GerritNav, 'setup');
-      sinon.stub(page, 'start');
-      sinon.stub(page, 'base');
-      element._startRouter();
-
-      element._handleDefaultRoute();
-
-      onExit('', () => {}); // we left page;
-
-      element._handleDefaultRoute();
-      assert.isTrue(handlePassThroughRoute.calledOnce);
-    });
-
-    test('_handleImproperlyEncodedPlusRoute', () => {
-      // Regression test for Issue 7100.
-      element._handleImproperlyEncodedPlusRoute(
-          {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(
-          redirectStub.lastCall.args[0],
-          '/c/test/+/42');
-
-      sinon.stub(element, '_getHashFromCanonicalPath').returns('foo');
-      element._handleImproperlyEncodedPlusRoute(
-          {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
-      assert.equal(
-          redirectStub.lastCall.args[0],
-          '/c/test/+/42#foo');
-    });
-
-    test('_handleQueryRoute', () => {
-      const data = {params: ['project:foo/bar/baz']};
-      assertDataToParams(data, '_handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'project:foo/bar/baz',
-        offset: undefined,
-      });
-
-      data.params.push(',123', '123');
-      assertDataToParams(data, '_handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'project:foo/bar/baz',
-        offset: '123',
-      });
-    });
-
-    test('_handleQueryLegacySuffixRoute', () => {
-      element._handleQueryLegacySuffixRoute({path: '/q/foo+bar,n,z'});
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
-    });
-
-    test('_handleQueryRoute', () => {
-      const data = {params: ['project:foo/bar/baz']};
-      assertDataToParams(data, '_handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'project:foo/bar/baz',
-        offset: undefined,
-      });
-
-      data.params.push(',123', '123');
-      assertDataToParams(data, '_handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'project:foo/bar/baz',
-        offset: '123',
-      });
-    });
-
-    test('_handleChangeIdQueryRoute', () => {
-      const data = {params: ['I0123456789abcdef0123456789abcdef01234567']};
-      assertDataToParams(data, '_handleChangeIdQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'I0123456789abcdef0123456789abcdef01234567',
-      });
-    });
-
-    suite('_handleRegisterRoute', () => {
-      test('happy path', () => {
-        const ctx = {params: ['/foo/bar']};
-        element._handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-      });
-
-      test('no param', () => {
-        const ctx = {params: ['']};
-        element._handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-      });
-
-      test('prevent redirect', () => {
-        const ctx = {params: ['/register']};
-        element._handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-      });
-    });
-
-    suite('_handleRootRoute', () => {
-      test('closes for closeAfterLogin', () => {
-        const data = {querystring: 'closeAfterLogin', canonicalPath: ''};
-        const closeStub = sinon.stub(window, 'close');
-        const result = element._handleRootRoute(data);
-        assert.isNotOk(result);
-        assert.isTrue(closeStub.called);
-        assert.isFalse(redirectStub.called);
-      });
-
-      test('redirects to dashboard if logged in', () => {
-        const data = {
-          canonicalPath: '/', path: '/', querystring: '', hash: '',
-        };
-        const result = element._handleRootRoute(data);
-        assert.isOk(result);
-        return result.then(() => {
-          assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
-        });
-      });
-
-      test('redirects to open changes if not logged in', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const data = {
-          canonicalPath: '/', path: '/', querystring: '', hash: '',
-        };
-        const result = element._handleRootRoute(data);
-        assert.isOk(result);
-        return result.then(() => {
-          assert.isTrue(
-              redirectStub.calledWithExactly('/q/status:open+-is:wip'));
-        });
-      });
-
-      suite('GWT hash-path URLs', () => {
-        test('redirects hash-path URLs', () => {
-          const data = {
-            canonicalPath: '/#/foo/bar/baz',
-            hash: '/foo/bar/baz',
-            querystring: '',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
-        });
-
-        test('redirects hash-path URLs w/o leading slash', () => {
-          const data = {
-            canonicalPath: '/#foo/bar/baz',
-            querystring: '',
-            hash: 'foo/bar/baz',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
-        });
-
-        test('normalizes "/ /" in hash to "/+/"', () => {
-          const data = {
-            canonicalPath: '/#/foo/bar/+/123/4',
-            querystring: '',
-            hash: '/foo/bar/ /123/4',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
-        });
-
-        test('prepends baseurl to hash-path', () => {
-          const data = {
-            canonicalPath: '/#/foo/bar',
-            querystring: '',
-            hash: '/foo/bar',
-          };
-          stubBaseUrl('/baz');
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
-        });
-
-        test('normalizes /VE/ settings hash-paths', () => {
-          const data = {
-            canonicalPath: '/#/VE/foo/bar',
-            querystring: '',
-            hash: '/VE/foo/bar',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly(
-              '/settings/VE/foo/bar'));
-        });
-
-        test('does not drop "inner hashes"', () => {
-          const data = {
-            canonicalPath: '/#/foo/bar#baz',
-            querystring: '',
-            hash: '/foo/bar',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
-        });
-      });
-    });
-
-    suite('_handleDashboardRoute', () => {
-      let redirectToLoginStub;
-
-      setup(() => {
-        redirectToLoginStub = sinon.stub(element, '_redirectToLogin');
-      });
-
-      test('own dashboard but signed out redirects to login', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const data = {canonicalPath: '/dashboard/', params: {0: 'seLF'}};
-        return element._handleDashboardRoute(data, '').then(() => {
-          assert.isTrue(redirectToLoginStub.calledOnce);
-          assert.isFalse(redirectStub.called);
-          assert.isFalse(setParamsStub.called);
-        });
-      });
-
-      test('non-self dashboard but signed out does not redirect', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
-        return element._handleDashboardRoute(data, '').then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(setParamsStub.called);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
-        });
-      });
-
-      test('dashboard while signed in sets params', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
-        return element._handleDashboardRoute(data, '').then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setParamsStub.calledOnce);
-          assert.deepEqual(setParamsStub.lastCall.args[0], {
-            view: GerritNav.View.DASHBOARD,
-            user: 'foo',
-          });
-        });
-      });
-    });
-
-    suite('_handleCustomDashboardRoute', () => {
-      let redirectToLoginStub;
-
-      setup(() => {
-        redirectToLoginStub = sinon.stub(element, '_redirectToLogin');
-      });
-
-      test('no user specified', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-        return element._handleCustomDashboardRoute(data, '').then(() => {
-          assert.isFalse(setParamsStub.called);
-          assert.isTrue(redirectStub.called);
-          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
-        });
-      });
-
-      test('custom dashboard without title', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-        return element._handleCustomDashboardRoute(data, '?a=b&c&d=e')
-            .then(() => {
-              assert.isFalse(redirectStub.called);
-              assert.isTrue(setParamsStub.calledOnce);
-              assert.deepEqual(setParamsStub.lastCall.args[0], {
-                view: GerritNav.View.DASHBOARD,
-                user: 'self',
-                sections: [
-                  {name: 'a', query: 'b'},
-                  {name: 'd', query: 'e'},
-                ],
-                title: 'Custom Dashboard',
-              });
-            });
-      });
-
-      test('custom dashboard with title', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-        return element._handleCustomDashboardRoute(data,
-            '?a=b&c&d=&=e&title=t')
-            .then(() => {
-              assert.isFalse(redirectToLoginStub.called);
-              assert.isFalse(redirectStub.called);
-              assert.isTrue(setParamsStub.calledOnce);
-              assert.deepEqual(setParamsStub.lastCall.args[0], {
-                view: GerritNav.View.DASHBOARD,
-                user: 'self',
-                sections: [
-                  {name: 'a', query: 'b'},
-                ],
-                title: 't',
-              });
-            });
-      });
-
-      test('custom dashboard with foreach', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-        return element._handleCustomDashboardRoute(data,
-            '?a=b&c&d=&=e&foreach=is:open')
-            .then(() => {
-              assert.isFalse(redirectToLoginStub.called);
-              assert.isFalse(redirectStub.called);
-              assert.isTrue(setParamsStub.calledOnce);
-              assert.deepEqual(setParamsStub.lastCall.args[0], {
-                view: GerritNav.View.DASHBOARD,
-                user: 'self',
-                sections: [
-                  {name: 'a', query: 'is:open b'},
-                ],
-                title: 'Custom Dashboard',
-              });
-            });
-      });
-    });
-
-    suite('group routes', () => {
-      test('_handleGroupInfoRoute', () => {
-        const data = {params: {0: 1234}};
-        element._handleGroupInfoRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
-      });
-
-      test('_handleGroupAuditLogRoute', () => {
-        const data = {params: {0: 1234}};
-        assertDataToParams(data, '_handleGroupAuditLogRoute', {
-          view: GerritNav.View.GROUP,
-          detail: 'log',
-          groupId: 1234,
-        });
-      });
-
-      test('_handleGroupMembersRoute', () => {
-        const data = {params: {0: 1234}};
-        assertDataToParams(data, '_handleGroupMembersRoute', {
-          view: GerritNav.View.GROUP,
-          detail: 'members',
-          groupId: 1234,
-        });
-      });
-
-      test('_handleGroupListOffsetRoute', () => {
-        const data = {params: {}};
-        assertDataToParams(data, '_handleGroupListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          offset: 0,
-          filter: null,
-          openCreateModal: false,
-        });
-
-        data.params[1] = 42;
-        assertDataToParams(data, '_handleGroupListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          offset: 42,
-          filter: null,
-          openCreateModal: false,
-        });
-
-        data.hash = 'create';
-        assertDataToParams(data, '_handleGroupListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          offset: 42,
-          filter: null,
-          openCreateModal: true,
-        });
-      });
-
-      test('_handleGroupListFilterOffsetRoute', () => {
-        const data = {params: {filter: 'foo', offset: 42}};
-        assertDataToParams(data, '_handleGroupListFilterOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          offset: 42,
-          filter: 'foo',
-        });
-      });
-
-      test('_handleGroupListFilterRoute', () => {
-        const data = {params: {filter: 'foo'}};
-        assertDataToParams(data, '_handleGroupListFilterRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          filter: 'foo',
-        });
-      });
-
-      test('_handleGroupRoute', () => {
-        const data = {params: {0: 4321}};
-        assertDataToParams(data, '_handleGroupRoute', {
-          view: GerritNav.View.GROUP,
-          groupId: 4321,
-        });
-      });
-    });
-
-    suite('repo routes', () => {
-      test('_handleProjectsOldRoute', () => {
-        const data = {params: {}};
-        element._handleProjectsOldRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
-      });
-
-      test('_handleProjectsOldRoute test', () => {
-        const data = {params: {1: 'test'}};
-        element._handleProjectsOldRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
-      });
-
-      test('_handleProjectsOldRoute test,branches', () => {
-        const data = {params: {1: 'test,branches'}};
-        element._handleProjectsOldRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(
-            redirectStub.lastCall.args[0], '/admin/repos/test,branches');
-      });
-
-      test('_handleRepoRoute', () => {
-        const data = {path: '/admin/repos/test'};
-        element._handleRepoRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(
-            redirectStub.lastCall.args[0], '/admin/repos/test,general');
-      });
-
-      test('_handleRepoGeneralRoute', () => {
-        const data = {params: {0: 4321}};
-        assertDataToParams(data, '_handleRepoGeneralRoute', {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.GENERAL,
-          repo: 4321,
-        });
-      });
-
-      test('_handleRepoCommandsRoute', () => {
-        const data = {params: {0: 4321}};
-        assertDataToParams(data, '_handleRepoCommandsRoute', {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.COMMANDS,
-          repo: 4321,
-        });
-      });
-
-      test('_handleRepoAccessRoute', () => {
-        const data = {params: {0: 4321}};
-        assertDataToParams(data, '_handleRepoAccessRoute', {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.ACCESS,
-          repo: 4321,
-        });
-      });
-
-      suite('branch list routes', () => {
-        test('_handleBranchListOffsetRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleBranchListOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
-            repo: 4321,
-            offset: 0,
-            filter: null,
-          });
-
-          data.params[2] = 42;
-          assertDataToParams(data, '_handleBranchListOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
-            repo: 4321,
-            offset: 42,
-            filter: null,
-          });
-        });
-
-        test('_handleBranchListFilterOffsetRoute', () => {
-          const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
-          assertDataToParams(data, '_handleBranchListFilterOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
-            repo: 4321,
-            offset: 42,
-            filter: 'foo',
-          });
-        });
-
-        test('_handleBranchListFilterRoute', () => {
-          const data = {params: {repo: 4321, filter: 'foo'}};
-          assertDataToParams(data, '_handleBranchListFilterRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
-            repo: 4321,
-            filter: 'foo',
-          });
-        });
-      });
-
-      suite('tag list routes', () => {
-        test('_handleTagListOffsetRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleTagListOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
-            repo: 4321,
-            offset: 0,
-            filter: null,
-          });
-        });
-
-        test('_handleTagListFilterOffsetRoute', () => {
-          const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
-          assertDataToParams(data, '_handleTagListFilterOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
-            repo: 4321,
-            offset: 42,
-            filter: 'foo',
-          });
-        });
-
-        test('_handleTagListFilterRoute', () => {
-          const data = {params: {repo: 4321}};
-          assertDataToParams(data, '_handleTagListFilterRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
-            repo: 4321,
-            filter: null,
-          });
-
-          data.params.filter = 'foo';
-          assertDataToParams(data, '_handleTagListFilterRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
-            repo: 4321,
-            filter: 'foo',
-          });
-        });
-      });
-
-      suite('repo list routes', () => {
-        test('_handleRepoListOffsetRoute', () => {
-          const data = {params: {}};
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 0,
-            filter: null,
-            openCreateModal: false,
-          });
-
-          data.params[1] = 42;
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 42,
-            filter: null,
-            openCreateModal: false,
-          });
-
-          data.hash = 'create';
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 42,
-            filter: null,
-            openCreateModal: true,
-          });
-        });
-
-        test('_handleRepoListFilterOffsetRoute', () => {
-          const data = {params: {filter: 'foo', offset: 42}};
-          assertDataToParams(data, '_handleRepoListFilterOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 42,
-            filter: 'foo',
-          });
-        });
-
-        test('_handleRepoListFilterRoute', () => {
-          const data = {params: {}};
-          assertDataToParams(data, '_handleRepoListFilterRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            filter: null,
-          });
-
-          data.params.filter = 'foo';
-          assertDataToParams(data, '_handleRepoListFilterRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            filter: 'foo',
-          });
-        });
-      });
-    });
-
-    suite('plugin routes', () => {
-      test('_handlePluginListOffsetRoute', () => {
-        const data = {params: {}};
-        assertDataToParams(data, '_handlePluginListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          offset: 0,
-          filter: null,
-        });
-
-        data.params[1] = 42;
-        assertDataToParams(data, '_handlePluginListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          offset: 42,
-          filter: null,
-        });
-      });
-
-      test('_handlePluginListFilterOffsetRoute', () => {
-        const data = {params: {filter: 'foo', offset: 42}};
-        assertDataToParams(data, '_handlePluginListFilterOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          offset: 42,
-          filter: 'foo',
-        });
-      });
-
-      test('_handlePluginListFilterRoute', () => {
-        const data = {params: {}};
-        assertDataToParams(data, '_handlePluginListFilterRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          filter: null,
-        });
-
-        data.params.filter = 'foo';
-        assertDataToParams(data, '_handlePluginListFilterRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          filter: 'foo',
-        });
-      });
-
-      test('_handlePluginListRoute', () => {
-        const data = {params: {}};
-        assertDataToParams(data, '_handlePluginListRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-        });
-      });
-    });
-
-    suite('change/diff routes', () => {
-      test('_handleChangeNumberLegacyRoute', () => {
-        const data = {params: {0: 12345}};
-        element._handleChangeNumberLegacyRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
-      });
-
-      test('_handleChangeLegacyRoute', async () => {
-        stubRestApi('getFromProjectLookup').returns(Promise.resolve('project'));
-        const ctx = {
-          params: [
-            1234, // 0 Change number
-            'comment/6789',
-          ],
-          querystring: '',
-        };
-        element._handleChangeLegacyRoute(ctx);
-        await flush();
-        assert.isTrue(redirectStub.calledWithExactly('/c/project/+/1234' +
-            '/comment/6789'));
-      });
-
-      test('_handleLegacyLinenum w/ @321', () => {
-        const ctx = {path: '/c/1234/3..8/foo/bar@321'};
-        element._handleLegacyLinenum(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(redirectStub.calledWithExactly(
-            '/c/1234/3..8/foo/bar#321'));
-      });
-
-      test('_handleLegacyLinenum w/ @b123', () => {
-        const ctx = {path: '/c/1234/3..8/foo/bar@b123'};
-        element._handleLegacyLinenum(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(redirectStub.calledWithExactly(
-            '/c/1234/3..8/foo/bar#b123'));
-      });
-
-      suite('_handleChangeRoute', () => {
-        let normalizeRangeStub;
-
-        function makeParams(path, hash) {
-          return {
-            params: [
-              'foo/bar', // 0 Project
-              1234, // 1 Change number
-              null, // 2 Unused
-              null, // 3 Unused
-              4, // 4 Base patch number
-              null, // 5 Unused
-              7, // 6 Patch number
-            ],
-            queryMap: new Map(),
-          };
-        }
-
-        setup(() => {
-          normalizeRangeStub = sinon.stub(element,
-              '_normalizePatchRangeParams');
-          stubRestApi('setInProjectLookup');
-        });
-
-        test('needs redirect', () => {
-          normalizeRangeStub.returns(true);
-          sinon.stub(element, '_generateUrl').returns('foo');
-          const ctx = makeParams(null, '');
-          element._handleChangeRoute(ctx);
-          assert.isTrue(normalizeRangeStub.called);
-          assert.isFalse(setParamsStub.called);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isTrue(redirectStub.calledWithExactly('foo'));
-        });
-
-        test('change view', () => {
-          normalizeRangeStub.returns(false);
-          sinon.stub(element, '_generateUrl').returns('foo');
-          const ctx = makeParams(null, '');
-          assertDataToParams(ctx, '_handleChangeRoute', {
-            view: GerritView.CHANGE,
-            project: 'foo/bar',
-            changeNum: 1234,
-            basePatchNum: 4,
-            patchNum: 7,
-            queryMap: new Map(),
-          });
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRangeStub.called);
-        });
-      });
-
-      suite('_handleDiffRoute', () => {
-        let normalizeRangeStub;
-
-        function makeParams(path, hash) {
-          return {
-            params: [
-              'foo/bar', // 0 Project
-              1234, // 1 Change number
-              null, // 2 Unused
-              null, // 3 Unused
-              4, // 4 Base patch number
-              null, // 5 Unused
-              7, // 6 Patch number
-              null, // 7 Unused,
-              path, // 8 Diff path
-            ],
-            hash,
-          };
-        }
-
-        setup(() => {
-          normalizeRangeStub = sinon.stub(element,
-              '_normalizePatchRangeParams');
-          stubRestApi('setInProjectLookup');
-        });
-
-        test('needs redirect', () => {
-          normalizeRangeStub.returns(true);
-          sinon.stub(element, '_generateUrl').returns('foo');
-          const ctx = makeParams(null, '');
-          element._handleDiffRoute(ctx);
-          assert.isTrue(normalizeRangeStub.called);
-          assert.isFalse(setParamsStub.called);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isTrue(redirectStub.calledWithExactly('foo'));
-        });
-
-        test('diff view', () => {
-          normalizeRangeStub.returns(false);
-          sinon.stub(element, '_generateUrl').returns('foo');
-          const ctx = makeParams('foo/bar/baz', 'b44');
-          assertDataToParams(ctx, '_handleDiffRoute', {
-            view: GerritView.DIFF,
-            project: 'foo/bar',
-            changeNum: 1234,
-            basePatchNum: 4,
-            patchNum: 7,
-            path: 'foo/bar/baz',
-            leftSide: true,
-            lineNum: 44,
-          });
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRangeStub.called);
-        });
-
-        test('comment route', () => {
-          const url = '/c/gerrit/+/264833/comment/00049681_f34fd6a9/';
-          const groups = url.match(_testOnly_RoutePattern.COMMENT);
-          assert.deepEqual(groups.slice(1), [
-            'gerrit', // project
-            '264833', // changeNum
-            '00049681_f34fd6a9', // commentId
-          ]);
-          assertDataToParams({params: groups.slice(1)}, '_handleCommentRoute', {
-            project: 'gerrit',
-            changeNum: 264833,
-            commentId: '00049681_f34fd6a9',
-            commentLink: true,
-            view: GerritView.DIFF,
-          });
-        });
-
-        test('comments route', () => {
-          const url = '/c/gerrit/+/264833/comments/00049681_f34fd6a9/';
-          const groups = url.match(_testOnly_RoutePattern.COMMENTS_TAB);
-          assert.deepEqual(groups.slice(1), [
-            'gerrit', // project
-            '264833', // changeNum
-            '00049681_f34fd6a9', // commentId
-          ]);
-          assertDataToParams({params: groups.slice(1)},
-              '_handleCommentsRoute', {
-                project: 'gerrit',
-                changeNum: 264833,
-                commentId: '00049681_f34fd6a9',
-                view: GerritView.CHANGE,
-              });
-        });
-      });
-
-      test('_handleDiffEditRoute', () => {
-        const normalizeRangeSpy =
-            sinon.spy(element, '_normalizePatchRangeParams');
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          params: [
-            'foo/bar', // 0 Project
-            1234, // 1 Change number
-            3, // 2 Patch num
-            'foo/bar/baz', // 3 File path
-          ],
-        };
-        const appParams = {
-          project: 'foo/bar',
-          changeNum: 1234,
-          view: GerritNav.View.EDIT,
-          path: 'foo/bar/baz',
-          patchNum: 3,
-          lineNum: undefined,
-        };
-
-        element._handleDiffEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.isTrue(normalizeRangeSpy.calledOnce);
-        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-      });
-
-      test('_handleDiffEditRoute with lineNum', () => {
-        const normalizeRangeSpy =
-            sinon.spy(element, '_normalizePatchRangeParams');
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          params: [
-            'foo/bar', // 0 Project
-            1234, // 1 Change number
-            3, // 2 Patch num
-            'foo/bar/baz', // 3 File path
-          ],
-          hash: 4,
-        };
-        const appParams = {
-          project: 'foo/bar',
-          changeNum: 1234,
-          view: GerritNav.View.EDIT,
-          path: 'foo/bar/baz',
-          patchNum: 3,
-          lineNum: 4,
-        };
-
-        element._handleDiffEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.isTrue(normalizeRangeSpy.calledOnce);
-        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-      });
-
-      test('_handleChangeEditRoute', () => {
-        const normalizeRangeSpy =
-            sinon.spy(element, '_normalizePatchRangeParams');
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          params: [
-            'foo/bar', // 0 Project
-            1234, // 1 Change number
-            null,
-            3, // 3 Patch num
-          ],
-        };
-        const appParams = {
-          project: 'foo/bar',
-          changeNum: 1234,
-          view: GerritView.CHANGE,
-          patchNum: 3,
-          edit: true,
-        };
-
-        element._handleChangeEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.isTrue(normalizeRangeSpy.calledOnce);
-        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-      });
-    });
-
-    test('_handlePluginScreen', () => {
-      const ctx = {params: ['foo', 'bar']};
-      assertDataToParams(ctx, '_handlePluginScreen', {
-        view: GerritNav.View.PLUGIN_SCREEN,
-        plugin: 'foo',
-        screen: 'bar',
-      });
-      assert.isFalse(redirectStub.called);
-    });
-  });
-
-  suite('_parseQueryString', () => {
-    test('empty queries', () => {
-      assert.deepEqual(element._parseQueryString(''), []);
-      assert.deepEqual(element._parseQueryString('?'), []);
-      assert.deepEqual(element._parseQueryString('??'), []);
-      assert.deepEqual(element._parseQueryString('&&&'), []);
-    });
-
-    test('url decoding', () => {
-      assert.deepEqual(element._parseQueryString('+'), [[' ', '']]);
-      assert.deepEqual(element._parseQueryString('???+%3d+'), [[' = ', '']]);
-      assert.deepEqual(
-          element._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'),
-          [['a', 'b'], ['c', 'd'], ['e', 'f']]);
-      assert.deepEqual(
-          element._parseQueryString('&a=b&&&e=f&c'),
-          [['a', 'b'], ['e', 'f'], ['c', '']]);
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..4c147bb
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -0,0 +1,1823 @@
+/**
+ * @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-router';
+import {page} from '../../../utils/page-wrapper-utils';
+import {
+  GenerateUrlChangeViewParameters,
+  GenerateUrlDashboardViewParameters,
+  GenerateUrlDiffViewParameters,
+  GenerateUrlEditViewParameters,
+  GenerateUrlGroupViewParameters,
+  GenerateUrlParameters,
+  GenerateUrlSearchViewParameters,
+  GerritNav,
+  GroupDetailView,
+  WeblinkType,
+} from '../gr-navigation/gr-navigation';
+import {
+  stubBaseUrl,
+  stubRestApi,
+  addListenerForTest,
+} from '../../../test/test-utils';
+import {
+  GrRouter,
+  PageContextWithQueryMap,
+  PatchRangeParams,
+  _testOnly_RoutePattern,
+} from './gr-router';
+import {GerritView} from '../../../services/router/router-model';
+import {
+  BasePatchSetNum,
+  BranchName,
+  CommitId,
+  DashboardId,
+  GroupId,
+  NumericChangeId,
+  ParentPatchSetNum,
+  PatchSetNum,
+  RepoName,
+  RevisionPatchSetNum,
+  TopicName,
+  UrlEncodedCommentId,
+  WebLinkInfo,
+} from '../../../types/common';
+import {
+  createGerritInfo,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {AppElementParams} from '../../gr-app-types';
+
+suite('gr-router tests', () => {
+  let router: GrRouter;
+
+  setup(() => {
+    router = new GrRouter();
+  });
+
+  test('firstCodeBrowserWeblink', () => {
+    assert.deepEqual(
+      router.firstCodeBrowserWeblink([
+        {name: 'gitweb'},
+        {name: 'gitiles'},
+        {name: 'browse'},
+        {name: 'test'},
+      ]),
+      {name: 'gitiles'}
+    );
+
+    assert.deepEqual(
+      router.firstCodeBrowserWeblink([{name: 'gitweb'}, {name: 'test'}]),
+      {name: 'gitweb'}
+    );
+  });
+
+  test('getBrowseCommitWeblink', () => {
+    const browserLink = {name: 'browser', url: 'browser/url'};
+    const link = {name: 'test', url: 'test/url'};
+    const weblinks = [browserLink, link];
+    const config = {
+      ...createServerInfo(),
+      gerrit: {...createGerritInfo(), primary_weblink_name: browserLink.name},
+    };
+    sinon.stub(router, 'firstCodeBrowserWeblink').returns(link);
+
+    assert.deepEqual(
+      router.getBrowseCommitWeblink(weblinks, config),
+      browserLink
+    );
+
+    assert.deepEqual(router.getBrowseCommitWeblink(weblinks), link);
+  });
+
+  test('getChangeWeblinks', () => {
+    const link = {name: 'test', url: 'test/url'};
+    const browserLink = {name: 'browser', url: 'browser/url'};
+    const mapLinksToConfig = (weblinks: WebLinkInfo[]) => {
+      return {
+        type: 'change' as WeblinkType.CHANGE,
+        repo: 'test' as RepoName,
+        commit: '111' as CommitId,
+        options: {weblinks},
+      };
+    };
+    sinon.stub(router, 'getBrowseCommitWeblink').returns(browserLink);
+
+    assert.deepEqual(
+      router.getChangeWeblinks(mapLinksToConfig([link, browserLink]))[0],
+      {name: 'test', url: 'test/url'}
+    );
+
+    assert.deepEqual(router.getChangeWeblinks(mapLinksToConfig([link]))[0], {
+      name: 'test',
+      url: 'test/url',
+    });
+
+    link.url = `https://${link.url}`;
+    assert.deepEqual(router.getChangeWeblinks(mapLinksToConfig([link]))[0], {
+      name: 'test',
+      url: 'https://test/url',
+    });
+  });
+
+  test('getHashFromCanonicalPath', () => {
+    let url = '/foo/bar';
+    let hash = router.getHashFromCanonicalPath(url);
+    assert.equal(hash, '');
+
+    url = '';
+    hash = router.getHashFromCanonicalPath(url);
+    assert.equal(hash, '');
+
+    url = '/foo#bar';
+    hash = router.getHashFromCanonicalPath(url);
+    assert.equal(hash, 'bar');
+
+    url = '/foo#bar#baz';
+    hash = router.getHashFromCanonicalPath(url);
+    assert.equal(hash, 'bar#baz');
+
+    url = '#foo#bar#baz';
+    hash = router.getHashFromCanonicalPath(url);
+    assert.equal(hash, 'foo#bar#baz');
+  });
+
+  suite('parseLineAddress', () => {
+    test('returns null for empty and invalid hashes', () => {
+      let actual = router.parseLineAddress('');
+      assert.isNull(actual);
+
+      actual = router.parseLineAddress('foobar');
+      assert.isNull(actual);
+
+      actual = router.parseLineAddress('foo123');
+      assert.isNull(actual);
+
+      actual = router.parseLineAddress('123bar');
+      assert.isNull(actual);
+    });
+
+    test('parses correctly', () => {
+      let actual = router.parseLineAddress('1234');
+      assert.isOk(actual);
+      assert.equal(actual!.lineNum, 1234);
+      assert.isFalse(actual!.leftSide);
+
+      actual = router.parseLineAddress('a4');
+      assert.isOk(actual);
+      assert.equal(actual!.lineNum, 4);
+      assert.isTrue(actual!.leftSide);
+
+      actual = router.parseLineAddress('b77');
+      assert.isOk(actual);
+      assert.equal(actual!.lineNum, 77);
+      assert.isTrue(actual!.leftSide);
+    });
+  });
+
+  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.
+
+    const requiresAuth: any = {};
+    const doesNotRequireAuth: any = {};
+    sinon.stub(GerritNav, 'setup');
+    sinon.stub(page, 'start');
+    sinon.stub(page, 'base');
+    sinon
+      .stub(router, 'mapRoute')
+      .callsFake((_pattern, methodName, _method, usesAuth) => {
+        if (usesAuth) {
+          requiresAuth[methodName] = true;
+        } else {
+          doesNotRequireAuth[methodName] = true;
+        }
+      });
+    router.startRouter();
+
+    const actualRequiresAuth = Object.keys(requiresAuth);
+    actualRequiresAuth.sort();
+    const actualDoesNotRequireAuth = Object.keys(doesNotRequireAuth);
+    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',
+    ];
+    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',
+    ];
+
+    // Handler names that check authentication themselves, and thus don't need
+    // it performed for them.
+    const selfAuthenticatingHandlers = [
+      'handleDashboardRoute',
+      'handleCustomDashboardRoute',
+      'handleRootRoute',
+    ];
+
+    const shouldNotRequireAuth = unauthenticatedHandlers.concat(
+      selfAuthenticatingHandlers
+    );
+    shouldNotRequireAuth.sort();
+    assert.deepEqual(actualDoesNotRequireAuth, shouldNotRequireAuth);
+  });
+
+  test('redirectIfNotLoggedIn while logged in', () => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(true));
+    const data = {
+      save() {},
+      handled: true,
+      canonicalPath: '',
+      path: '',
+      querystring: '',
+      pathname: '',
+      state: '',
+      title: '',
+      hash: '',
+      params: {test: 'test'},
+    };
+    const redirectStub = sinon.stub(router, 'redirectToLogin');
+    return router.redirectIfNotLoggedIn(data).then(() => {
+      assert.isFalse(redirectStub.called);
+    });
+  });
+
+  test('redirectIfNotLoggedIn while logged out', () => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    const redirectStub = sinon.stub(router, 'redirectToLogin');
+    const data = {
+      save() {},
+      handled: true,
+      canonicalPath: '',
+      path: '',
+      querystring: '',
+      pathname: '',
+      state: '',
+      title: '',
+      hash: '',
+      params: {test: 'test'},
+    };
+    return new Promise(resolve => {
+      router
+        .redirectIfNotLoggedIn(data)
+        .then(() => {
+          assert.isTrue(false, 'Should never execute');
+        })
+        .catch(() => {
+          assert.isTrue(redirectStub.calledOnce);
+          resolve(Promise.resolve());
+        });
+    });
+  });
+
+  suite('generateUrl', () => {
+    test('search', () => {
+      let params: GenerateUrlSearchViewParameters = {
+        view: GerritView.SEARCH,
+        owner: 'a%b',
+        project: 'c%d' as RepoName,
+        branch: 'e%f' as BranchName,
+        topic: 'g%h' as TopicName,
+        statuses: ['op%en'],
+      };
+      assert.equal(
+        router.generateUrl(params),
+        '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+          'topic:g%2525h+status:op%2525en'
+      );
+
+      params.offset = 100;
+      assert.equal(
+        router.generateUrl(params),
+        '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+          'topic:g%2525h+status:op%2525en,100'
+      );
+      delete params.offset;
+
+      // The presence of the query param overrides other params.
+      params.query = 'foo$bar';
+      assert.equal(router.generateUrl(params), '/q/foo%2524bar');
+
+      params.offset = 100;
+      assert.equal(router.generateUrl(params), '/q/foo%2524bar,100');
+
+      params = {
+        view: GerritNav.View.SEARCH,
+        statuses: ['a', 'b', 'c'],
+      };
+      assert.equal(
+        router.generateUrl(params),
+        '/q/(status:a OR status:b OR status:c)'
+      );
+
+      params = {
+        view: GerritNav.View.SEARCH,
+        topic: 'test' as TopicName,
+      };
+      assert.equal(router.generateUrl(params), '/q/topic:test');
+      params = {
+        view: GerritNav.View.SEARCH,
+        topic: 'test test' as TopicName,
+      };
+      assert.equal(router.generateUrl(params), '/q/topic:"test+test"');
+      params = {
+        view: GerritNav.View.SEARCH,
+        topic: 'test:test' as TopicName,
+      };
+      assert.equal(router.generateUrl(params), '/q/topic:"test:test"');
+    });
+
+    test('change', () => {
+      const params: GenerateUrlChangeViewParameters = {
+        view: GerritView.CHANGE,
+        changeNum: 1234 as NumericChangeId,
+        project: 'test' as RepoName,
+      };
+
+      assert.equal(router.generateUrl(params), '/c/test/+/1234');
+
+      params.patchNum = 10 as PatchSetNum;
+      assert.equal(router.generateUrl(params), '/c/test/+/1234/10');
+
+      params.basePatchNum = 5 as BasePatchSetNum;
+      assert.equal(router.generateUrl(params), '/c/test/+/1234/5..10');
+
+      params.messageHash = '#123';
+      assert.equal(router.generateUrl(params), '/c/test/+/1234/5..10#123');
+    });
+
+    test('change with repo name encoding', () => {
+      const params: GenerateUrlChangeViewParameters = {
+        view: GerritView.CHANGE,
+        changeNum: 1234 as NumericChangeId,
+        project: 'x+/y+/z+/w' as RepoName,
+      };
+      assert.equal(
+        router.generateUrl(params),
+        '/c/x%252B/y%252B/z%252B/w/+/1234'
+      );
+    });
+
+    test('diff', () => {
+      const params: GenerateUrlDiffViewParameters = {
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+        path: 'x+y/path.cpp' as RepoName,
+        patchNum: 12 as PatchSetNum,
+        project: '' as RepoName,
+      };
+      assert.equal(router.generateUrl(params), '/c/42/12/x%252By/path.cpp');
+
+      params.project = 'test' as RepoName;
+      assert.equal(
+        router.generateUrl(params),
+        '/c/test/+/42/12/x%252By/path.cpp'
+      );
+
+      params.basePatchNum = 6 as BasePatchSetNum;
+      assert.equal(
+        router.generateUrl(params),
+        '/c/test/+/42/6..12/x%252By/path.cpp'
+      );
+
+      params.path = 'foo bar/my+file.txt%';
+      params.patchNum = 2 as PatchSetNum;
+      delete params.basePatchNum;
+      assert.equal(
+        router.generateUrl(params),
+        '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
+      );
+
+      params.path = 'file.cpp';
+      params.lineNum = 123;
+      assert.equal(router.generateUrl(params), '/c/test/+/42/2/file.cpp#123');
+
+      params.leftSide = true;
+      assert.equal(router.generateUrl(params), '/c/test/+/42/2/file.cpp#b123');
+    });
+
+    test('diff with repo name encoding', () => {
+      const params: GenerateUrlDiffViewParameters = {
+        view: GerritView.DIFF,
+        changeNum: 42 as NumericChangeId,
+        path: 'x+y/path.cpp',
+        patchNum: 12 as PatchSetNum,
+        project: 'x+/y' as RepoName,
+      };
+      assert.equal(
+        router.generateUrl(params),
+        '/c/x%252B/y/+/42/12/x%252By/path.cpp'
+      );
+    });
+
+    test('edit', () => {
+      const params: GenerateUrlEditViewParameters = {
+        view: GerritView.EDIT,
+        changeNum: 42 as NumericChangeId,
+        project: 'test' as RepoName,
+        path: 'x+y/path.cpp',
+        patchNum: 'edit' as PatchSetNum,
+      };
+      assert.equal(
+        router.generateUrl(params),
+        '/c/test/+/42/edit/x%252By/path.cpp,edit'
+      );
+    });
+
+    test('getPatchRangeExpression', () => {
+      const params: PatchRangeParams = {};
+      let actual = router.getPatchRangeExpression(params);
+      assert.equal(actual, '');
+
+      params.patchNum = 4 as PatchSetNum;
+      actual = router.getPatchRangeExpression(params);
+      assert.equal(actual, '4');
+
+      params.basePatchNum = 2 as BasePatchSetNum;
+      actual = router.getPatchRangeExpression(params);
+      assert.equal(actual, '2..4');
+
+      delete params.patchNum;
+      actual = router.getPatchRangeExpression(params);
+      assert.equal(actual, '2..');
+    });
+
+    suite('dashboard', () => {
+      test('self dashboard', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+        };
+        assert.equal(router.generateUrl(params), '/dashboard/self');
+      });
+
+      test('user dashboard', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+          user: 'user',
+        };
+        assert.equal(router.generateUrl(params), '/dashboard/user');
+      });
+
+      test('custom self dashboard, no title', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+          sections: [
+            {name: 'section 1', query: 'query 1'},
+            {name: 'section 2', query: 'query 2'},
+          ],
+        };
+        assert.equal(
+          router.generateUrl(params),
+          '/dashboard/?section%201=query%201&section%202=query%202'
+        );
+      });
+
+      test('custom repo dashboard', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+          sections: [
+            {name: 'section 1', query: 'query 1 ${project}'},
+            {name: 'section 2', query: 'query 2 ${repo}'},
+          ],
+          repo: 'repo-name' as RepoName,
+        };
+        assert.equal(
+          router.generateUrl(params),
+          '/dashboard/?section%201=query%201%20repo-name&' +
+            'section%202=query%202%20repo-name'
+        );
+      });
+
+      test('custom user dashboard, with title', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+          user: 'user',
+          sections: [{name: 'name', query: 'query'}],
+          title: 'custom dashboard',
+        };
+        assert.equal(
+          router.generateUrl(params),
+          '/dashboard/user?name=query&title=custom%20dashboard'
+        );
+      });
+
+      test('repo dashboard', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+          repo: 'gerrit/repo' as RepoName,
+          dashboard: 'default:main' as DashboardId,
+        };
+        assert.equal(
+          router.generateUrl(params),
+          '/p/gerrit/repo/+/dashboard/default:main'
+        );
+      });
+
+      test('project dashboard (legacy)', () => {
+        const params: GenerateUrlDashboardViewParameters = {
+          view: GerritView.DASHBOARD,
+          project: 'gerrit/project' as RepoName,
+          dashboard: 'default:main' as DashboardId,
+        };
+        assert.equal(
+          router.generateUrl(params),
+          '/p/gerrit/project/+/dashboard/default:main'
+        );
+      });
+    });
+
+    suite('groups', () => {
+      test('group info', () => {
+        const params: GenerateUrlGroupViewParameters = {
+          view: GerritView.GROUP,
+          groupId: '1234' as GroupId,
+        };
+        assert.equal(router.generateUrl(params), '/admin/groups/1234');
+      });
+
+      test('group members', () => {
+        const params: GenerateUrlGroupViewParameters = {
+          view: GerritView.GROUP,
+          groupId: '1234' as GroupId,
+          detail: 'members' as GroupDetailView,
+        };
+        assert.equal(router.generateUrl(params), '/admin/groups/1234,members');
+      });
+
+      test('group audit log', () => {
+        const params: GenerateUrlGroupViewParameters = {
+          view: GerritView.GROUP,
+          groupId: '1234' as GroupId,
+          detail: 'log' as GroupDetailView,
+        };
+        assert.equal(
+          router.generateUrl(params),
+          '/admin/groups/1234,audit-log'
+        );
+      });
+    });
+  });
+
+  suite('param normalization', () => {
+    suite('normalizePatchRangeParams', () => {
+      test('range n..n normalizes to n', () => {
+        const params: PatchRangeParams = {
+          basePatchNum: 4 as BasePatchSetNum,
+          patchNum: 4 as PatchSetNum,
+        };
+        const needsRedirect = router.normalizePatchRangeParams(params);
+        assert.isTrue(needsRedirect);
+        assert.equal(params.basePatchNum, ParentPatchSetNum);
+        assert.equal(params.patchNum, 4 as PatchSetNum);
+      });
+
+      test('range n.. normalizes to n', () => {
+        const params: PatchRangeParams = {basePatchNum: 4 as BasePatchSetNum};
+        const needsRedirect = router.normalizePatchRangeParams(params);
+        assert.isFalse(needsRedirect);
+        assert.equal(params.basePatchNum, ParentPatchSetNum);
+        assert.equal(params.patchNum, 4 as PatchSetNum);
+      });
+    });
+  });
+
+  suite('route handlers', () => {
+    let redirectStub: sinon.SinonStub;
+    let setParamsStub: sinon.SinonStub;
+    let handlePassThroughRoute: sinon.SinonStub;
+
+    // Simple route handlers are direct mappings from parsed route data to a
+    // new set of app.params. This test helper asserts that passing `data`
+    // into `methodName` results in setting the params specified in `params`.
+    function assertDataToParams(
+      data: PageContextWithQueryMap,
+      methodName: string,
+      params: AppElementParams | GenerateUrlParameters
+    ) {
+      (router as any)[methodName](data);
+      assert.deepEqual(setParamsStub.lastCall.args[0], params);
+    }
+
+    function createPageContext(): PageContextWithQueryMap {
+      return {
+        queryMap: new Map(),
+        save() {},
+        handled: true,
+        canonicalPath: '',
+        path: '',
+        querystring: '',
+        pathname: '',
+        state: '',
+        title: '',
+        hash: '',
+        params: {},
+      };
+    }
+
+    setup(() => {
+      redirectStub = sinon.stub(router, 'redirect');
+      setParamsStub = sinon.stub(router, 'setParams');
+      handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
+    });
+
+    test('handleLegacyProjectDashboardRoute', () => {
+      const params = {
+        ...createPageContext(),
+        params: {0: 'gerrit/project', 1: 'dashboard:main'},
+      };
+      router.handleLegacyProjectDashboardRoute(params);
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(
+        redirectStub.lastCall.args[0],
+        '/p/gerrit/project/+/dashboard/dashboard:main'
+      );
+    });
+
+    test('handleAgreementsRoute', () => {
+      router.handleAgreementsRoute();
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
+    });
+
+    test('handleNewAgreementsRoute', () => {
+      const params = createPageContext();
+      router.handleNewAgreementsRoute(params);
+      assert.isTrue(setParamsStub.calledOnce);
+      assert.equal(
+        setParamsStub.lastCall.args[0].view,
+        GerritNav.View.AGREEMENTS
+      );
+    });
+
+    test('handleSettingsLegacyRoute', () => {
+      const data = {...createPageContext(), params: {0: 'my-token'}};
+      assertDataToParams(data, 'handleSettingsLegacyRoute', {
+        view: GerritNav.View.SETTINGS,
+        emailToken: 'my-token',
+      });
+    });
+
+    test('handleSettingsLegacyRoute with +', () => {
+      const data = {...createPageContext(), params: {0: 'my-token test'}};
+      assertDataToParams(data, 'handleSettingsLegacyRoute', {
+        view: GerritNav.View.SETTINGS,
+        emailToken: 'my-token+test',
+      });
+    });
+
+    test('handleSettingsRoute', () => {
+      const data = createPageContext();
+      assertDataToParams(data, 'handleSettingsRoute', {
+        view: GerritNav.View.SETTINGS,
+      });
+    });
+
+    test('handleDefaultRoute on first load', () => {
+      const spy = sinon.spy();
+      addListenerForTest(document, 'page-error', spy);
+      router.handleDefaultRoute();
+      assert.isTrue(spy.calledOnce);
+      assert.equal(spy.lastCall.args[0].detail.response.status, 404);
+    });
+
+    test('handleDefaultRoute after internal navigation', () => {
+      let onExit: Function | null = null;
+      const onRegisteringExit = (
+        _match: string | RegExp,
+        _onExit: Function
+      ) => {
+        onExit = _onExit;
+      };
+      sinon.stub(page, 'exit').callsFake(onRegisteringExit);
+      sinon.stub(GerritNav, 'setup');
+      sinon.stub(page, 'start');
+      sinon.stub(page, 'base');
+      router.startRouter();
+
+      router.handleDefaultRoute();
+
+      onExit!('', () => {}); // we left page;
+
+      router.handleDefaultRoute();
+      assert.isTrue(handlePassThroughRoute.calledOnce);
+    });
+
+    test('handleImproperlyEncodedPlusRoute', () => {
+      const params = {
+        ...createPageContext(),
+        canonicalPath: '/c/test/%20/42',
+        params: {0: 'test', 1: '42'},
+      };
+      // Regression test for Issue 7100.
+      router.handleImproperlyEncodedPlusRoute(params);
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42');
+
+      sinon.stub(router, 'getHashFromCanonicalPath').returns('foo');
+      router.handleImproperlyEncodedPlusRoute(params);
+      assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42#foo');
+    });
+
+    test('handleQueryRoute', () => {
+      const data: PageContextWithQueryMap = {
+        ...createPageContext(),
+        params: {0: 'project:foo/bar/baz'},
+      };
+      assertDataToParams(data, 'handleQueryRoute', {
+        view: GerritNav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: undefined,
+      });
+
+      data.params[1] = '123';
+      data.params[2] = '123';
+      assertDataToParams(data, 'handleQueryRoute', {
+        view: GerritNav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: '123',
+      });
+    });
+
+    test('handleQueryLegacySuffixRoute', () => {
+      const params = {...createPageContext(), path: '/q/foo+bar,n,z'};
+      router.handleQueryLegacySuffixRoute(params);
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
+    });
+
+    test('handleChangeIdQueryRoute', () => {
+      const data = {
+        ...createPageContext(),
+        params: {0: 'I0123456789abcdef0123456789abcdef01234567'},
+      };
+      assertDataToParams(data, 'handleChangeIdQueryRoute', {
+        view: GerritNav.View.SEARCH,
+        query: 'I0123456789abcdef0123456789abcdef01234567',
+      });
+    });
+
+    suite('handleRegisterRoute', () => {
+      test('happy path', () => {
+        const ctx = {...createPageContext(), params: {0: '/foo/bar'}};
+        router.handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
+        assert.isTrue(setParamsStub.calledOnce);
+        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+      });
+
+      test('no param', () => {
+        const ctx = createPageContext();
+        router.handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/'));
+        assert.isTrue(setParamsStub.calledOnce);
+        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+      });
+
+      test('prevent redirect', () => {
+        const ctx = {...createPageContext(), params: {0: '/register'}};
+        router.handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/'));
+        assert.isTrue(setParamsStub.calledOnce);
+        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+      });
+    });
+
+    suite('handleRootRoute', () => {
+      test('closes for closeAfterLogin', () => {
+        const data = {...createPageContext(), querystring: 'closeAfterLogin'};
+        const closeStub = sinon.stub(window, 'close');
+        const result = router.handleRootRoute(data);
+        assert.isNotOk(result);
+        assert.isTrue(closeStub.called);
+        assert.isFalse(redirectStub.called);
+      });
+
+      test('redirects to dashboard if logged in', () => {
+        const data = {...createPageContext(), canonicalPath: '/', path: '/'};
+        const result = router.handleRootRoute(data);
+        assert.isOk(result);
+        return result!.then(() => {
+          assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
+        });
+      });
+
+      test('redirects to open changes if not logged in', () => {
+        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+        const data = {...createPageContext(), canonicalPath: '/', path: '/'};
+        const result = router.handleRootRoute(data);
+        assert.isOk(result);
+        return result!.then(() => {
+          assert.isTrue(
+            redirectStub.calledWithExactly('/q/status:open+-is:wip')
+          );
+        });
+      });
+
+      suite('GWT hash-path URLs', () => {
+        test('redirects hash-path URLs', () => {
+          const data = {
+            ...createPageContext(),
+            canonicalPath: '/#/foo/bar/baz',
+            hash: '/foo/bar/baz',
+          };
+          const result = router.handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+        });
+
+        test('redirects hash-path URLs w/o leading slash', () => {
+          const data = {
+            ...createPageContext(),
+            canonicalPath: '/#foo/bar/baz',
+            hash: 'foo/bar/baz',
+          };
+          const result = router.handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+        });
+
+        test('normalizes "/ /" in hash to "/+/"', () => {
+          const data = {
+            ...createPageContext(),
+            canonicalPath: '/#/foo/bar/+/123/4',
+            hash: '/foo/bar/ /123/4',
+          };
+          const result = router.handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
+        });
+
+        test('prepends baseurl to hash-path', () => {
+          const data = {
+            ...createPageContext(),
+            canonicalPath: '/#/foo/bar',
+            hash: '/foo/bar',
+          };
+          stubBaseUrl('/baz');
+          const result = router.handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
+        });
+
+        test('normalizes /VE/ settings hash-paths', () => {
+          const data = {
+            ...createPageContext(),
+            canonicalPath: '/#/VE/foo/bar',
+            hash: '/VE/foo/bar',
+          };
+          const result = router.handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/settings/VE/foo/bar'));
+        });
+
+        test('does not drop "inner hashes"', () => {
+          const data = {
+            ...createPageContext(),
+            canonicalPath: '/#/foo/bar#baz',
+            hash: '/foo/bar',
+          };
+          const result = router.handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
+        });
+      });
+    });
+
+    suite('handleDashboardRoute', () => {
+      let redirectToLoginStub: sinon.SinonStub;
+
+      setup(() => {
+        redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
+      });
+
+      test('own dashboard but signed out redirects to login', () => {
+        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+        const data = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: 'seLF'},
+        };
+        return router.handleDashboardRoute(data).then(() => {
+          assert.isTrue(redirectToLoginStub.calledOnce);
+          assert.isFalse(redirectStub.called);
+          assert.isFalse(setParamsStub.called);
+        });
+      });
+
+      test('non-self dashboard but signed out does not redirect', () => {
+        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+        const data = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: 'foo'},
+        };
+        return router.handleDashboardRoute(data).then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
+        });
+      });
+
+      test('dashboard while signed in sets params', () => {
+        const data = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: 'foo'},
+        };
+        return router.handleDashboardRoute(data).then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setParamsStub.calledOnce);
+          assert.deepEqual(setParamsStub.lastCall.args[0], {
+            view: GerritNav.View.DASHBOARD,
+            user: 'foo',
+          });
+        });
+      });
+    });
+
+    suite('handleCustomDashboardRoute', () => {
+      let redirectToLoginStub: sinon.SinonStub;
+
+      setup(() => {
+        redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
+      });
+
+      test('no user specified', () => {
+        const data = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: ''},
+        };
+        return router.handleCustomDashboardRoute(data, '').then(() => {
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.called);
+          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
+        });
+      });
+
+      test('custom dashboard without title', () => {
+        const data = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: ''},
+        };
+        return router
+          .handleCustomDashboardRoute(data, '?a=b&c&d=e')
+          .then(() => {
+            assert.isFalse(redirectStub.called);
+            assert.isTrue(setParamsStub.calledOnce);
+            assert.deepEqual(setParamsStub.lastCall.args[0], {
+              view: GerritNav.View.DASHBOARD,
+              user: 'self',
+              sections: [
+                {name: 'a', query: 'b'},
+                {name: 'd', query: 'e'},
+              ],
+              title: 'Custom Dashboard',
+            });
+          });
+      });
+
+      test('custom dashboard with title', () => {
+        const data = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: ''},
+        };
+        return router
+          .handleCustomDashboardRoute(data, '?a=b&c&d=&=e&title=t')
+          .then(() => {
+            assert.isFalse(redirectToLoginStub.called);
+            assert.isFalse(redirectStub.called);
+            assert.isTrue(setParamsStub.calledOnce);
+            assert.deepEqual(setParamsStub.lastCall.args[0], {
+              view: GerritNav.View.DASHBOARD,
+              user: 'self',
+              sections: [{name: 'a', query: 'b'}],
+              title: 't',
+            });
+          });
+      });
+
+      test('custom dashboard with foreach', () => {
+        const data = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: ''},
+        };
+        return router
+          .handleCustomDashboardRoute(data, '?a=b&c&d=&=e&foreach=is:open')
+          .then(() => {
+            assert.isFalse(redirectToLoginStub.called);
+            assert.isFalse(redirectStub.called);
+            assert.isTrue(setParamsStub.calledOnce);
+            assert.deepEqual(setParamsStub.lastCall.args[0], {
+              view: GerritNav.View.DASHBOARD,
+              user: 'self',
+              sections: [{name: 'a', query: 'is:open b'}],
+              title: 'Custom Dashboard',
+            });
+          });
+      });
+    });
+
+    suite('group routes', () => {
+      test('handleGroupInfoRoute', () => {
+        const data = {...createPageContext(), params: {0: '1234'}};
+        router.handleGroupInfoRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
+      });
+
+      test('handleGroupAuditLogRoute', () => {
+        const data = {...createPageContext(), params: {0: '1234'}};
+        assertDataToParams(data, 'handleGroupAuditLogRoute', {
+          view: GerritView.GROUP,
+          detail: GroupDetailView.LOG,
+          groupId: '1234' as GroupId,
+        });
+      });
+
+      test('handleGroupMembersRoute', () => {
+        const data = {...createPageContext(), params: {0: '1234'}};
+        assertDataToParams(data, 'handleGroupMembersRoute', {
+          view: GerritView.GROUP,
+          detail: GroupDetailView.MEMBERS,
+          groupId: '1234' as GroupId,
+        });
+      });
+
+      test('handleGroupListOffsetRoute', () => {
+        const data = createPageContext();
+        assertDataToParams(data, 'handleGroupListOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: 0,
+          filter: null,
+          openCreateModal: false,
+        });
+
+        data.params[1] = '42';
+        assertDataToParams(data, 'handleGroupListOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: '42',
+          filter: null,
+          openCreateModal: false,
+        });
+
+        data.hash = 'create';
+        assertDataToParams(data, 'handleGroupListOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: '42',
+          filter: null,
+          openCreateModal: true,
+        });
+      });
+
+      test('handleGroupListFilterOffsetRoute', () => {
+        const data = {
+          ...createPageContext(),
+          params: {filter: 'foo', offset: '42'},
+        };
+        assertDataToParams(data, 'handleGroupListFilterOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: '42',
+          filter: 'foo',
+        });
+      });
+
+      test('handleGroupListFilterRoute', () => {
+        const data = {...createPageContext(), params: {filter: 'foo'}};
+        assertDataToParams(data, 'handleGroupListFilterRoute', {
+          view: GerritView.ADMIN,
+          adminView: 'gr-admin-group-list',
+          filter: 'foo',
+        });
+      });
+
+      test('handleGroupRoute', () => {
+        const data = {...createPageContext(), params: {0: '4321'}};
+        assertDataToParams(data, 'handleGroupRoute', {
+          view: GerritView.GROUP,
+          groupId: '4321' as GroupId,
+        });
+      });
+    });
+
+    suite('repo routes', () => {
+      test('handleProjectsOldRoute', () => {
+        const data = {...createPageContext(), params: {}};
+        router.handleProjectsOldRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
+      });
+
+      test('handleProjectsOldRoute test', () => {
+        const data = {...createPageContext(), params: {1: 'test'}};
+        router.handleProjectsOldRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
+      });
+
+      test('handleProjectsOldRoute test,branches', () => {
+        const data = {...createPageContext(), params: {1: 'test,branches'}};
+        router.handleProjectsOldRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(
+          redirectStub.lastCall.args[0],
+          '/admin/repos/test,branches'
+        );
+      });
+
+      test('handleRepoRoute', () => {
+        const data = {...createPageContext(), path: '/admin/repos/test'};
+        router.handleRepoRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(
+          redirectStub.lastCall.args[0],
+          '/admin/repos/test,general'
+        );
+      });
+
+      test('handleRepoGeneralRoute', () => {
+        const data = {...createPageContext(), params: {0: '4321'}};
+        assertDataToParams(data, 'handleRepoGeneralRoute', {
+          view: GerritView.REPO,
+          detail: GerritNav.RepoDetailView.GENERAL,
+          repo: '4321' as RepoName,
+        });
+      });
+
+      test('handleRepoCommandsRoute', () => {
+        const data = {...createPageContext(), params: {0: '4321'}};
+        assertDataToParams(data, 'handleRepoCommandsRoute', {
+          view: GerritView.REPO,
+          detail: GerritNav.RepoDetailView.COMMANDS,
+          repo: '4321' as RepoName,
+        });
+      });
+
+      test('handleRepoAccessRoute', () => {
+        const data = {...createPageContext(), params: {0: '4321'}};
+        assertDataToParams(data, 'handleRepoAccessRoute', {
+          view: GerritView.REPO,
+          detail: GerritNav.RepoDetailView.ACCESS,
+          repo: '4321' as RepoName,
+        });
+      });
+
+      suite('branch list routes', () => {
+        test('handleBranchListOffsetRoute', () => {
+          const data: PageContextWithQueryMap = {
+            ...createPageContext(),
+            params: {0: '4321'},
+          };
+          assertDataToParams(data, 'handleBranchListOffsetRoute', {
+            view: GerritView.REPO,
+            detail: GerritNav.RepoDetailView.BRANCHES,
+            repo: '4321' as RepoName,
+            offset: 0,
+            filter: null,
+          });
+
+          data.params[2] = '42';
+          assertDataToParams(data, 'handleBranchListOffsetRoute', {
+            view: GerritView.REPO,
+            detail: GerritNav.RepoDetailView.BRANCHES,
+            repo: '4321' as RepoName,
+            offset: '42',
+            filter: null,
+          });
+        });
+
+        test('handleBranchListFilterOffsetRoute', () => {
+          const data = {
+            ...createPageContext(),
+            params: {repo: '4321', filter: 'foo', offset: '42'},
+          };
+          assertDataToParams(data, 'handleBranchListFilterOffsetRoute', {
+            view: GerritView.REPO,
+            detail: GerritNav.RepoDetailView.BRANCHES,
+            repo: '4321' as RepoName,
+            offset: '42',
+            filter: 'foo',
+          });
+        });
+
+        test('handleBranchListFilterRoute', () => {
+          const data = {
+            ...createPageContext(),
+            params: {repo: '4321', filter: 'foo'},
+          };
+          assertDataToParams(data, 'handleBranchListFilterRoute', {
+            view: GerritView.REPO,
+            detail: GerritNav.RepoDetailView.BRANCHES,
+            repo: '4321' as RepoName,
+            filter: 'foo',
+          });
+        });
+      });
+
+      suite('tag list routes', () => {
+        test('handleTagListOffsetRoute', () => {
+          const data = {...createPageContext(), params: {0: '4321'}};
+          assertDataToParams(data, 'handleTagListOffsetRoute', {
+            view: GerritView.REPO,
+            detail: GerritNav.RepoDetailView.TAGS,
+            repo: '4321' as RepoName,
+            offset: 0,
+            filter: null,
+          });
+        });
+
+        test('handleTagListFilterOffsetRoute', () => {
+          const data = {
+            ...createPageContext(),
+            params: {repo: '4321', filter: 'foo', offset: '42'},
+          };
+          assertDataToParams(data, 'handleTagListFilterOffsetRoute', {
+            view: GerritView.REPO,
+            detail: GerritNav.RepoDetailView.TAGS,
+            repo: '4321' as RepoName,
+            offset: '42',
+            filter: 'foo',
+          });
+        });
+
+        test('handleTagListFilterRoute', () => {
+          const data: PageContextWithQueryMap = {
+            ...createPageContext(),
+            params: {repo: '4321'},
+          };
+          assertDataToParams(data, 'handleTagListFilterRoute', {
+            view: GerritView.REPO,
+            detail: GerritNav.RepoDetailView.TAGS,
+            repo: '4321' as RepoName,
+            filter: null,
+          });
+
+          data.params.filter = 'foo';
+          assertDataToParams(data, 'handleTagListFilterRoute', {
+            view: GerritView.REPO,
+            detail: GerritNav.RepoDetailView.TAGS,
+            repo: '4321' as RepoName,
+            filter: 'foo',
+          });
+        });
+      });
+
+      suite('repo list routes', () => {
+        test('handleRepoListOffsetRoute', () => {
+          const data = createPageContext();
+          assertDataToParams(data, 'handleRepoListOffsetRoute', {
+            view: GerritView.ADMIN,
+            adminView: 'gr-repo-list',
+            offset: 0,
+            filter: null,
+            openCreateModal: false,
+          });
+
+          data.params[1] = '42';
+          assertDataToParams(data, 'handleRepoListOffsetRoute', {
+            view: GerritView.ADMIN,
+            adminView: 'gr-repo-list',
+            offset: '42',
+            filter: null,
+            openCreateModal: false,
+          });
+
+          data.hash = 'create';
+          assertDataToParams(data, 'handleRepoListOffsetRoute', {
+            view: GerritView.ADMIN,
+            adminView: 'gr-repo-list',
+            offset: '42',
+            filter: null,
+            openCreateModal: true,
+          });
+        });
+
+        test('handleRepoListFilterOffsetRoute', () => {
+          const data = {
+            ...createPageContext(),
+            params: {filter: 'foo', offset: '42'},
+          };
+          assertDataToParams(data, 'handleRepoListFilterOffsetRoute', {
+            view: GerritView.ADMIN,
+            adminView: 'gr-repo-list',
+            offset: '42',
+            filter: 'foo',
+          });
+        });
+
+        test('handleRepoListFilterRoute', () => {
+          const data = createPageContext();
+          assertDataToParams(data, 'handleRepoListFilterRoute', {
+            view: GerritView.ADMIN,
+            adminView: 'gr-repo-list',
+            filter: null,
+          });
+
+          data.params.filter = 'foo';
+          assertDataToParams(data, 'handleRepoListFilterRoute', {
+            view: GerritView.ADMIN,
+            adminView: 'gr-repo-list',
+            filter: 'foo',
+          });
+        });
+      });
+    });
+
+    suite('plugin routes', () => {
+      test('handlePluginListOffsetRoute', () => {
+        const data = createPageContext();
+        assertDataToParams(data, 'handlePluginListOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: 'gr-plugin-list',
+          offset: 0,
+          filter: null,
+        });
+
+        data.params[1] = '42';
+        assertDataToParams(data, 'handlePluginListOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: 'gr-plugin-list',
+          offset: '42',
+          filter: null,
+        });
+      });
+
+      test('handlePluginListFilterOffsetRoute', () => {
+        const data = {
+          ...createPageContext(),
+          params: {filter: 'foo', offset: '42'},
+        };
+        assertDataToParams(data, 'handlePluginListFilterOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: 'gr-plugin-list',
+          offset: '42',
+          filter: 'foo',
+        });
+      });
+
+      test('handlePluginListFilterRoute', () => {
+        const data = createPageContext();
+        assertDataToParams(data, 'handlePluginListFilterRoute', {
+          view: GerritView.ADMIN,
+          adminView: 'gr-plugin-list',
+          filter: null,
+        });
+
+        data.params.filter = 'foo';
+        assertDataToParams(data, 'handlePluginListFilterRoute', {
+          view: GerritView.ADMIN,
+          adminView: 'gr-plugin-list',
+          filter: 'foo',
+        });
+      });
+
+      test('handlePluginListRoute', () => {
+        const data = createPageContext();
+        assertDataToParams(data, 'handlePluginListRoute', {
+          view: GerritView.ADMIN,
+          adminView: 'gr-plugin-list',
+        });
+      });
+    });
+
+    suite('change/diff routes', () => {
+      test('handleChangeNumberLegacyRoute', () => {
+        const data = {...createPageContext(), params: {0: '12345'}};
+        router.handleChangeNumberLegacyRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
+      });
+
+      test('handleChangeLegacyRoute', async () => {
+        stubRestApi('getFromProjectLookup').returns(
+          Promise.resolve('project' as RepoName)
+        );
+        const ctx = {
+          ...createPageContext(),
+          params: {0: '1234', 1: 'comment/6789'},
+        };
+        router.handleChangeLegacyRoute(ctx);
+        await flush();
+        assert.isTrue(
+          redirectStub.calledWithExactly('/c/project/+/1234' + '/comment/6789')
+        );
+      });
+
+      test('handleLegacyLinenum w/ @321', () => {
+        const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@321'};
+        router.handleLegacyLinenum(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(
+          redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#321')
+        );
+      });
+
+      test('handleLegacyLinenum w/ @b123', () => {
+        const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@b123'};
+        router.handleLegacyLinenum(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(
+          redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#b123')
+        );
+      });
+
+      suite('handleChangeRoute', () => {
+        let normalizeRangeStub: sinon.SinonStub;
+
+        function makeParams(
+          _path: string,
+          _hash: string
+        ): PageContextWithQueryMap {
+          return {
+            ...createPageContext(),
+            params: {
+              0: 'foo/bar', // 0 Project
+              1: '1234', // 1 Change number
+              2: '', // 2 Unused
+              3: '', // 3 Unused
+              4: '4', // 4 Base patch number
+              5: '', // 5 Unused
+              6: '7', // 6 Patch number
+            },
+          };
+        }
+
+        setup(() => {
+          normalizeRangeStub = sinon.stub(router, 'normalizePatchRangeParams');
+          stubRestApi('setInProjectLookup');
+        });
+
+        test('needs redirect', () => {
+          normalizeRangeStub.returns(true);
+          sinon.stub(router, 'generateUrl').returns('foo');
+          const ctx = makeParams('', '');
+          router.handleChangeRoute(ctx);
+          assert.isTrue(normalizeRangeStub.called);
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isTrue(redirectStub.calledWithExactly('foo'));
+        });
+
+        test('change view', () => {
+          normalizeRangeStub.returns(false);
+          sinon.stub(router, 'generateUrl').returns('foo');
+          const ctx = makeParams('', '');
+          assertDataToParams(ctx, 'handleChangeRoute', {
+            view: GerritView.CHANGE,
+            project: 'foo/bar' as RepoName,
+            changeNum: 1234 as NumericChangeId,
+            basePatchNum: 4 as BasePatchSetNum,
+            patchNum: 7 as RevisionPatchSetNum,
+          });
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(normalizeRangeStub.called);
+        });
+
+        test('params', () => {
+          normalizeRangeStub.returns(false);
+          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', {
+            view: GerritView.CHANGE,
+            project: 'foo/bar' as RepoName,
+            changeNum: 1234 as NumericChangeId,
+            basePatchNum: 4 as BasePatchSetNum,
+            patchNum: 7 as RevisionPatchSetNum,
+            attempt: 1,
+            filter: 'fff',
+            select: 'sss',
+            tab: 'checks',
+          });
+        });
+      });
+
+      suite('handleDiffRoute', () => {
+        let normalizeRangeStub: sinon.SinonStub;
+
+        function makeParams(
+          path: string,
+          hash: string
+        ): PageContextWithQueryMap {
+          return {
+            ...createPageContext(),
+            hash,
+            params: {
+              0: 'foo/bar', // 0 Project
+              1: '1234', // 1 Change number
+              2: '', // 2 Unused
+              3: '', // 3 Unused
+              4: '4', // 4 Base patch number
+              5: '', // 5 Unused
+              6: '7', // 6 Patch number
+              7: '', // 7 Unused,
+              8: path, // 8 Diff path
+            },
+          };
+        }
+
+        setup(() => {
+          normalizeRangeStub = sinon.stub(router, 'normalizePatchRangeParams');
+          stubRestApi('setInProjectLookup');
+        });
+
+        test('needs redirect', () => {
+          normalizeRangeStub.returns(true);
+          sinon.stub(router, 'generateUrl').returns('foo');
+          const ctx = makeParams('', '');
+          router.handleDiffRoute(ctx);
+          assert.isTrue(normalizeRangeStub.called);
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isTrue(redirectStub.calledWithExactly('foo'));
+        });
+
+        test('diff view', () => {
+          normalizeRangeStub.returns(false);
+          sinon.stub(router, 'generateUrl').returns('foo');
+          const ctx = makeParams('foo/bar/baz', 'b44');
+          assertDataToParams(ctx, 'handleDiffRoute', {
+            view: GerritView.DIFF,
+            project: 'foo/bar' as RepoName,
+            changeNum: 1234 as NumericChangeId,
+            basePatchNum: 4 as BasePatchSetNum,
+            patchNum: 7 as RevisionPatchSetNum,
+            path: 'foo/bar/baz',
+            leftSide: true,
+            lineNum: 44,
+          });
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(normalizeRangeStub.called);
+        });
+
+        test('comment route', () => {
+          const url = '/c/gerrit/+/264833/comment/00049681_f34fd6a9/';
+          const groups = url.match(_testOnly_RoutePattern.COMMENT);
+          assert.deepEqual(groups!.slice(1), [
+            'gerrit', // project
+            '264833', // changeNum
+            '00049681_f34fd6a9', // commentId
+          ]);
+          assertDataToParams(
+            {params: groups!.slice(1)} as any,
+            'handleCommentRoute',
+            {
+              project: 'gerrit' as RepoName,
+              changeNum: 264833 as NumericChangeId,
+              commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
+              commentLink: true,
+              view: GerritView.DIFF,
+            }
+          );
+        });
+
+        test('comments route', () => {
+          const url = '/c/gerrit/+/264833/comments/00049681_f34fd6a9/';
+          const groups = url.match(_testOnly_RoutePattern.COMMENTS_TAB);
+          assert.deepEqual(groups!.slice(1), [
+            'gerrit', // project
+            '264833', // changeNum
+            '00049681_f34fd6a9', // commentId
+          ]);
+          assertDataToParams(
+            {params: groups!.slice(1)} as any,
+            'handleCommentsRoute',
+            {
+              project: 'gerrit' as RepoName,
+              changeNum: 264833 as NumericChangeId,
+              commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
+              view: GerritView.CHANGE,
+            }
+          );
+        });
+      });
+
+      test('handleDiffEditRoute', () => {
+        const normalizeRangeSpy = sinon.spy(
+          router,
+          'normalizePatchRangeParams'
+        );
+        stubRestApi('setInProjectLookup');
+        const ctx = {
+          ...createPageContext(),
+          hash: '',
+          params: {
+            0: 'foo/bar', // 0 Project
+            1: '1234', // 1 Change number
+            2: '3', // 2 Patch num
+            3: 'foo/bar/baz', // 3 File path
+          },
+        };
+        const appParams: GenerateUrlEditViewParameters = {
+          project: 'foo/bar' as RepoName,
+          changeNum: 1234 as NumericChangeId,
+          view: GerritNav.View.EDIT,
+          path: 'foo/bar/baz',
+          patchNum: 3 as PatchSetNum,
+          lineNum: '',
+        };
+
+        router.handleDiffEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRangeSpy.calledOnce);
+        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
+        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
+        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+      });
+
+      test('handleDiffEditRoute with lineNum', () => {
+        const normalizeRangeSpy = sinon.spy(
+          router,
+          'normalizePatchRangeParams'
+        );
+        stubRestApi('setInProjectLookup');
+        const ctx = {
+          ...createPageContext(),
+          hash: '4',
+          params: {
+            0: 'foo/bar', // 0 Project
+            1: '1234', // 1 Change number
+            2: '3', // 2 Patch num
+            3: 'foo/bar/baz', // 3 File path
+          },
+        };
+        const appParams: GenerateUrlEditViewParameters = {
+          project: 'foo/bar' as RepoName,
+          changeNum: 1234 as NumericChangeId,
+          view: GerritNav.View.EDIT,
+          path: 'foo/bar/baz',
+          patchNum: 3 as PatchSetNum,
+          lineNum: '4',
+        };
+
+        router.handleDiffEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRangeSpy.calledOnce);
+        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
+        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
+        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+      });
+
+      test('handleChangeEditRoute', () => {
+        const normalizeRangeSpy = sinon.spy(
+          router,
+          'normalizePatchRangeParams'
+        );
+        stubRestApi('setInProjectLookup');
+        const ctx = {
+          ...createPageContext(),
+          params: {
+            0: 'foo/bar', // 0 Project
+            1: '1234', // 1 Change number
+            2: '',
+            3: '3', // 3 Patch num
+          },
+        };
+        const appParams: GenerateUrlChangeViewParameters = {
+          project: 'foo/bar' as RepoName,
+          changeNum: 1234 as NumericChangeId,
+          view: GerritView.CHANGE,
+          patchNum: 3 as PatchSetNum,
+          edit: true,
+          tab: '',
+        };
+
+        router.handleChangeEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRangeSpy.calledOnce);
+        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
+        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
+        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+      });
+    });
+
+    test('handlePluginScreen', () => {
+      const ctx = {...createPageContext(), params: {0: 'foo', 1: 'bar'}};
+      assertDataToParams(ctx, 'handlePluginScreen', {
+        view: GerritNav.View.PLUGIN_SCREEN,
+        plugin: 'foo',
+        screen: 'bar',
+      });
+      assert.isFalse(redirectStub.called);
+    });
+  });
+
+  suite('parseQueryString', () => {
+    test('empty queries', () => {
+      assert.deepEqual(router.parseQueryString(''), []);
+      assert.deepEqual(router.parseQueryString('?'), []);
+      assert.deepEqual(router.parseQueryString('??'), []);
+      assert.deepEqual(router.parseQueryString('&&&'), []);
+    });
+
+    test('url decoding', () => {
+      assert.deepEqual(router.parseQueryString('+'), [[' ', '']]);
+      assert.deepEqual(router.parseQueryString('???+%3d+'), [[' = ', '']]);
+      assert.deepEqual(
+        router.parseQueryString('%6e%61%6d%65=%76%61%6c%75%65'),
+        [['name', 'value']]
+      );
+    });
+
+    test('multiple parameters', () => {
+      assert.deepEqual(router.parseQueryString('a=b&c=d&e=f'), [
+        ['a', 'b'],
+        ['c', 'd'],
+        ['e', 'f'],
+      ]);
+      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.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 2901b8a..7bbb93b 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -15,16 +15,6 @@
  * limitations under the License.
  */
 import '../../shared/gr-autocomplete/gr-autocomplete';
-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-search-bar_html';
-import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {customElement, property} from '@polymer/decorators';
 import {ServerInfo} from '../../../types/common';
 import {
   AutocompleteQuery,
@@ -33,8 +23,19 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getDocsBaseUrl} from '../../../utils/url-util';
 import {MergeabilityComputationBehavior} from '../../../constants/constants';
-import {appContext} from '../../../services/app-context';
-import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {getAppContext} from '../../../services/app-context';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {
+  customElement,
+  property,
+  state,
+  query as queryDec,
+} from 'lit/decorators';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {query as queryUtil} from '../../../utils/common-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -42,7 +43,6 @@
   'after:',
   'age:',
   'age:1week', // Give an example age
-  'assignee:',
   'attention:',
   'author:',
   'before:',
@@ -76,16 +76,15 @@
   'intopic:',
   'is:',
   'is:abandoned',
-  'is:assigned',
   'is:attention',
   'is:cherrypick',
   'is:closed',
-  'is:ignored',
   'is:merge',
   'is:merged',
   'is:open',
   'is:owner',
   'is:private',
+  'is:pure-revert',
   'is:reviewed',
   'is:reviewer',
   'is:starred',
@@ -143,34 +142,18 @@
   inputVal: string;
 }
 
-export interface GrSearchBar {
-  $: {
-    searchInput: GrAutocomplete;
-  };
-}
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-search-bar')
-export class GrSearchBar extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  private searchOperators = new Set(SEARCH_OPERATORS_WITH_NEGATIONS_SET);
-
+export class GrSearchBar extends LitElement {
   /**
    * Fired when a search is committed
    *
    * @event handle-search
    */
 
-  @property({type: String, notify: true, observer: '_valueChanged'})
-  value = '';
+  @queryDec('#searchInput') protected searchInput?: GrAutocomplete;
 
-  @property({type: Object})
-  query: AutocompleteQuery;
+  @property({type: String})
+  value = '';
 
   @property({type: Object})
   projectSuggestions: SuggestionProvider = () => Promise.resolve([]);
@@ -181,76 +164,140 @@
   @property({type: Object})
   accountSuggestions: SuggestionProvider = () => Promise.resolve([]);
 
-  @property({type: String})
-  _inputVal = '';
-
-  @property({type: Number})
-  _threshold = 1;
+  @property({type: Object})
+  serverConfig?: ServerInfo;
 
   @property({type: String})
   label = '';
 
-  @property({type: String})
-  docBaseUrl: string | null = null;
+  // private but used in test
+  @state() inputVal = '';
 
-  private readonly restApiService = appContext.restApiService;
+  // private but used in test
+  @state() docBaseUrl: string | null = null;
+
+  @state() private query: AutocompleteQuery;
+
+  @state() private threshold = 1;
+
+  private searchOperators = new Set(SEARCH_OPERATORS_WITH_NEGATIONS_SET);
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly shortcuts = new ShortcutController(this);
 
   constructor() {
     super();
-    this.query = (input: string) => this._getSearchSuggestions(input);
+    this.query = (input: string) => this.getSearchSuggestions(input);
+    this.shortcuts.addAbstract(Shortcut.SEARCH, () => this.handleSearch());
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getConfig().then((serverConfig?: ServerInfo) => {
-      const mergeability =
-        serverConfig &&
-        serverConfig.change &&
-        serverConfig.change.mergeability_computation_behavior;
-      if (
-        mergeability ===
-          MergeabilityComputationBehavior.API_REF_UPDATED_AND_CHANGE_REINDEX ||
-        mergeability ===
-          MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX
-      ) {
-        // add 'is:mergeable' to searchOperators
-        this._addOperator('is:mergeable');
-      }
-      if (serverConfig) {
-        getDocsBaseUrl(serverConfig, this.restApiService).then(baseUrl => {
-          this.docBaseUrl = baseUrl;
-        });
-      }
-    });
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        form {
+          display: flex;
+        }
+        gr-autocomplete {
+          background-color: var(--view-background-color);
+          border-radius: var(--border-radius);
+          flex: 1;
+          outline: none;
+        }
+      `,
+    ];
   }
 
-  _computeHelpDocLink(docBaseUrl: string | null) {
+  override render() {
+    return html`
+      <form>
+        <gr-autocomplete
+          id="searchInput"
+          .label=${this.label}
+          show-search-icon
+          .text=${this.inputVal}
+          .query=${this.query}
+          allow-non-suggested-values
+          multi
+          .threshold=${this.threshold}
+          tab-complete
+          .verticalOffset=${30}
+          @commit=${(e: Event) => {
+            this.handleInputCommit(e);
+          }}
+          @text-changed=${(e: CustomEvent) => {
+            this.handleSearchTextChanged(e);
+          }}
+        >
+          <a
+            class="help"
+            slot="suffix"
+            href=${this.computeHelpDocLink()}
+            target="_blank"
+            tabindex="-1"
+          >
+            <iron-icon
+              icon="gr-icons:help-outline"
+              title="read documentation"
+            ></iron-icon>
+          </a>
+        </gr-autocomplete>
+      </form>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('serverConfig')) {
+      this.serverConfigChanged();
+    }
+
+    if (changedProperties.has('value')) {
+      this.valueChanged();
+    }
+  }
+
+  private serverConfigChanged() {
+    const mergeability =
+      this.serverConfig?.change?.mergeability_computation_behavior;
+    if (
+      mergeability ===
+        MergeabilityComputationBehavior.API_REF_UPDATED_AND_CHANGE_REINDEX ||
+      mergeability ===
+        MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX
+    ) {
+      // add 'is:mergeable' to searchOperators
+      this.searchOperators.add('is:mergeable');
+      this.searchOperators.add('-is:mergeable');
+    } else {
+      this.searchOperators.delete('is:mergeable');
+      this.searchOperators.delete('-is:mergeable');
+    }
+    if (this.serverConfig) {
+      getDocsBaseUrl(this.serverConfig, this.restApiService).then(baseUrl => {
+        this.docBaseUrl = baseUrl;
+      });
+    }
+  }
+
+  private valueChanged() {
+    this.inputVal = this.value;
+  }
+
+  // private but used in test
+  computeHelpDocLink() {
     // fallback to gerrit's official doc
     let baseUrl =
-      docBaseUrl || 'https://gerrit-review.googlesource.com/documentation/';
+      this.docBaseUrl ||
+      'https://gerrit-review.googlesource.com/documentation/';
     if (baseUrl.endsWith('/')) {
       baseUrl = baseUrl.substring(0, baseUrl.length - 1);
     }
     return `${baseUrl}/user-search.html`;
   }
 
-  _addOperator(name: string, include_neg = true) {
-    this.searchOperators.add(name);
-    if (include_neg) {
-      this.searchOperators.add(`-${name}`);
-    }
-  }
-
-  override keyboardShortcuts(): ShortcutListener[] {
-    return [listen(Shortcut.SEARCH, _ => this._handleSearch())];
-  }
-
-  _valueChanged(value: string) {
-    this._inputVal = value;
-  }
-
-  _handleInputCommit(e: Event) {
-    this._preventDefaultAndNavigateToInputVal(e);
+  private handleInputCommit(e: Event) {
+    this.preventDefaultAndNavigateToInputVal(e);
   }
 
   /**
@@ -259,18 +306,18 @@
    * - e.target is the gr-autocomplete widget (#searchInput)
    * - e.target is the input element wrapped within #searchInput
    */
-  _preventDefaultAndNavigateToInputVal(e: Event) {
+  private preventDefaultAndNavigateToInputVal(e: Event) {
     e.preventDefault();
-    const target = (dom(e) as EventApi).rootTarget as PolymerElement;
+    const target = e.composedPath()[0] as HTMLElement;
     // If the target is the #searchInput or has a sub-input component, that
     // is what holds the focus as opposed to the target from the DOM event.
-    if (target.$['input']) {
-      (target.$['input'] as HTMLElement).blur();
+    if (queryUtil(target, '#input')) {
+      queryUtil<HTMLElement>(target, '#input')!.blur();
     } else {
       target.blur();
     }
-    if (!this._inputVal) return;
-    const trimmedInput = this._inputVal.trim();
+    if (!this.inputVal) return;
+    const trimmedInput = this.inputVal.trim();
     if (trimmedInput) {
       const predefinedOpOnlyQuery = [...this.searchOperators].some(
         op => op.endsWith(':') && op === trimmedInput
@@ -279,7 +326,7 @@
         return;
       }
       const detail: SearchBarHandleSearchDetail = {
-        inputVal: this._inputVal,
+        inputVal: this.inputVal,
       };
       this.dispatchEvent(
         new CustomEvent('handle-search', {
@@ -291,13 +338,13 @@
 
   /**
    * Determine what array of possible suggestions should be provided
-   * to _getSearchSuggestions.
+   * to getSearchSuggestions.
    *
    * @param input - The full search term, in lowercase.
    * @return This returns a promise that resolves to an array of
    * suggestion objects.
    */
-  _fetchSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+  private fetchSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
     // Split the input on colon to get a two part predicate/expression.
     const splitInput = input.split(':');
     const predicate = splitInput[0];
@@ -315,7 +362,6 @@
         // Fetch projects.
         return this.projectSuggestions(predicate, expression);
 
-      case 'assignee':
       case 'attention':
       case 'author':
       case 'cc':
@@ -345,14 +391,16 @@
    * @param input - The complete search query.
    * @return This returns a promise that resolves to an array of
    * suggestions.
+   *
+   * private but used in test
    */
-  _getSearchSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+  getSearchSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
     // Allow spaces within quoted terms.
     const tokens = input.match(TOKENIZE_REGEX);
     if (tokens === null) return Promise.resolve([]);
     const trimmedInput = tokens[tokens.length - 1].toLowerCase();
 
-    return this._fetchSuggestions(trimmedInput).then(suggestions => {
+    return this.fetchSuggestions(trimmedInput).then(suggestions => {
       if (!suggestions || !suggestions.length) {
         return [];
       }
@@ -390,9 +438,14 @@
     });
   }
 
-  _handleSearch() {
-    this.$.searchInput.focus();
-    this.$.searchInput.selectAll();
+  private handleSearch() {
+    assertIsDefined(this.searchInput, 'searchInput');
+    this.searchInput.focus();
+    this.searchInput.selectAll();
+  }
+
+  private handleSearchTextChanged(e: CustomEvent) {
+    this.inputVal = e.detail.value;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
deleted file mode 100644
index a0de7f2..0000000
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
+++ /dev/null
@@ -1,59 +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">
-    form {
-      display: flex;
-    }
-    gr-autocomplete {
-      background-color: var(--view-background-color);
-      border-radius: var(--border-radius);
-      flex: 1;
-      outline: none;
-    }
-  </style>
-  <form>
-    <gr-autocomplete
-      label="[[label]]"
-      show-search-icon=""
-      id="searchInput"
-      text="{{_inputVal}}"
-      query="[[query]]"
-      on-commit="_handleInputCommit"
-      allow-non-suggested-values=""
-      multi=""
-      threshold="[[_threshold]]"
-      tab-complete=""
-      vertical-offset="30"
-    >
-      <a
-        slot="suffix"
-        href$="[[_computeHelpDocLink(docBaseUrl)]]"
-        target="_blank"
-        class="help"
-        tabindex="-1"
-      >
-        <iron-icon
-          icon="gr-icons:help-outline"
-          title="read documentation"
-        ></iron-icon>
-      </a>
-    </gr-autocomplete>
-  </form>
-`;
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 b6d0579..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
@@ -17,9 +17,9 @@
 
 import '../../../test/common-test-setup-karma';
 import './gr-search-bar';
-import '../../../scripts/util';
 import {GrSearchBar} from './gr-search-bar';
-import {stubRestApi, mockPromise} from '../../../test/test-utils';
+import '../../../scripts/util';
+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 {
@@ -28,6 +28,9 @@
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {MergeabilityComputationBehavior} from '../../../constants/constants';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
 
 const basicFixture = fixtureFromElement('gr-search-bar');
 
@@ -36,12 +39,13 @@
 
   setup(async () => {
     element = basicFixture.instantiate();
-    await flush();
+    await element.updateComplete;
   });
 
-  test('value is propagated to _inputVal', () => {
+  test('value is propagated to inputVal', async () => {
     element.value = 'foo';
-    assert.equal(element._inputVal, 'foo');
+    await element.updateComplete;
+    assert.equal(element.inputVal, 'foo');
   });
 
   const getActiveElement = () =>
@@ -52,12 +56,17 @@
   test('enter in search input fires event', async () => {
     const promise = mockPromise();
     element.addEventListener('handle-search', () => {
-      assert.notEqual(getActiveElement(), element.$.searchInput);
+      assert.notEqual(
+        getActiveElement(),
+        queryAndAssert<GrAutocomplete>(element, '#searchInput')
+      );
       promise.resolve();
     });
     element.value = 'test';
+    await element.updateComplete;
+    const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
     MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
+      queryAndAssert<HTMLInputElement>(searchInput, '#input'),
       13,
       null,
       'enter'
@@ -65,24 +74,36 @@
     await promise;
   });
 
-  test('input blurred after commit', () => {
-    const blurSpy = sinon.spy(element.$.searchInput.$.input, 'blur');
-    element.$.searchInput.text = 'fate/stay';
+  test('input blurred after commit', async () => {
+    const blurSpy = sinon.spy(
+      queryAndAssert<PaperInputElement>(
+        queryAndAssert<GrAutocomplete>(element, '#searchInput'),
+        '#input'
+      ),
+      'blur'
+    );
+    queryAndAssert<GrAutocomplete>(element, '#searchInput').text = 'fate/stay';
+    await element.updateComplete;
     MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
+      queryAndAssert<PaperInputElement>(
+        queryAndAssert<GrAutocomplete>(element, '#searchInput'),
+        '#input'
+      ),
       13,
       null,
       'enter'
     );
-    assert.isTrue(blurSpy.called);
+    await waitUntil(() => blurSpy.called);
   });
 
-  test('empty search query does not trigger nav', () => {
+  test('empty search query does not trigger nav', async () => {
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = '';
+    await element.updateComplete;
+    const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
     MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
+      queryAndAssert<HTMLInputElement>(searchInput, '#input'),
       13,
       null,
       'enter'
@@ -90,12 +111,14 @@
     assert.isFalse(searchSpy.called);
   });
 
-  test('Predefined query op with no predication doesnt trigger nav', () => {
+  test('Predefined query op with no predication doesnt trigger nav', async () => {
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = 'added:';
+    await element.updateComplete;
+    const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
     MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
+      queryAndAssert<HTMLInputElement>(searchInput, '#input'),
       13,
       null,
       'enter'
@@ -103,97 +126,112 @@
     assert.isFalse(searchSpy.called);
   });
 
-  test('predefined predicate query triggers nav', () => {
+  test('predefined predicate query triggers nav', async () => {
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = 'age:1week';
+    await element.updateComplete;
+    const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
     MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
+      queryAndAssert<HTMLInputElement>(searchInput, '#input'),
       13,
       null,
       'enter'
     );
-    assert.isTrue(searchSpy.called);
+    await waitUntil(() => searchSpy.called);
   });
 
-  test('undefined predicate query triggers nav', () => {
+  test('undefined predicate query triggers nav', async () => {
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = 'random:1week';
+    await element.updateComplete;
+    const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
     MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
+      queryAndAssert<HTMLInputElement>(searchInput, '#input'),
       13,
       null,
       'enter'
     );
-    assert.isTrue(searchSpy.called);
+    await waitUntil(() => searchSpy.called);
   });
 
-  test('empty undefined predicate query triggers nav', () => {
+  test('empty undefined predicate query triggers nav', async () => {
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = 'random:';
+    await element.updateComplete;
+    const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
     MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
+      queryAndAssert<HTMLInputElement>(searchInput, '#input'),
       13,
       null,
       'enter'
     );
-    assert.isTrue(searchSpy.called);
+    await waitUntil(() => searchSpy.called);
   });
 
-  test('keyboard shortcuts', () => {
-    const focusSpy = sinon.spy(element.$.searchInput, 'focus');
-    const selectAllSpy = sinon.spy(element.$.searchInput, 'selectAll');
+  test('keyboard shortcuts', async () => {
+    const focusSpy = sinon.spy(
+      queryAndAssert<GrAutocomplete>(element, '#searchInput'),
+      'focus'
+    );
+    const selectAllSpy = sinon.spy(
+      queryAndAssert<GrAutocomplete>(element, '#searchInput'),
+      'selectAll'
+    );
     MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
     assert.isTrue(focusSpy.called);
     assert.isTrue(selectAllSpy.called);
   });
 
-  suite('_getSearchSuggestions', () => {
-    setup(() => {
-      // Ensure that config.change.mergeability_computation_behavior is not set.
+  suite('getSearchSuggestions', () => {
+    setup(async () => {
       element = basicFixture.instantiate();
+      element.serverConfig = {
+        ...createServerInfo(),
+        change: {
+          ...createChangeConfig(),
+          mergeability_computation_behavior:
+            'NEVER' as MergeabilityComputationBehavior,
+        },
+      };
+      await element.updateComplete;
     });
 
-    test('Autocompletes accounts', () => {
-      sinon
-        .stub(element, 'accountSuggestions')
-        .callsFake(() => Promise.resolve([{text: 'owner:fred@goog.co'}]));
-      return element._getSearchSuggestions('owner:fr').then(s => {
-        assert.equal(s[0].value, 'owner:fred@goog.co');
-      });
+    test('Autocompletes accounts', async () => {
+      element.accountSuggestions = () =>
+        Promise.resolve([{text: 'owner:fred@goog.co'}]);
+      await element.updateComplete;
+      const s = await element.getSearchSuggestions('owner:fr');
+      assert.equal(s[0].value, 'owner:fred@goog.co');
     });
 
     test('Autocompletes groups', async () => {
-      sinon
-        .stub(element, 'groupSuggestions')
-        .callsFake(() =>
-          Promise.resolve([
-            {text: 'ownerin:Polygerrit'},
-            {text: 'ownerin:gerrit'},
-          ])
-        );
-      const s = await element._getSearchSuggestions('ownerin:pol');
+      element.groupSuggestions = () =>
+        Promise.resolve([
+          {text: 'ownerin:Polygerrit'},
+          {text: 'ownerin:gerrit'},
+        ]);
+      await element.updateComplete;
+      const s = await element.getSearchSuggestions('ownerin:pol');
       assert.equal(s[0].value, 'ownerin:Polygerrit');
     });
 
     test('Autocompletes projects', async () => {
-      sinon
-        .stub(element, 'projectSuggestions')
-        .callsFake(() =>
-          Promise.resolve([
-            {text: 'project:Polygerrit'},
-            {text: 'project:gerrit'},
-            {text: 'project:gerrittest'},
-          ])
-        );
-      const s = await element._getSearchSuggestions('project:pol');
+      element.projectSuggestions = () =>
+        Promise.resolve([
+          {text: 'project:Polygerrit'},
+          {text: 'project:gerrit'},
+          {text: 'project:gerrittest'},
+        ]);
+      await element.updateComplete;
+      const s = await element.getSearchSuggestions('project:pol');
       assert.equal(s[0].value, 'project:Polygerrit');
     });
 
     test('Autocompletes simple searches', async () => {
-      const s = await element._getSearchSuggestions('is:o');
+      const s = await element.getSearchSuggestions('is:o');
       assert.equal(s[0].name, 'is:open');
       assert.equal(s[0].value, 'is:open');
       assert.equal(s[1].name, 'is:owner');
@@ -201,12 +239,12 @@
     });
 
     test('Does not autocomplete with no match', async () => {
-      const s = await element._getSearchSuggestions('asdasdasdasd');
+      const s = await element.getSearchSuggestions('asdasdasdasd');
       assert.equal(s.length, 0);
     });
 
     test('Autocompletes without is:mergable when disabled', async () => {
-      const s = await element._getSearchSuggestions('is:mergeab');
+      const s = await element.getSearchSuggestions('is:mergeab');
       assert.isEmpty(s);
     });
   });
@@ -217,23 +255,20 @@
   ].forEach(mergeability => {
     suite(`mergeability as ${mergeability}`, () => {
       setup(async () => {
-        stubRestApi('getConfig').returns(
-          Promise.resolve({
-            ...createServerInfo(),
-            change: {
-              ...createChangeConfig(),
-              mergeability_computation_behavior:
-                mergeability as MergeabilityComputationBehavior,
-            },
-          })
-        );
-
         element = basicFixture.instantiate();
-        await flush();
+        element.serverConfig = {
+          ...createServerInfo(),
+          change: {
+            ...createChangeConfig(),
+            mergeability_computation_behavior:
+              mergeability as MergeabilityComputationBehavior,
+          },
+        };
+        await element.updateComplete;
       });
 
       test('Autocompltes with is:mergable when enabled', async () => {
-        const s = await element._getSearchSuggestions('is:mergeab');
+        const s = await element.getSearchSuggestions('is:mergeab');
         assert.equal(s.length, 2);
         assert.equal(s[0].name, 'is:mergeable');
         assert.equal(s[0].value, 'is:mergeable');
@@ -245,32 +280,30 @@
 
   suite('doc url', () => {
     setup(async () => {
-      stubRestApi('getConfig').returns(
-        Promise.resolve({
-          ...createServerInfo(),
-          gerrit: {
-            ...createGerritInfo(),
-            doc_url: 'https://doc.com/',
-          },
-        })
-      );
-
       _testOnly_clearDocsBaseUrlCache();
       element = basicFixture.instantiate();
-      await flush();
+      element.serverConfig = {
+        ...createServerInfo(),
+        gerrit: {
+          ...createGerritInfo(),
+          doc_url: 'https://doc.com/',
+        },
+      };
+      await element.updateComplete;
     });
 
     test('compute help doc url with correct path', () => {
       assert.equal(element.docBaseUrl, 'https://doc.com/');
       assert.equal(
-        element._computeHelpDocLink(element.docBaseUrl),
+        element.computeHelpDocLink(),
         'https://doc.com/user-search.html'
       );
     });
 
     test('compute help doc url fallback to gerrit url', () => {
+      element.docBaseUrl = null;
       assert.equal(
-        element._computeHelpDocLink(null),
+        element.computeHelpDocLink(),
         'https://gerrit-review.googlesource.com/documentation/' +
           'user-search.html'
       );
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
index f5a28f8..ed6b822 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -15,18 +15,17 @@
  * limitations under the License.
  */
 import '../gr-search-bar/gr-search-bar';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-smart-search_html';
 import {GerritNav} from '../gr-navigation/gr-navigation';
 import {getUserName} from '../../../utils/display-name-util';
-import {customElement, property} from '@polymer/decorators';
 import {AccountInfo, ServerInfo} from '../../../types/common';
 import {
   SearchBarHandleSearchDetail,
   SuggestionProvider,
 } from '../gr-search-bar/gr-search-bar';
 import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
+import {LitElement, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
 const SELF_EXPRESSION = 'self';
@@ -36,49 +35,45 @@
   interface HTMLElementEventMap {
     'handle-search': CustomEvent<SearchBarHandleSearchDetail>;
   }
+  interface HTMLElementTagNameMap {
+    'gr-smart-search': GrSmartSearch;
+  }
 }
 
 @customElement('gr-smart-search')
-export class GrSmartSearch extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrSmartSearch extends LitElement {
   @property({type: String})
   searchQuery = '';
 
   @property({type: Object})
-  _config?: ServerInfo;
-
-  @property({type: Object})
-  _projectSuggestions: SuggestionProvider = (predicate, expression) =>
-    this._fetchProjects(predicate, expression);
-
-  @property({type: Object})
-  _groupSuggestions: SuggestionProvider = (predicate, expression) =>
-    this._fetchGroups(predicate, expression);
-
-  @property({type: Object})
-  _accountSuggestions: SuggestionProvider = (predicate, expression) =>
-    this._fetchAccounts(predicate, expression);
+  serverConfig?: ServerInfo;
 
   @property({type: String})
   label = '';
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getConfig().then(cfg => {
-      this._config = cfg;
-    });
-  }
-
-  _handleSearch(e: CustomEvent<SearchBarHandleSearchDetail>) {
-    const input = e.detail.inputVal;
-    if (input) {
-      GerritNav.navigateToSearchQuery(input);
-    }
+  override render() {
+    const accountSuggestions: SuggestionProvider = (predicate, expression) =>
+      this.fetchAccounts(predicate, expression);
+    const groupSuggestions: SuggestionProvider = (predicate, expression) =>
+      this.fetchGroups(predicate, expression);
+    const projectSuggestions: SuggestionProvider = (predicate, expression) =>
+      this.fetchProjects(predicate, expression);
+    return html`
+      <gr-search-bar
+        id="search"
+        .label=${this.label}
+        .value=${this.searchQuery}
+        .projectSuggestions=${projectSuggestions}
+        .groupSuggestions=${groupSuggestions}
+        .accountSuggestions=${accountSuggestions}
+        .serverConfig=${this.serverConfig}
+        @handle-search=${(e: CustomEvent<SearchBarHandleSearchDetail>) => {
+          this.handleSearch(e);
+        }}
+      ></gr-search-bar>
+    `;
   }
 
   /**
@@ -88,8 +83,10 @@
    * 'project'
    * @param expression - The second part of the search term, e.g.
    * 'gerr'
+   *
+   * private but used in test
    */
-  _fetchProjects(
+  fetchProjects(
     predicate: string,
     expression: string
   ): Promise<AutocompleteSuggestion[]> {
@@ -113,8 +110,10 @@
    * 'ownerin'
    * @param expression - The second part of the search term, e.g.
    * 'polyger'
+   *
+   * private but used in test
    */
-  _fetchGroups(
+  fetchGroups(
     predicate: string,
     expression: string
   ): Promise<AutocompleteSuggestion[]> {
@@ -141,8 +140,10 @@
    * 'owner'
    * @param expression - The second part of the search term, e.g.
    * 'kasp'
+   *
+   * private but used in test
    */
-  _fetchAccounts(
+  fetchAccounts(
     predicate: string,
     expression: string
   ): Promise<AutocompleteSuggestion[]> {
@@ -155,7 +156,7 @@
         if (!accounts) {
           return [];
         }
-        return this._mapAccountsHelper(accounts, predicate);
+        return this.mapAccountsHelper(accounts, predicate);
       })
       .then(accounts => {
         // When the expression supplied is a beginning substring of 'self',
@@ -170,12 +171,12 @@
       });
   }
 
-  _mapAccountsHelper(
+  private mapAccountsHelper(
     accounts: AccountInfo[],
     predicate: string
   ): AutocompleteSuggestion[] {
     return accounts.map(account => {
-      const userName = getUserName(this._config, account);
+      const userName = getUserName(this.serverConfig, account);
       return {
         label: account.name || '',
         text: account.email
@@ -184,10 +185,11 @@
       };
     });
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-smart-search': GrSmartSearch;
+  private handleSearch(e: CustomEvent<SearchBarHandleSearchDetail>) {
+    const input = e.detail.inputVal;
+    if (input) {
+      GerritNav.navigateToSearchQuery(input);
+    }
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts
deleted file mode 100644
index c08df15..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts
+++ /dev/null
@@ -1,30 +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"></style>
-  <gr-search-bar
-    id="search"
-    label="[[label]]"
-    value="{{searchQuery}}"
-    on-handle-search="_handleSearch"
-    project-suggestions="[[_projectSuggestions]]"
-    group-suggestions="[[_groupSuggestions]]"
-    account-suggestions="[[_accountSuggestions]]"
-  ></gr-search-bar>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
index 0218a8f..779844e 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
@@ -26,8 +26,9 @@
 suite('gr-smart-search tests', () => {
   let element: GrSmartSearch;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
   test('Autocompletes accounts', () => {
@@ -39,7 +40,7 @@
         },
       ])
     );
-    return element._fetchAccounts('owner', 'fr').then(s => {
+    return element.fetchAccounts('owner', 'fr').then(s => {
       assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
     });
   });
@@ -54,12 +55,12 @@
       ])
     );
     element
-      ._fetchAccounts('owner', 's')
+      .fetchAccounts('owner', 's')
       .then(s => {
         assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
         assert.deepEqual(s[1], {text: 'owner:self'});
       })
-      .then(() => element._fetchAccounts('owner', 'selfs'))
+      .then(() => element.fetchAccounts('owner', 'selfs'))
       .then(s => {
         assert.notEqual(s[0], {text: 'owner:self'});
       });
@@ -75,12 +76,12 @@
       ])
     );
     return element
-      ._fetchAccounts('owner', 'm')
+      .fetchAccounts('owner', 'm')
       .then(s => {
         assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
         assert.deepEqual(s[1], {text: 'owner:me'});
       })
-      .then(() => element._fetchAccounts('owner', 'meme'))
+      .then(() => element.fetchAccounts('owner', 'meme'))
       .then(s => {
         assert.notEqual(s[0], {text: 'owner:me'});
       });
@@ -94,7 +95,7 @@
         gerrittest: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
       })
     );
-    return element._fetchGroups('ownerin', 'pol').then(s => {
+    return element.fetchGroups('ownerin', 'pol').then(s => {
       assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
     });
   });
@@ -103,7 +104,7 @@
     stubRestApi('getSuggestedProjects').callsFake(() =>
       Promise.resolve({Polygerrit: {id: 'test' as UrlEncodedRepoName}})
     );
-    return element._fetchProjects('project', 'pol').then(s => {
+    return element.fetchProjects('project', 'pol').then(s => {
       assert.deepEqual(s[0], {text: 'project:Polygerrit'});
     });
   });
@@ -116,7 +117,7 @@
         gerrittest: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
       })
     );
-    return element._fetchGroups('ownerin', 'gerrit').then(s => {
+    return element.fetchGroups('ownerin', 'gerrit').then(s => {
       assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
       assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
       assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
@@ -127,7 +128,7 @@
     stubRestApi('getSuggestedAccounts').callsFake(() =>
       Promise.resolve([{name: 'fred'}])
     );
-    return element._fetchAccounts('owner', 'fr').then(s => {
+    return element.fetchAccounts('owner', 'fr').then(s => {
       assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
     });
   });
@@ -136,7 +137,7 @@
     stubRestApi('getSuggestedAccounts').callsFake(() =>
       Promise.resolve([{email: 'fred@goog.co' as EmailAddress}])
     );
-    return element._fetchAccounts('owner', 'fr').then(s => {
+    return element.fetchAccounts('owner', 'fr').then(s => {
       assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
     });
   });
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 a6176e1..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 '../gr-diff/gr-diff';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-apply-fix-dialog_html';
+import '../../../embed/diff/gr-diff/gr-diff';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {
   NumericChangeId,
   EditPatchSetNum,
@@ -36,18 +22,14 @@
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {isRobot} from '../../../utils/comment-util';
 import {OpenFixPreviewEvent} from '../../../types/events';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {fireCloseFixPreview, fireEvent} from '../../../utils/event-util';
 import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
-
-export interface GrApplyFixDialog {
-  $: {
-    applyFixOverlay: GrOverlay;
-    nextFix: GrButton;
-  };
-}
+import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
+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 = appContext.restApiService;
+  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,184 +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,
-            EditPatchSetNum,
-            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 b0716dd..0000000
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
+++ /dev/null
@@ -1,95 +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)]]"
-              change-num="[[changeNum]]"
-              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 94d37f5..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';
@@ -24,11 +12,13 @@
   BasePatchSetNum,
   EditPatchSetNum,
   PatchSetNum,
+  RobotCommentInfo,
   RobotId,
   RobotRunId,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
+import {Comment} from '../../../utils/comment-util';
 import {
   createFixSuggestionInfo,
   createParsedChange,
@@ -37,20 +27,18 @@
 } from '../../../test/test-data-generators';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {DiffInfo} from '../../../types/diff';
-import {UIRobot} from '../../../utils/comment-util';
 import {
   CloseFixPreviewEventDetail,
   EventType,
   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;
 
-  const ROBOT_COMMENT_WITH_TWO_FIXES: UIRobot = {
+  const ROBOT_COMMENT_WITH_TWO_FIXES: RobotCommentInfo = {
     id: '1' as UrlEncodedCommentId,
     updated: '2018-02-08 18:49:18.000000000' as Timestamp,
     robot_id: 'robot_1' as RobotId,
@@ -62,7 +50,7 @@
     ],
   };
 
-  const ROBOT_COMMENT_WITH_ONE_FIX: UIRobot = {
+  const ROBOT_COMMENT_WITH_ONE_FIX: RobotCommentInfo = {
     id: '2' as UrlEncodedCommentId,
     updated: '2018-02-08 18:49:18.000000000' as Timestamp,
     robot_id: 'robot_1' as RobotId,
@@ -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,
@@ -277,12 +276,10 @@
       2 as PatchSetNum,
       '123'
     );
-    sinon.assert.calledWithExactly(
-      navigateToChangeStub,
-      element.change!,
-      EditPatchSetNum,
-      element.change!.revisions[2]._number as BasePatchSetNum
-    );
+    sinon.assert.calledWithExactly(navigateToChangeStub, element.change!, {
+      patchNum: EditPatchSetNum,
+      basePatchNum: element.change!.revisions[2]._number as BasePatchSetNum,
+    });
 
     sinon.assert.calledOnceWithExactly(
       closeFixPreviewEventSpy,
@@ -294,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 () => {
@@ -303,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,
@@ -314,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 () => {
@@ -339,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
@@ -349,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-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index fc22a58..a08bf39 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -14,16 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment-api_html';
-import {customElement, property} from '@polymer/decorators';
 import {
-  CommentBasics,
   PatchRange,
   PatchSetNum,
   RobotCommentInfo,
   UrlEncodedCommentId,
-  NumericChangeId,
   PathToCommentsInfoMap,
   FileInfo,
   ParentPatchSetNum,
@@ -35,18 +30,14 @@
   CommentThread,
   DraftInfo,
   isUnresolved,
-  UIComment,
   createCommentThreads,
   isInPatchRange,
   isDraftThread,
-  isInBaseOfPatchRange,
-  isInRevisionOfPatchRange,
   isPatchsetLevel,
   addPath,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
-import {appContext} from '../../../services/app-context';
-import {CommentSide, Side} from '../../../constants/constants';
+import {CommentSide} from '../../../constants/constants';
 import {pluralize} from '../../../utils/string-util';
 import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 
@@ -70,11 +61,11 @@
    * elements of that which uses the gr-comment-api.
    */
   constructor(
-    comments: PathToCommentsInfoMap | undefined,
-    robotComments: {[path: string]: RobotCommentInfo[]} | undefined,
-    drafts: {[path: string]: DraftInfo[]} | undefined,
-    portedComments: PathToCommentsInfoMap | undefined,
-    portedDrafts: PathToCommentsInfoMap | undefined
+    comments?: PathToCommentsInfoMap,
+    robotComments?: {[path: string]: RobotCommentInfo[]},
+    drafts?: {[path: string]: DraftInfo[]},
+    portedComments?: PathToCommentsInfoMap,
+    portedDrafts?: PathToCommentsInfoMap
   ) {
     this._comments = addPath(comments);
     this._robotComments = addPath(robotComments);
@@ -119,7 +110,7 @@
    * patchNum and basePatchNum properties to represent the range.
    */
   getPaths(patchRange?: PatchRange): CommentMap {
-    const responses: {[path: string]: UIComment[]}[] = [
+    const responses: {[path: string]: Comment[]}[] = [
       this._comments,
       this.drafts,
       this._robotComments,
@@ -144,25 +135,11 @@
   }
 
   /**
-   * Gets all the comments for a particular thread group. Used for refreshing
-   * comments after the thread group has already been built.
-   */
-  getCommentsForThread(rootId: UrlEncodedCommentId) {
-    const allThreads = this.getAllThreadsForChange();
-    const threadMatch = allThreads.find(t => t.rootId === rootId);
-
-    // In the event that a single draft comment was removed by the thread-list
-    // and the diff view is updating comments, there will no longer be a thread
-    // found.  In this case, return null.
-    return threadMatch ? threadMatch.comments : null;
-  }
-
-  /**
    * Gets all the comments and robot comments for the given change.
    */
   getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) {
     const paths = this.getPaths();
-    const publishedComments: {[path: string]: CommentBasics[]} = {};
+    const publishedComments: {[path: string]: CommentInfo[]} = {};
     for (const path of Object.keys(paths)) {
       publishedComments[path] = this.getAllCommentsForPath(
         path,
@@ -196,8 +173,8 @@
     path: string,
     patchNum?: PatchSetNum,
     includeDrafts?: boolean
-  ): Comment[] {
-    const comments: Comment[] = this._comments[path] || [];
+  ): CommentInfo[] {
+    const comments: CommentInfo[] = this._comments[path] || [];
     const robotComments = this._robotComments[path] || [];
     let allComments = comments.concat(robotComments);
     if (includeDrafts) {
@@ -233,43 +210,18 @@
     return allComments;
   }
 
-  cloneWithUpdatedDrafts(drafts: {[path: string]: DraftInfo[]} | undefined) {
-    return new ChangeComments(
-      this._comments,
-      this._robotComments,
-      drafts,
-      this._portedComments,
-      this._portedDrafts
-    );
-  }
-
-  cloneWithUpdatedPortedComments(
-    portedComments?: PathToCommentsInfoMap,
-    portedDrafts?: PathToCommentsInfoMap
-  ) {
-    return new ChangeComments(
-      this._comments,
-      this._robotComments,
-      this._drafts,
-      portedComments,
-      portedDrafts
-    );
-  }
-
   /**
    * Get the drafts for a path and optional patch num.
    *
    * This will return a shallow copy of all drafts every time,
    * so changes on any copy will not affect other copies.
    */
-  getAllDraftsForPath(path: string, patchNum?: PatchSetNum): Comment[] {
-    let comments = this._drafts[path] || [];
+  getAllDraftsForPath(path: string, patchNum?: PatchSetNum): DraftInfo[] {
+    let drafts = this._drafts[path] || [];
     if (patchNum) {
-      comments = comments.filter(c => c.patch_set === patchNum);
+      drafts = drafts.filter(c => c.patch_set === patchNum);
     }
-    return comments.map(c => {
-      return {...c, __draft: true};
-    });
+    return drafts;
   }
 
   /**
@@ -277,7 +229,7 @@
    *
    * // TODO(taoalpha): maybe merge in *ForPath
    */
-  getAllDraftsForFile(file: PatchSetFile): Comment[] {
+  getAllDraftsForFile(file: PatchSetFile): CommentInfo[] {
     let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
     if (file.basePath) {
       allDrafts = allDrafts.concat(
@@ -297,8 +249,8 @@
    * @param projectConfig Optional project config object to
    * include in the meta sub-object.
    */
-  getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
-    let comments: Comment[] = [];
+  getCommentsForPath(path: string, patchRange: PatchRange): CommentInfo[] {
+    let comments: CommentInfo[] = [];
     let drafts: DraftInfo[] = [];
     let robotComments: RobotCommentInfo[] = [];
     if (this._comments && this._comments[path]) {
@@ -311,17 +263,13 @@
       robotComments = this._robotComments[path];
     }
 
-    drafts.forEach(d => {
-      d.__draft = true;
-    });
-
-    return comments
-      .concat(drafts)
-      .concat(robotComments)
+    const all = comments.concat(drafts).concat(robotComments);
+    const final = all
       .filter(c => isInPatchRange(c, patchRange))
       .map(c => {
         return {...c};
       });
+    return final;
   }
 
   /**
@@ -372,7 +320,7 @@
     // ported comments will involve comments that may not belong to the
     // current patchrange, so we need to form threads for them using all
     // comments
-    const allComments: UIComment[] = this.getAllCommentsForFile(file, true);
+    const allComments: CommentInfo[] = this.getAllCommentsForFile(file, true);
 
     return createCommentThreads(allComments).filter(thread => {
       // Robot comments and drafts are not ported over. A human reply to
@@ -388,22 +336,9 @@
         comment => comment.id === portedComment.id
       )!;
 
-      if (
-        (originalComment.line && !portedComment.line) ||
-        (originalComment.range && !portedComment.range)
-      ) {
-        thread.rangeInfoLost = true;
-      }
+      // Original comment shown anyway? No need to port.
+      if (isInPatchRange(originalComment, patchRange)) return false;
 
-      if (
-        isInBaseOfPatchRange(thread.comments[0], patchRange) ||
-        isInRevisionOfPatchRange(thread.comments[0], patchRange)
-      ) {
-        // no need to port this thread as it will be rendered by default
-        return false;
-      }
-
-      thread.diffSide = Side.RIGHT;
       if (thread.commentSide === CommentSide.PARENT) {
         // TODO(dhruvsri): Add handling for merge parents
         if (
@@ -411,11 +346,21 @@
           !!thread.mergeParentNum
         )
           return false;
-        thread.diffSide = Side.LEFT;
       }
 
       if (!isUnresolved(thread) && !isDraftThread(thread)) return false;
 
+      if (
+        (originalComment.line && !portedComment.line) ||
+        (originalComment.range && !portedComment.range)
+      ) {
+        thread.rangeInfoLost = true;
+      }
+      // TODO: It probably makes more sense to set the patch_set in
+      // portedComment either in the backend or in the RestApi layer. Then we
+      // could check `!isInPatchRange(portedComment, patchRange)` and then set
+      // thread.patchNum = portedComment.patch_set;
+      thread.patchNum = patchRange.patchNum;
       thread.range = portedComment.range;
       thread.line = portedComment.line;
       thread.ported = true;
@@ -428,8 +373,7 @@
     patchRange: PatchRange
   ): CommentThread[] {
     const threads = createCommentThreads(
-      this.getCommentsForFile(file, patchRange),
-      patchRange
+      this.getCommentsForFile(file, patchRange)
     );
     threads.push(...this._getPortedCommentThreads(file, patchRange));
     return threads;
@@ -447,7 +391,10 @@
    * @param projectConfig Optional project config object to
    * include in the meta sub-object.
    */
-  getCommentsForFile(file: PatchSetFile, patchRange: PatchRange): Comment[] {
+  getCommentsForFile(
+    file: PatchSetFile,
+    patchRange: PatchRange
+  ): CommentInfo[] {
     const comments = this.getCommentsForPath(file.path, patchRange);
     if (file.basePath) {
       comments.push(...this.getCommentsForPath(file.basePath, patchRange));
@@ -469,11 +416,11 @@
     file: PatchSetFile | PatchNumOnly,
     ignorePatchsetLevelComments?: boolean
   ) {
-    let comments: Comment[] = [];
+    let comments: CommentInfo[] = [];
     if (isPatchSetFile(file)) {
       comments = this.getAllCommentsForFile(file);
     } else {
-      comments = this._commentObjToArray(
+      comments = this._commentObjToArray<CommentInfo>(
         this.getAllPublishedComments(file.patchNum)
       );
     }
@@ -584,8 +531,8 @@
     file: PatchSetFile | PatchNumOnly,
     ignorePatchsetLevelComments?: boolean
   ) {
-    let comments: Comment[] = [];
-    let drafts: Comment[] = [];
+    let comments: CommentInfo[] = [];
+    let drafts: CommentInfo[] = [];
 
     if (isPatchSetFile(file)) {
       comments = this.getAllCommentsForFile(file);
@@ -611,38 +558,3 @@
     return createCommentThreads(comments);
   }
 }
-
-@customElement('gr-comment-api')
-export class GrCommentApi extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
-  _changeComments?: ChangeComments;
-
-  private readonly restApiService = appContext.restApiService;
-
-  private readonly commentsService = appContext.commentsService;
-
-  reloadPortedComments(changeNum: NumericChangeId, patchNum: PatchSetNum) {
-    if (!this._changeComments) {
-      this.commentsService.loadAll(changeNum);
-      return Promise.resolve();
-    }
-    return Promise.all([
-      this.restApiService.getPortedComments(changeNum, patchNum),
-      this.restApiService.getPortedDrafts(changeNum, patchNum),
-    ]).then(res => {
-      if (!this._changeComments) return;
-      this._changeComments =
-        this._changeComments.cloneWithUpdatedPortedComments(res[0], res[1]);
-    });
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-comment-api': GrCommentApi;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index 7e01371..2738eb8 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -20,7 +20,7 @@
 import {ChangeComments} from './gr-comment-api.js';
 import {isInRevisionOfPatchRange, isInBaseOfPatchRange, isDraftThread, isUnresolved, createCommentThreads} from '../../../utils/comment-util.js';
 import {createDraft, createComment, createChangeComments, createCommentThread} from '../../../test/test-data-generators.js';
-import {CommentSide, Side} from '../../../constants/constants.js';
+import {CommentSide} from '../../../constants/constants.js';
 import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-comment-api');
@@ -131,7 +131,8 @@
             {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
 
         assert.equal(portedThreads.length, 1);
-        // check range of thread is from the ported comment and not the original
+        // check that the location of the thread matches the ported comment
+        assert.equal(portedThreads[0].patchNum, 4);
         assert.deepEqual(portedThreads[0].range, {
           start_line: 136,
           start_character: 16,
@@ -207,7 +208,6 @@
             {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: 'PARENT'});
         assert.equal(portedThreads.length, 1);
         assert.equal(portedThreads[0].line, 31);
-        assert.equal(portedThreads[0].diffSide, Side.LEFT);
 
         assert.equal(changeComments._getPortedCommentThreads(
             {path: 'karma.conf.js'}, {patchNum: 4, basePatchNum: -2}
@@ -363,6 +363,7 @@
           ...createComment(),
           id: '01',
           patch_set: 2,
+          path: 'file/one',
           side: PARENT,
           line: 1,
           updated: makeTime(1),
@@ -379,6 +380,7 @@
           id: '02',
           in_reply_to: '04',
           patch_set: 2,
+          path: 'file/one',
           unresolved: true,
           line: 1,
           updated: makeTime(3),
@@ -388,6 +390,7 @@
           ...createComment(),
           id: '03',
           patch_set: 2,
+          path: 'file/one',
           side: PARENT,
           line: 2,
           updated: makeTime(1),
@@ -397,6 +400,7 @@
           ...createComment(),
           id: '04',
           patch_set: 2,
+          path: 'file/one',
           line: 1,
           updated: makeTime(1),
         };
@@ -470,6 +474,7 @@
           side: PARENT,
           line: 1,
           updated: makeTime(3),
+          path: 'file/one',
         };
 
         commentObjs['13'] = {
@@ -481,6 +486,7 @@
           // Draft gets lower timestamp than published comment, because we
           // want to test that the draft still gets sorted to the end.
           updated: makeTime(2),
+          path: 'file/one',
         };
 
         commentObjs['14'] = {
@@ -597,10 +603,6 @@
         const path = 'file/one';
         const drafts = element._changeComments.getAllDraftsForPath(path);
         assert.equal(drafts.length, 2);
-        const aCopyOfDrafts = element._changeComments
-            .getAllDraftsForPath(path);
-        assert.deepEqual(drafts, aCopyOfDrafts);
-        assert.notEqual(drafts[0], aCopyOfDrafts[0]);
       });
 
       test('computeUnresolvedNum', () => {
@@ -828,24 +830,6 @@
         const threads = element._changeComments.getAllThreadsForChange();
         assert.deepEqual(threads, expectedThreads);
       });
-
-      test('getCommentsForThreadGroup', () => {
-        let expectedComments = [
-          {...commentObjs['04'], path: 'file/one'},
-          {...commentObjs['02'], path: 'file/one'},
-          {...commentObjs['13'], path: 'file/one'},
-        ];
-        assert.deepEqual(element._changeComments.getCommentsForThread('04'),
-            expectedComments);
-
-        expectedComments = [{...commentObjs['12'], path: 'file/one'}];
-
-        assert.deepEqual(element._changeComments.getCommentsForThread('12'),
-            expectedComments);
-
-        assert.deepEqual(element._changeComments.getCommentsForThread('1000'),
-            null);
-      });
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
deleted file mode 100644
index f121113..0000000
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
+++ /dev/null
@@ -1,120 +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 {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-coverage-layer_html';
-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.'],
-  [CoverageType.NOT_COVERED, 'Not covered by tests.'],
-  [CoverageType.PARTIALLY_COVERED, 'Partially covered by tests.'],
-  [CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
-]);
-
-@customElement('gr-coverage-layer')
-export class GrCoverageLayer extends PolymerElement implements DiffLayer {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * 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;
-
-  /**
-   * We keep track of the line number from the previous annotate() call,
-   * and also of the index of the coverage range that had matched.
-   * annotate() calls are coming in with increasing line numbers and
-   * coverage ranges are sorted by line number. So this is a very simple
-   * and efficient way for finding the coverage range that matches a given
-   * line number.
-   */
-  @property({type: Number})
-  _lineNumber = 0;
-
-  @property({type: Number})
-  _index = 0;
-
-  /**
-   * Layer method to add annotations to a line.
-   *
-   * @param _el Not used for this layer. (unused parameter)
-   * @param lineNumberEl The <td> element with the line number.
-   * @param line Not used for this layer.
-   */
-  annotate(_el: HTMLElement, lineNumberEl: HTMLElement) {
-    if (
-      !this.side ||
-      !lineNumberEl ||
-      !lineNumberEl.classList.contains(this.side)
-    ) {
-      return;
-    }
-    let elementLineNumber;
-    const dataValue = lineNumberEl.getAttribute('data-value');
-    if (dataValue) {
-      elementLineNumber = Number(dataValue);
-    }
-    if (!elementLineNumber || elementLineNumber < 1) return;
-
-    // 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;
-    }
-    this._lineNumber = 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];
-
-      // 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++;
-        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) {
-        return;
-      }
-
-      // The line number is within the current coverage range. Style it!
-      lineNumberEl.classList.add(coverageRange.type);
-      lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type) || '';
-      return;
-    }
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.ts b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.ts
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/elements/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/elements/diff/gr-coverage-layer/gr-coverage-layer_test.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.js
deleted file mode 100644
index e886e61..0000000
--- a/polygerrit-ui/app/elements/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/elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts
deleted file mode 100644
index 573f559..0000000
--- a/polygerrit-ui/app/elements/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/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
deleted file mode 100644
index bd7dc29..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ /dev/null
@@ -1,142 +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 {GrDiffBuilder} from './gr-diff-builder';
-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 {DiffViewMode, Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
-import {RenderPreferences} from '../../../api/diff';
-
-export class GrDiffBuilderSideBySide extends GrDiffBuilder {
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement,
-    layers: DiffLayer[] = [],
-    renderPrefs?: RenderPreferences
-  ) {
-    super(diff, prefs, outputEl, layers, renderPrefs);
-  }
-
-  _getMoveControlsConfig() {
-    return {
-      numberOfCells: 4,
-      movedOutIndex: 1,
-      movedInIndex: 3,
-      lineNumberCols: [0, 2],
-    };
-  }
-
-  buildSectionElement(group: GrDiffGroup) {
-    const sectionEl = this._createElement('tbody', 'section');
-    sectionEl.classList.add(group.type);
-    if (this._isTotal(group)) {
-      sectionEl.classList.add('total');
-    }
-    if (group.dueToRebase) {
-      sectionEl.classList.add('dueToRebase');
-    }
-    if (group.moveDetails) {
-      sectionEl.classList.add('dueToMove');
-      sectionEl.appendChild(this._buildMoveControls(group));
-    }
-    if (group.ignoredWhitespaceOnly) {
-      sectionEl.classList.add('ignoredWhitespaceOnly');
-    }
-    if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
-      this._createContextControls(
-        sectionEl,
-        group.contextGroups,
-        DiffViewMode.SIDE_BY_SIDE
-      );
-      return sectionEl;
-    }
-
-    const pairs = group.getSideBySidePairs();
-    for (let i = 0; i < pairs.length; i++) {
-      sectionEl.appendChild(this._createRow(pairs[i].left, pairs[i].right));
-    }
-    return sectionEl;
-  }
-
-  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
-    const colgroup = document.createElement('colgroup');
-
-    // Add the blame column.
-    let col = this._createElement('col', 'blame');
-    colgroup.appendChild(col);
-
-    // Add left-side line number.
-    col = this._createElement('col', 'left');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    // Add left-side content.
-    colgroup.appendChild(this._createElement('col', 'left'));
-
-    // Add right-side line number.
-    col = document.createElement('col');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    // Add right-side content.
-    colgroup.appendChild(document.createElement('col'));
-
-    outputEl.appendChild(colgroup);
-  }
-
-  _createRow(leftLine: GrDiffLine, rightLine: GrDiffLine) {
-    const row = this._createElement('tr');
-    row.classList.add('diff-row', 'side-by-side');
-    row.setAttribute('left-type', leftLine.type);
-    row.setAttribute('right-type', rightLine.type);
-    // TabIndex makes screen reader read a row when navigating with j/k
-    row.tabIndex = -1;
-
-    row.appendChild(this._createBlameCell(leftLine.beforeNumber));
-
-    this._appendPair(row, leftLine, leftLine.beforeNumber, Side.LEFT);
-    this._appendPair(row, rightLine, rightLine.afterNumber, Side.RIGHT);
-    return row;
-  }
-
-  _appendPair(
-    row: HTMLElement,
-    line: GrDiffLine,
-    lineNumber: LineNumber,
-    side: Side
-  ) {
-    const lineNumberEl = this._createLineEl(line, lineNumber, line.type, side);
-    row.appendChild(lineNumberEl);
-    row.appendChild(this._createTextEl(lineNumberEl, line, side));
-  }
-
-  _getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
-    let tr: HTMLElement = content.parentElement!.parentElement!;
-    while ((tr = tr.nextSibling as HTMLElement)) {
-      const nextContent = tr.querySelector(
-        'td.content .contentText[data-side="' + side + '"]'
-      );
-      if (nextContent) return nextContent as HTMLElement;
-    }
-    return null;
-  }
-
-  override updateRenderPrefs(_renderPrefs: RenderPreferences) {}
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
deleted file mode 100644
index 663ee7e..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ /dev/null
@@ -1,924 +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 {
-  ContentLoadNeededEventDetail,
-  DiffContextExpandedExternalDetail,
-  MovedLinkClickedEventDetail,
-  RenderPreferences,
-} from '../../../api/diff';
-import {getBaseUrl} from '../../../utils/url-util';
-import {fire} from '../../../utils/event-util';
-import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-
-import '../gr-context-controls/gr-context-controls';
-import {
-  GrContextControls,
-  GrContextControlsShowConfig,
-} from '../gr-context-controls/gr-context-controls';
-import {BlameInfo} from '../../../types/common';
-import {
-  DiffInfo,
-  DiffPreferencesInfo,
-  DiffResponsiveMode,
-} from '../../../types/diff';
-import {DiffViewMode, Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
-
-/**
- * In JS, unicode code points above 0xFFFF occupy two elements of a string.
- * For example '𐀏'.length is 2. An occurrence of such a code point is called a
- * surrogate pair.
- *
- * This regex segments a string along tabs ('\t') and surrogate pairs, since
- * these are two cases where '1 char' does not automatically imply '1 column'.
- *
- * TODO: For human languages whose orthographies use combining marks, this
- * approach won't correctly identify the grapheme boundaries. In those cases,
- * a grapheme consists of multiple code points that should count as only one
- * character against the column limit. Getting that correct (if it's desired)
- * is probably beyond the limits of a regex, but there are nonstandard APIs to
- * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
- *
- * Further reading:
- *   On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
- *   Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
- *   A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
- */
-const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
-
-export interface DiffContextExpandedEventDetail
-  extends DiffContextExpandedExternalDetail {
-  groups: GrDiffGroup[];
-  section: HTMLElement;
-  numLines: number;
-}
-
-declare global {
-  interface HTMLElementEventMap {
-    'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
-    'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
-  }
-}
-
-export function getResponsiveMode(
-  prefs: DiffPreferencesInfo,
-  renderPrefs?: RenderPreferences
-): DiffResponsiveMode {
-  if (renderPrefs?.responsive_mode) {
-    return renderPrefs.responsive_mode;
-  }
-  // Backwards compatibility to the line_wrapping param.
-  if (prefs.line_wrapping) {
-    return 'FULL_RESPONSIVE';
-  }
-  return 'NONE';
-}
-
-export function isResponsive(responsiveMode: DiffResponsiveMode) {
-  return (
-    responsiveMode === 'FULL_RESPONSIVE' || responsiveMode === 'SHRINK_ONLY'
-  );
-}
-
-export abstract class GrDiffBuilder {
-  private readonly _diff: DiffInfo;
-
-  private readonly _numLinesLeft: number;
-
-  private readonly _prefs: DiffPreferencesInfo;
-
-  protected readonly _renderPrefs?: RenderPreferences;
-
-  protected readonly _outputEl: HTMLElement;
-
-  readonly groups: GrDiffGroup[];
-
-  private blameInfo: BlameInfo[] | null;
-
-  private readonly _layerUpdateListener: (
-    start: LineNumber,
-    end: LineNumber,
-    side: Side
-  ) => void;
-
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement,
-    readonly layers: DiffLayer[] = [],
-    renderPrefs?: RenderPreferences
-  ) {
-    this._diff = diff;
-    this._numLinesLeft = this._diff.content
-      ? this._diff.content.reduce((sum, chunk) => {
-          const left = chunk.a || chunk.ab;
-          return sum + (left?.length || chunk.skip || 0);
-        }, 0)
-      : 0;
-    this._prefs = prefs;
-    this._renderPrefs = renderPrefs;
-    this._outputEl = outputEl;
-    this.groups = [];
-    this.blameInfo = null;
-
-    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
-      throw Error('Invalid tab size from preferences.');
-    }
-
-    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
-      throw Error('Invalid line length from preferences.');
-    }
-
-    this._layerUpdateListener = (
-      start: LineNumber,
-      end: LineNumber,
-      side: Side
-    ) => this._handleLayerUpdate(start, end, side);
-    for (const layer of this.layers) {
-      if (layer.addListener) {
-        layer.addListener(this._layerUpdateListener);
-      }
-    }
-  }
-
-  clear() {
-    for (const layer of this.layers) {
-      if (layer.removeListener) {
-        layer.removeListener(this._layerUpdateListener);
-      }
-    }
-  }
-
-  // TODO(TS): Convert to enum.
-  static readonly GroupType = {
-    ADDED: 'b',
-    BOTH: 'ab',
-    REMOVED: 'a',
-  };
-
-  // TODO(TS): Convert to enum.
-  static readonly Highlights = {
-    ADDED: 'edit_b',
-    REMOVED: 'edit_a',
-  };
-
-  abstract addColumns(outputEl: HTMLElement, fontSize: number): void;
-
-  abstract buildSectionElement(group: GrDiffGroup): HTMLElement;
-
-  emitGroup(group: GrDiffGroup, beforeSection: HTMLElement | null) {
-    const element = this.buildSectionElement(group);
-    this._outputEl.insertBefore(element, beforeSection);
-    group.element = element;
-  }
-
-  getGroupsByLineRange(
-    startLine: LineNumber,
-    endLine: LineNumber,
-    side?: Side
-  ) {
-    const groups = [];
-    for (let i = 0; i < this.groups.length; i++) {
-      const group = this.groups[i];
-      if (group.lines.length === 0) {
-        continue;
-      }
-      let groupStartLine = 0;
-      let groupEndLine = 0;
-      if (side) {
-        const range =
-          side === Side.LEFT ? group.lineRange.left : group.lineRange.right;
-        groupStartLine = range.start_line;
-        groupEndLine = range.end_line;
-      }
-
-      if (groupStartLine === 0) {
-        // Line was removed or added.
-        groupStartLine = groupEndLine;
-      }
-      if (groupEndLine === 0) {
-        // Line was removed or added.
-        groupEndLine = groupStartLine;
-      }
-      if (startLine <= groupEndLine && endLine >= groupStartLine) {
-        groups.push(group);
-      }
-    }
-    return groups;
-  }
-
-  getContentTdByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root: Element = this._outputEl
-  ): Element | null {
-    const sideSelector: string = side ? `.${side}` : '';
-    return root.querySelector(
-      `td.lineNum[data-value="${lineNumber}"]${sideSelector} ~ td.content`
-    );
-  }
-
-  getContentByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root?: HTMLElement
-  ): HTMLElement | null {
-    const td = this.getContentTdByLine(lineNumber, side, root);
-    return td ? td.querySelector('.contentText') : null;
-  }
-
-  /**
-   * Find line elements or line objects by a range of line numbers and a side.
-   *
-   * @param start The first line number
-   * @param end The last line number
-   * @param side The side of the range. Either 'left' or 'right'.
-   * @param out_lines The output list of line objects. Use null if not desired.
-   * @param out_elements The output list of line elements. Use null if not
-   *        desired.
-   */
-  findLinesByRange(
-    start: LineNumber,
-    end: LineNumber,
-    side: Side,
-    out_lines: GrDiffLine[] | null,
-    out_elements: HTMLElement[] | null
-  ) {
-    const groups = this.getGroupsByLineRange(start, end, side);
-    for (const group of groups) {
-      let content: HTMLElement | null = null;
-      for (const line of group.lines) {
-        if (
-          (side === 'left' && line.type === GrDiffLineType.ADD) ||
-          (side === 'right' && line.type === GrDiffLineType.REMOVE)
-        ) {
-          continue;
-        }
-        const lineNumber =
-          side === 'left' ? line.beforeNumber : line.afterNumber;
-        if (lineNumber < start || lineNumber > end) {
-          continue;
-        }
-
-        if (out_lines) {
-          out_lines.push(line);
-        }
-        if (out_elements) {
-          if (content) {
-            content = this._getNextContentOnSide(content, side);
-          } else {
-            content = this.getContentByLine(lineNumber, side, group.element);
-          }
-          if (content) {
-            out_elements.push(content);
-          }
-        }
-      }
-    }
-  }
-
-  /**
-   * Re-renders the DIV.contentText elements for the given side and range of
-   * diff content.
-   */
-  _renderContentByRange(start: LineNumber, end: LineNumber, side: Side) {
-    const lines: GrDiffLine[] = [];
-    const elements: HTMLElement[] = [];
-    let line;
-    let el;
-    this.findLinesByRange(start, end, side, lines, elements);
-    for (let i = 0; i < lines.length; i++) {
-      line = lines[i];
-      el = elements[i];
-      if (!el || !el.parentElement) {
-        // Cannot re-render an element if it does not exist. This can happen
-        // if lines are collapsed and not visible on the page yet.
-        continue;
-      }
-      const lineNumberEl = this._getLineNumberEl(el, side);
-      el.parentElement.replaceChild(
-        this._createTextEl(lineNumberEl, line, side).firstChild!,
-        el
-      );
-    }
-  }
-
-  getSectionsByLineRange(
-    startLine: LineNumber,
-    endLine: LineNumber,
-    side: Side
-  ) {
-    return this.getGroupsByLineRange(startLine, endLine, side).map(
-      group => group.element
-    );
-  }
-
-  _createContextControls(
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    viewMode: DiffViewMode
-  ) {
-    const leftStart = contextGroups[0].lineRange.left.start_line;
-    const leftEnd =
-      contextGroups[contextGroups.length - 1].lineRange.left.end_line;
-    const firstGroupIsSkipped = !!contextGroups[0].skip;
-    const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
-
-    const containsWholeFile = this._numLinesLeft === leftEnd - leftStart + 1;
-    const showAbove =
-      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
-    const showBelow = leftEnd < this._numLinesLeft && !lastGroupIsSkipped;
-
-    if (showAbove) {
-      const paddingRow = this._createContextControlPaddingRow(viewMode);
-      paddingRow.classList.add('above');
-      section.appendChild(paddingRow);
-    }
-    section.appendChild(
-      this._createContextControlRow(
-        section,
-        contextGroups,
-        showAbove,
-        showBelow,
-        viewMode
-      )
-    );
-    if (showBelow) {
-      const paddingRow = this._createContextControlPaddingRow(viewMode);
-      paddingRow.classList.add('below');
-      section.appendChild(paddingRow);
-    }
-  }
-
-  /**
-   * Creates context controls. Buttons extend from the gap created by this
-   * method up or down into the area of code that they affect.
-   */
-  _createContextControlRow(
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    showAbove: boolean,
-    showBelow: boolean,
-    viewMode: DiffViewMode
-  ): HTMLElement {
-    const row = this._createElement('tr', 'dividerRow');
-    let showConfig: GrContextControlsShowConfig;
-    if (showAbove && !showBelow) {
-      showConfig = 'above';
-    } else if (!showAbove && showBelow) {
-      showConfig = 'below';
-    } else {
-      // Note that !showAbove && !showBelow also intentionally creates
-      // "show-both". This means the file is completely collapsed, which is
-      // unusual, but at least happens in one test.
-      showConfig = 'both';
-    }
-    row.classList.add(`show-${showConfig}`);
-
-    row.appendChild(this._createBlameCell(0));
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.appendChild(this._createElement('td'));
-    }
-
-    const cell = this._createElement('td', 'dividerCell');
-    cell.setAttribute('colspan', '3');
-    row.appendChild(cell);
-
-    const contextControls = this._createElement(
-      'gr-context-controls'
-    ) as GrContextControls;
-    contextControls.diff = this._diff;
-    contextControls.renderPreferences = this._renderPrefs;
-    contextControls.section = section;
-    contextControls.contextGroups = contextGroups;
-    contextControls.showConfig = showConfig;
-    cell.appendChild(contextControls);
-    return row;
-  }
-
-  /**
-   * Creates a table row to serve as padding between code and context controls.
-   * Blame column, line gutters, and content area will continue visually, but
-   * context controls can render over this background to map more clearly to
-   * the area of code they expand.
-   */
-  _createContextControlPaddingRow(viewMode: DiffViewMode) {
-    const row = this._createElement('tr', 'contextBackground');
-
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.classList.add('side-by-side');
-      row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
-      row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
-    } else {
-      row.classList.add('unified');
-    }
-
-    row.appendChild(this._createBlameCell(0));
-    row.appendChild(this._createElement('td', 'contextLineNum'));
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.appendChild(this._createElement('td'));
-    }
-    row.appendChild(this._createElement('td', 'contextLineNum'));
-    row.appendChild(this._createElement('td'));
-
-    return row;
-  }
-
-  _createLineEl(
-    line: GrDiffLine,
-    number: LineNumber,
-    type: GrDiffLineType,
-    side: Side
-  ) {
-    const td = this._createElement('td');
-    td.classList.add(side);
-    if (line.type === GrDiffLineType.BLANK) {
-      return td;
-    }
-    if (line.type === GrDiffLineType.BOTH || line.type === type) {
-      td.classList.add('lineNum');
-      td.dataset['value'] = number.toString();
-
-      if (
-        ((this._prefs.show_file_comment_button === false ||
-          this._renderPrefs?.show_file_comment_button === false) &&
-          number === 'FILE') ||
-        number === 'LOST'
-      ) {
-        return td;
-      }
-
-      const button = this._createElement('button');
-      td.appendChild(button);
-      button.tabIndex = -1;
-      button.classList.add('lineNumButton');
-      button.classList.add(side);
-      button.dataset['value'] = number.toString();
-      button.textContent = number === 'FILE' ? 'File' : number.toString();
-      if (number === 'FILE') {
-        button.setAttribute('aria-label', 'Add file comment');
-      }
-
-      // Add aria-labels for valid line numbers.
-      // For unified diff, this method will be called with number set to 0 for
-      // the empty line number column for added/removed lines. This should not
-      // be announced to the screenreader.
-      if (number > 0) {
-        if (line.type === GrDiffLineType.REMOVE) {
-          button.setAttribute('aria-label', `${number} removed`);
-        } else if (line.type === GrDiffLineType.ADD) {
-          button.setAttribute('aria-label', `${number} added`);
-        }
-      }
-      this._addLineNumberMouseEvents(td, number, side);
-    }
-    return td;
-  }
-
-  _addLineNumberMouseEvents(el: HTMLElement, number: LineNumber, side: Side) {
-    el.addEventListener('mouseenter', () => {
-      fire(el, 'line-mouse-enter', {lineNum: number, side});
-    });
-    el.addEventListener('mouseleave', () => {
-      fire(el, 'line-mouse-leave', {lineNum: number, side});
-    });
-  }
-
-  _createTextEl(
-    lineNumberEl: HTMLElement | null,
-    line: GrDiffLine,
-    side?: Side
-  ) {
-    const td = this._createElement('td');
-    if (line.type !== GrDiffLineType.BLANK) {
-      td.classList.add('content');
-    }
-
-    // If intraline info is not available, the entire line will be
-    // considered as changed and marked as dark red / green color
-    if (!line.hasIntralineInfo) {
-      td.classList.add('no-intraline-info');
-    }
-    td.classList.add(line.type);
-
-    const {beforeNumber, afterNumber} = line;
-    if (beforeNumber !== 'FILE' && beforeNumber !== 'LOST') {
-      const responsiveMode = getResponsiveMode(this._prefs, this._renderPrefs);
-      const contentText = this._formatText(
-        line.text,
-        responsiveMode,
-        this._prefs.tab_size,
-        this._prefs.line_length
-      );
-
-      if (side) {
-        contentText.setAttribute('data-side', side);
-        const number = side === Side.LEFT ? beforeNumber : afterNumber;
-        this._addLineNumberMouseEvents(td, number, side);
-      }
-
-      if (lineNumberEl && side) {
-        for (const layer of this.layers) {
-          if (typeof layer.annotate === 'function') {
-            layer.annotate(contentText, lineNumberEl, line, side);
-          }
-        }
-      } else {
-        console.error('lineNumberEl or side not set, skipping layer.annotate');
-      }
-
-      td.appendChild(contentText);
-    } else if (line.beforeNumber === 'FILE') td.classList.add('file');
-    else if (line.beforeNumber === 'LOST') td.classList.add('lost');
-
-    return td;
-  }
-
-  private createLineBreak(responsive: boolean) {
-    return responsive
-      ? this._createElement('wbr')
-      : this._createElement('span', 'br');
-  }
-
-  /**
-   * Returns a 'div' element containing the supplied |text| as its innerText,
-   * with '\t' characters expanded to a width determined by |tabSize|, and the
-   * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
-   * desired.
-   *
-   * @param text The text to be formatted.
-   * @param tabSize The width of each tab stop.
-   * @param lineLimit The column after which to wrap lines.
-   */
-  _formatText(
-    text: string,
-    responsiveMode: DiffResponsiveMode,
-    tabSize: number,
-    lineLimit: number
-  ): HTMLElement {
-    const contentText = this._createElement('div', 'contentText');
-    contentText.ariaLabel = text;
-    const responsive = isResponsive(responsiveMode);
-    let columnPos = 0;
-    let textOffset = 0;
-    for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
-      if (segment) {
-        // |segment| contains only normal characters. If |segment| doesn't fit
-        // entirely on the current line, append chunks of |segment| followed by
-        // line breaks.
-        let rowStart = 0;
-        let rowEnd = lineLimit - columnPos;
-        while (rowEnd < segment.length) {
-          contentText.appendChild(
-            document.createTextNode(segment.substring(rowStart, rowEnd))
-          );
-          contentText.appendChild(this.createLineBreak(responsive));
-          columnPos = 0;
-          rowStart = rowEnd;
-          rowEnd += lineLimit;
-        }
-        // Append the last part of |segment|, which fits on the current line.
-        contentText.appendChild(
-          document.createTextNode(segment.substring(rowStart))
-        );
-        columnPos += segment.length - rowStart;
-        textOffset += segment.length;
-      }
-      if (textOffset < text.length) {
-        // Handle the special character at |textOffset|.
-        if (text.startsWith('\t', textOffset)) {
-          // Append a single '\t' character.
-          let effectiveTabSize = tabSize - (columnPos % tabSize);
-          if (columnPos + effectiveTabSize > lineLimit) {
-            contentText.appendChild(this.createLineBreak(responsive));
-            columnPos = 0;
-            effectiveTabSize = tabSize;
-          }
-          contentText.appendChild(this._getTabWrapper(effectiveTabSize));
-          columnPos += effectiveTabSize;
-          textOffset++;
-        } else {
-          // Append a single surrogate pair.
-          if (columnPos >= lineLimit) {
-            contentText.appendChild(this.createLineBreak(responsive));
-            columnPos = 0;
-          }
-          contentText.appendChild(
-            document.createTextNode(text.substring(textOffset, textOffset + 2))
-          );
-          textOffset += 2;
-          columnPos += 1;
-        }
-      }
-    }
-    return contentText;
-  }
-
-  /**
-   * Returns a <span> element holding a '\t' character, that will visually
-   * occupy |tabSize| many columns.
-   *
-   * @param tabSize The effective size of this tab stop.
-   */
-  _getTabWrapper(tabSize: number): HTMLElement {
-    // Force this to be a number to prevent arbitrary injection.
-    const result = this._createElement('span', 'tab');
-    result.setAttribute(
-      'style',
-      `tab-size: ${tabSize}; -moz-tab-size: ${tabSize};`
-    );
-    result.innerText = '\t';
-    return result;
-  }
-
-  _createElement(tagName: string, classStr?: string): HTMLElement {
-    const el = document.createElement(tagName);
-    // When Shady DOM is being used, these classes are added to account for
-    // Polymer's polyfill behavior. In order to guarantee sufficient
-    // specificity within the CSS rules, these are added to every element.
-    // Since the Polymer DOM utility functions (which would do this
-    // automatically) are not being used for performance reasons, this is
-    // done manually.
-    el.classList.add('style-scope', 'gr-diff');
-    if (classStr) {
-      for (const className of classStr.split(' ')) {
-        el.classList.add(className);
-      }
-    }
-    return el;
-  }
-
-  _handleLayerUpdate(start: LineNumber, end: LineNumber, side: Side) {
-    this._renderContentByRange(start, end, side);
-  }
-
-  /**
-   * Finds the next DIV.contentText element following the given element, and on
-   * the same side. Will only search within a group.
-   */
-  abstract _getNextContentOnSide(
-    content: HTMLElement,
-    side: Side
-  ): HTMLElement | null;
-
-  /**
-   * Gets configuration for creating move controls for chunks marked with
-   * dueToMove
-   */
-  abstract _getMoveControlsConfig(): {
-    numberOfCells: number;
-    movedOutIndex: number;
-    movedInIndex: number;
-    lineNumberCols: number[];
-  };
-
-  /**
-   * Determines whether the given group is either totally an addition or totally
-   * a removal.
-   */
-  _isTotal(group: GrDiffGroup): boolean {
-    return (
-      group.type === GrDiffGroupType.DELTA &&
-      (!group.adds.length || !group.removes.length) &&
-      !(!group.adds.length && !group.removes.length)
-    );
-  }
-
-  /**
-   * Set the blame information for the diff. For any already-rendered line,
-   * re-render its blame cell content.
-   */
-  setBlame(blame: BlameInfo[] | null) {
-    this.blameInfo = blame;
-    if (!blame) return;
-
-    // TODO(wyatta): make this loop asynchronous.
-    for (const commit of blame) {
-      for (const range of commit.ranges) {
-        for (let i = range.start; i <= range.end; i++) {
-          // TODO(wyatta): this query is expensive, but, when traversing a
-          // range, the lines are consecutive, and given the previous blame
-          // cell, the next one can be reached cheaply.
-          const el = this._getBlameByLineNum(i);
-          if (!el) {
-            continue;
-          }
-          // Remove the element's children (if any).
-          while (el.hasChildNodes()) {
-            el.removeChild(el.lastChild!);
-          }
-          const blame = this._getBlameForBaseLine(i, commit);
-          if (blame) el.appendChild(blame);
-        }
-      }
-    }
-  }
-
-  _createMovedLineAnchor(line: number, side: Side) {
-    const anchor = this._createElementWithText('a', `${line}`);
-
-    // href is not actually used but important for Screen Readers
-    anchor.setAttribute('href', `#${line}`);
-    anchor.addEventListener('click', e => {
-      e.preventDefault();
-      anchor.dispatchEvent(
-        new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
-          detail: {
-            lineNum: line,
-            side,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-    return anchor;
-  }
-
-  _createElementWithText(tagName: string, textContent: string) {
-    const element = this._createElement(tagName);
-    element.textContent = textContent;
-    return element;
-  }
-
-  _createMoveDescriptionDiv(movedIn: boolean, group: GrDiffGroup) {
-    const div = this._createElement('div');
-    if (group.moveDetails?.range) {
-      const {changed, range} = group.moveDetails;
-      const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
-      const andChangedLabel = changed ? 'and changed ' : '';
-      const direction = movedIn ? 'from' : 'to';
-      const textLabel = `Moved ${andChangedLabel}${direction} lines `;
-      div.appendChild(this._createElementWithText('span', textLabel));
-      div.appendChild(this._createMovedLineAnchor(range.start, otherSide));
-      div.appendChild(this._createElementWithText('span', ' - '));
-      div.appendChild(this._createMovedLineAnchor(range.end, otherSide));
-    } else {
-      div.appendChild(
-        this._createElementWithText('span', movedIn ? 'Moved in' : 'Moved out')
-      );
-    }
-    return div;
-  }
-
-  _buildMoveControls(group: GrDiffGroup) {
-    const movedIn = group.adds.length > 0;
-    const {numberOfCells, movedOutIndex, movedInIndex, lineNumberCols} =
-      this._getMoveControlsConfig();
-
-    let controlsClass;
-    let descriptionIndex;
-    const descriptionTextDiv = this._createMoveDescriptionDiv(movedIn, group);
-    if (movedIn) {
-      controlsClass = 'movedIn';
-      descriptionIndex = movedInIndex;
-    } else {
-      controlsClass = 'movedOut';
-      descriptionIndex = movedOutIndex;
-    }
-
-    const controls = this._createElement('tr', `moveControls ${controlsClass}`);
-    const cells = [...Array(numberOfCells).keys()].map(() =>
-      this._createElement('td')
-    );
-    lineNumberCols.forEach(index => {
-      cells[index].classList.add('moveControlsLineNumCol');
-    });
-
-    const moveRangeHeader = this._createElement('gr-range-header');
-    moveRangeHeader.setAttribute('icon', 'gr-icons:move-item');
-    moveRangeHeader.appendChild(descriptionTextDiv);
-    cells[descriptionIndex].classList.add('moveHeader');
-    cells[descriptionIndex].appendChild(moveRangeHeader);
-    cells.forEach(c => {
-      controls.appendChild(c);
-    });
-    return controls;
-  }
-
-  /**
-   * Find the blame cell for a given line number.
-   */
-  _getBlameByLineNum(lineNum: number): Element | null {
-    return this._outputEl.querySelector(
-      `td.blame[data-line-number="${lineNum}"]`
-    );
-  }
-
-  /**
-   * Given a base line number, return the commit containing that line in the
-   * current set of blame information. If no blame information has been
-   * provided, null is returned.
-   *
-   * @return The commit information.
-   */
-  _getBlameCommitForBaseLine(lineNum: LineNumber) {
-    if (!this.blameInfo) {
-      return null;
-    }
-
-    for (const blameCommit of this.blameInfo) {
-      for (const range of blameCommit.ranges) {
-        if (range.start <= lineNum && range.end >= lineNum) {
-          return blameCommit;
-        }
-      }
-    }
-    return null;
-  }
-
-  /**
-   * Given the number of a base line, get the content for the blame cell of that
-   * line. If there is no blame information for that line, returns null.
-   *
-   * @param commit Optionally provide the commit object, so that
-   *     it does not need to be searched.
-   */
-  _getBlameForBaseLine(
-    lineNum: LineNumber,
-    commit: BlameInfo | null = this._getBlameCommitForBaseLine(lineNum)
-  ): HTMLElement | null {
-    if (!commit) {
-      return null;
-    }
-
-    const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
-
-    const date = new Date(commit.time * 1000).toLocaleDateString();
-    const blameNode = this._createElement(
-      'span',
-      isStartOfRange ? 'startOfRange' : ''
-    );
-
-    const shaNode = this._createElement('a', 'blameDate');
-    shaNode.innerText = `${date}`;
-    shaNode.setAttribute('href', `${getBaseUrl()}/q/${commit.id}`);
-    blameNode.appendChild(shaNode);
-
-    const shortName = commit.author.split(' ')[0];
-    const authorNode = this._createElement('span', 'blameAuthor');
-    authorNode.innerText = ` ${shortName}`;
-    blameNode.appendChild(authorNode);
-
-    const hoverCardFragment = this._createElement('span', 'blameHoverCard');
-    hoverCardFragment.innerText = `Commit ${commit.id}
-Author: ${commit.author}
-Date: ${date}
-
-${commit.commit_msg}`;
-    const hovercard = this._createElement('gr-hovercard');
-    hovercard.appendChild(hoverCardFragment);
-    blameNode.appendChild(hovercard);
-
-    return blameNode;
-  }
-
-  /**
-   * Create a blame cell for the given base line. Blame information will be
-   * included in the cell if available.
-   */
-  _createBlameCell(lineNumber: LineNumber): HTMLTableDataCellElement {
-    const blameTd = this._createElement(
-      'td',
-      'blame'
-    ) as HTMLTableDataCellElement;
-    blameTd.setAttribute('data-line-number', lineNumber.toString());
-    if (lineNumber) {
-      const content = this._getBlameForBaseLine(lineNumber);
-      if (content) {
-        blameTd.appendChild(content);
-      }
-    }
-    return blameTd;
-  }
-
-  /**
-   * Finds the line number element given the content element by walking up the
-   * DOM tree to the diff row and then querying for a .lineNum element on the
-   * requested side.
-   *
-   * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
-   */
-  _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;
-  }
-
-  updateRenderPrefs(_renderPrefs: RenderPreferences) {}
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
deleted file mode 100644
index 3e292fe..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ /dev/null
@@ -1,588 +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 '../../../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,
-} from '../gr-diff/gr-diff-utils';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {queryAndAssert} from '../../../utils/common-util';
-
-interface SidedRange {
-  side: Side;
-  range: CommentRange;
-}
-
-interface NormalizedPosition {
-  node: Node | null;
-  side: Side;
-  line: number;
-  column: number;
-}
-
-interface NormalizedRange {
-  start: NormalizedPosition | null;
-  end: NormalizedPosition | null;
-}
-
-// TODO(TS): Replace by GrCommentThread once that is converted.
-interface CommentThreadElement extends HTMLElement {
-  rootId: string;
-}
-
-@customElement('gr-diff-highlight')
-export class GrDiffHighlight extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Array, notify: true})
-  commentRanges: SidedRange[] = [];
-
-  @property({type: Boolean})
-  loggedIn?: boolean;
-
-  @property({type: Object})
-  _cachedDiffBuilder?: GrDiffBuilderElement;
-
-  @property({type: Object, notify: true})
-  selectedRange?: SidedRange;
-
-  private selectionChangeTask?: DelayedTask;
-
-  constructor() {
-    super();
-    this.addEventListener('comment-thread-mouseleave', e =>
-      this._handleCommentThreadMouseleave(e)
-    );
-    this.addEventListener('comment-thread-mouseenter', e =>
-      this._handleCommentThreadMouseenter(e)
-    );
-    this.addEventListener('create-comment-requested', e =>
-      this._handleRangeCommentRequest(e)
-    );
-  }
-
-  override disconnectedCallback() {
-    this.selectionChangeTask?.cancel();
-    super.disconnectedCallback();
-  }
-
-  get diffBuilder() {
-    if (!this._cachedDiffBuilder) {
-      this._cachedDiffBuilder = this.querySelector(
-        'gr-diff-builder'
-      ) as GrDiffBuilderElement;
-    }
-    return this._cachedDiffBuilder;
-  }
-
-  /**
-   * Determines side/line/range for a DOM selection and shows a tooltip.
-   *
-   * With native shadow DOM, gr-diff-highlight cannot access a selection that
-   * references the DOM elements making up the diff because they are in the
-   * shadow DOM the gr-diff element. For this reason, we listen to the
-   * selectionchange event and retrieve the selection in gr-diff, and then
-   * call this method to process the Selection.
-   *
-   * @param selection A DOM Selection living in the shadow DOM of
-   * the diff element.
-   * @param isMouseUp If true, this is called due to a mouseup
-   * event, in which case we might want to immediately create a comment,
-   * because isMouseUp === true combined with an existing selection must
-   * mean that this is the end of a double-click.
-   */
-  handleSelectionChange(
-    selection: Selection | Range | null,
-    isMouseUp: boolean
-  ) {
-    if (selection === null) return;
-    // Debounce is not just nice for waiting until the selection has settled,
-    // it is also vital for being able to click on the action box before it is
-    // 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
-    // simple drag for select.
-    this.selectionChangeTask = debounce(
-      this.selectionChangeTask,
-      () => this._handleSelection(selection, isMouseUp),
-      10
-    );
-  }
-
-  _getThreadEl(e: Event): CommentThreadElement | null {
-    const path = (dom(e) as EventApi).path || [];
-    for (const pathEl of path) {
-      if (
-        pathEl instanceof HTMLElement &&
-        pathEl.classList.contains('comment-thread')
-      ) {
-        return pathEl as CommentThreadElement;
-      }
-    }
-    return null;
-  }
-
-  _toggleRangeElHighlight(
-    threadEl: CommentThreadElement,
-    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
-      );
-    }
-
-    return this.commentRanges.findIndex(
-      commentRange =>
-        commentRange.side === side && rangesEqual(commentRange.range, range)
-    );
-  }
-
-  /**
-   * 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) {
-    /* 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);
-    }
-    const rangeCount = selection.rangeCount;
-    if (rangeCount === 0) {
-      return null;
-    } else if (rangeCount === 1) {
-      return this._normalizeRange(selection.getRangeAt(0));
-    } else {
-      const startRange = this._normalizeRange(selection.getRangeAt(0));
-      const endRange = this._normalizeRange(
-        selection.getRangeAt(rangeCount - 1)
-      );
-      return {
-        start: startRange.start,
-        end: endRange.end,
-      };
-    }
-  }
-
-  /**
-   * Normalize a specific DOM Range.
-   *
-   * @return fixed normalized range
-   */
-  _normalizeRange(domRange: Range): NormalizedRange {
-    const range = normalize(domRange);
-    return this._fixTripleClickSelection(
-      {
-        start: this._normalizeSelectionSide(
-          range.startContainer,
-          range.startOffset
-        ),
-        end: this._normalizeSelectionSide(range.endContainer, range.endOffset),
-      },
-      domRange
-    );
-  }
-
-  /**
-   * Adjust triple click selection for the whole line.
-   * A triple click always results in:
-   * - start.column == end.column == 0
-   * - end.line == start.line + 1
-   *
-   * @param range Normalized range, ie column/line numbers
-   * @param domRange DOM Range object
-   * @return fixed normalized range
-   */
-  _fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
-    if (!range.start) {
-      // Selection outside of current diff.
-      return range;
-    }
-    const start = range.start;
-    const end = range.end;
-    // Happens when triple click in side-by-side mode with other side empty.
-    const endsAtOtherEmptySide =
-      !end &&
-      domRange.endOffset === 0 &&
-      domRange.endContainer instanceof HTMLElement &&
-      domRange.endContainer.nodeName === 'TD' &&
-      (domRange.endContainer.classList.contains('left') ||
-        domRange.endContainer.classList.contains('right'));
-    const endsAtBeginningOfNextLine =
-      end &&
-      start.column === 0 &&
-      end.column === 0 &&
-      end.line === start.line + 1;
-    const content = domRange.cloneContents().querySelector('.contentText');
-    const lineLength = (content && this._getLength(content)) || 0;
-    if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
-      // Move the selection to the end of the previous line.
-      range.end = {
-        node: start.node,
-        column: lineLength,
-        side: start.side,
-        line: start.line,
-      };
-    }
-    return range;
-  }
-
-  /**
-   * Convert DOM Range selection to concrete numbers (line, column, side).
-   * Moves range end if it's not inside td.content.
-   * Returns null if selection end is not valid (outside of diff).
-   *
-   * @param node td.content child
-   * @param offset offset within node
-   */
-  _normalizeSelectionSide(
-    node: Node | null,
-    offset: number
-  ): NormalizedPosition | null {
-    let column;
-    if (!node || !this.contains(node)) return null;
-    const lineEl = getLineElByChild(node);
-    if (!lineEl) return null;
-    const side = getSideByLineEl(lineEl);
-    if (!side) return null;
-    const line = getLineNumberByChild(lineEl);
-    if (!line || line === FILE || line === 'LOST') return null;
-    const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
-    if (!contentTd) return null;
-    const contentText = contentTd.querySelector('.contentText');
-    if (!contentTd.contains(node)) {
-      node = contentText;
-      column = 0;
-    } else {
-      const thread = contentTd.querySelector('.comment-thread');
-      if (thread?.contains(node)) {
-        column = this._getLength(contentText);
-        node = contentText;
-      } else {
-        column = this._convertOffsetToColumn(node, offset);
-      }
-    }
-
-    return {
-      node,
-      side,
-      line,
-      column,
-    };
-  }
-
-  /**
-   * The only line in which add a comment tooltip is cut off is the first
-   * line. Even if there is a collapsed section, The first visible line is
-   * in the position where the second line would have been, if not for the
-   * collapsed section, so don't need to worry about this case for
-   * positioning the tooltip.
-   */
-  _positionActionBox(
-    actionBox: GrSelectionActionBox,
-    startLine: number,
-    range: Text | Element | Range
-  ) {
-    if (startLine > 1) {
-      actionBox.placeAbove(range);
-      return;
-    }
-    actionBox.positionBelow = true;
-    actionBox.placeBelow(range);
-  }
-
-  _isRangeValid(range: NormalizedRange | null) {
-    if (!range || !range.start || !range.start.node || !range.end) {
-      return false;
-    }
-    const start = range.start;
-    const end = range.end;
-    return !(
-      start.side !== end.side ||
-      end.line < start.line ||
-      (start.line === end.line && start.column === end.column)
-    );
-  }
-
-  _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();
-      return;
-    }
-    /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
-       we can get is a single Range */
-    const domRange =
-      selection instanceof Range ? selection : selection.getRangeAt(0);
-    const start = normalizedRange!.start!;
-    const end = normalizedRange!.end!;
-
-    // TODO (viktard): Drop empty first and last lines from selection.
-
-    // If the selection is from the end of one line to the start of the next
-    // line, then this must have been a double-click, or you have started
-    // dragging. Showing the action box is bad in the former case and not very
-    // useful in the latter, so never do that.
-    // If this was a mouse-up event, we create a comment immediately if
-    // the selection is from the end of a line to the start of the next line.
-    // In a perfect world we would only do this for double-click, but it is
-    // extremely rare that a user would drag from the end of one line to the
-    // start of the next and release the mouse, so we don't bother.
-    // TODO(brohlfs): This does not work, if the double-click is before a new
-    // diff chunk (start will be equal to end), and neither before an "expand
-    // the diff context" block (end line will match the first line of the new
-    // section and thus be greater than start line + 1).
-    if (start.line === end.line - 1 && end.column === 0) {
-      // Rather than trying to find the line contents (for comparing
-      // 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, {
-          start_line: start.line,
-          start_character: 0,
-          end_line: start.line,
-          end_character: start.column,
-        });
-      }
-      return;
-    }
-
-    let actionBox = this.shadowRoot!.querySelector(
-      'gr-selection-action-box'
-    ) as GrSelectionActionBox | null;
-    if (!actionBox) {
-      actionBox = document.createElement('gr-selection-action-box');
-      this.root!.insertBefore(actionBox, this.root!.firstElementChild);
-    }
-    this.selectedRange = {
-      range: {
-        start_line: start.line,
-        start_character: start.column,
-        end_line: end.line,
-        end_character: end.column,
-      },
-      side: start.side,
-    };
-    if (start.line === end.line) {
-      this._positionActionBox(actionBox, start.line, domRange);
-    } else if (start.node instanceof Text) {
-      if (start.column) {
-        this._positionActionBox(
-          actionBox,
-          start.line,
-          start.node.splitText(start.column)
-        );
-      }
-      start.node.parentElement!.normalize(); // Undo splitText from above.
-    } else if (
-      start.node instanceof HTMLElement &&
-      start.node.classList.contains('content') &&
-      (start.node.firstChild instanceof Element ||
-        start.node.firstChild instanceof Text)
-    ) {
-      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);
-    } else {
-      console.warn('Failed to position comment action box.');
-      this._removeActionBox();
-    }
-  }
-
-  _fireCreateRangeComment(side: Side, range: CommentRange) {
-    this.dispatchEvent(
-      new CustomEvent('create-range-comment', {
-        detail: {side, range},
-        composed: true,
-        bubbles: true,
-      })
-    );
-    this._removeActionBox();
-  }
-
-  _handleRangeCommentRequest(e: Event) {
-    e.stopPropagation();
-    if (!this.selectedRange) {
-      throw Error('Selected Range is needed for new range comment!');
-    }
-    const {side, range} = this.selectedRange;
-    this._fireCreateRangeComment(side, range);
-  }
-
-  _removeActionBox() {
-    this.selectedRange = undefined;
-    const actionBox = this.shadowRoot!.querySelector('gr-selection-action-box');
-    if (actionBox) {
-      this.root!.removeChild(actionBox);
-    }
-  }
-
-  _convertOffsetToColumn(el: Node, offset: number) {
-    if (el instanceof Element && el.classList.contains('content')) {
-      return offset;
-    }
-    while (
-      el.previousSibling ||
-      !el.parentElement?.classList.contains('content')
-    ) {
-      if (el.previousSibling) {
-        el = el.previousSibling;
-        offset += this._getLength(el);
-      } else {
-        el = el.parentElement!;
-      }
-    }
-    return offset;
-  }
-
-  /**
-   * Get length of a node. If the node is a content node, then only give the
-   * length of its .contentText child.
-   *
-   * @param node this is sometimes passed as null.
-   */
-  _getLength(node: Node | null): number {
-    if (node === null) return 0;
-    if (node instanceof Element && node.classList.contains('content')) {
-      return this._getLength(queryAndAssert(node, '.contentText'));
-    } else {
-      return GrAnnotation.getLength(node);
-    }
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-highlight': GrDiffHighlight;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.ts
deleted file mode 100644
index 5a6cb1c..0000000
--- a/polygerrit-ui/app/elements/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/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
deleted file mode 100644
index 4c1295f..0000000
--- a/polygerrit-ui/app/elements/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/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 59bd817..f4e5835 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
@@ -15,8 +15,8 @@
  * limitations under the License.
  */
 import '../../shared/gr-comment-thread/gr-comment-thread';
-import '../gr-diff/gr-diff';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
+import '../../checks/gr-diff-check-result';
+import '../../../embed/diff/gr-diff/gr-diff';
 import {htmlTemplate} from './gr-diff-host_html';
 import {
   GerritNav,
@@ -25,25 +25,27 @@
 import {
   anyLineTooLong,
   getLine,
-  getRange,
   getSide,
-  rangesEqual,
   SYNTAX_MAX_LINE_LENGTH,
-} from '../gr-diff/gr-diff-utils';
-import {appContext} from '../../../services/app-context';
+} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {getAppContext} from '../../../services/app-context';
 import {
   getParentIndex,
   isAParent,
   isMergeParent,
   isNumber,
 } from '../../../utils/patch-set-util';
-import {CommentThread} from '../../../utils/comment-util';
+import {
+  CommentThread,
+  equalLocation,
+  isInBaseOfPatchRange,
+  isInRevisionOfPatchRange,
+} from '../../../utils/comment-util';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
   CommitRange,
   CoverageRange,
   DiffLayer,
-  DiffLayerListener,
   PatchSetFile,
 } from '../../../types/types';
 import {
@@ -66,13 +68,11 @@
 import {
   CreateCommentEventDetail,
   GrDiff,
-  LineOfInterest,
-} from '../gr-diff/gr-diff';
-import {GrSyntaxLayer} from '../gr-syntax-layer/gr-syntax-layer';
+} from '../../../embed/diff/gr-diff/gr-diff';
 import {DiffViewMode, Side, CommentSide} from '../../../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
-import {LineNumber, FILE} from '../gr-diff/gr-diff-line';
+import {LineNumber, FILE} from '../../../embed/diff/gr-diff/gr-diff-line';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {
@@ -84,14 +84,22 @@
 } from '../../../utils/event-util';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {assertIsDefined} from '../../../utils/common-util';
-import {DiffContextExpandedEventDetail} from '../gr-diff-builder/gr-diff-builder';
-import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
+import {DiffContextExpandedEventDetail} from '../../../embed/diff/gr-diff-builder/gr-diff-builder';
+import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
 import {Timing} from '../../../constants/reporting';
-import {changeComments$} from '../../../services/comments/comments-model';
-import {takeUntil} from 'rxjs/operators';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
-import {Subject} from 'rxjs';
-import {RenderPreferences} from '../../../api/diff';
+import {Subscription} from 'rxjs';
+import {DisplayLine, RenderPreferences} from '../../../api/diff';
+import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {browserModelToken} from '../../../models/browser/browser-model';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {checksModelToken, RunResult} from '../../../models/checks/checks-model';
+import {GrDiffCheckResult} from '../../checks/gr-diff-check-result';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+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.';
 
@@ -99,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;
 
@@ -133,7 +135,7 @@
  * specific component, while <gr-diff> is a re-usable component.
  */
 @customElement('gr-diff-host')
-export class GrDiffHost extends PolymerElement {
+export class GrDiffHost extends DIPolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -205,12 +207,12 @@
   @property({type: Boolean})
   lineWrapping = false;
 
+  @property({type: Object})
+  lineOfInterest?: DisplayLine;
+
   @property({type: String})
   viewMode = DiffViewMode.SIDE_BY_SIDE;
 
-  @property({type: Object})
-  lineOfInterest?: LineOfInterest;
-
   @property({type: Boolean})
   showLoadFailure?: boolean;
 
@@ -233,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})
@@ -269,20 +271,37 @@
     num_lines_rendered_at_once: 128,
   };
 
-  private readonly reporting = appContext.reportingService;
+  private readonly getBrowserModel = resolve(this, browserModelToken);
 
-  private readonly flags = appContext.flagsService;
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly getChecksModel = resolve(this, checksModelToken);
 
-  private readonly jsAPI = appContext.jsApiService;
+  private readonly flagService = getAppContext().flagsService;
 
-  private readonly syntaxLayer = new GrSyntaxLayer();
+  private readonly reporting = getAppContext().reportingService;
 
-  disconnected$ = new Subject();
+  private readonly flags = getAppContext().flagsService;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly jsAPI = getAppContext().jsApiService;
+
+  private readonly syntaxLayer: GrSyntaxLayerWorker;
+
+  private checksSubscription?: Subscription;
+
+  private subscriptions: Subscription[] = [];
 
   constructor() {
     super();
+    this.syntaxLayer = new GrSyntaxLayerWorker();
+    this._renderPrefs = {
+      ...this._renderPrefs,
+      use_lit_components: this.flags.isEnabled(
+        KnownExperimentId.DIFF_RENDERING_LIT
+      ),
+    };
     this.addEventListener(
       // These are named inconsistently for a reason:
       // The create-comment event is fired to indicate that we should
@@ -291,12 +310,7 @@
       // change in some way, and that we should update any models we may want
       // to keep in sync.
       'create-comment',
-      e => this._handleCreateComment(e)
-    );
-    this.addEventListener('render-start', () => this._handleRenderStart());
-    this.addEventListener('render-content', () => this._handleRenderContent());
-    this.addEventListener('normalize-range', event =>
-      this._handleNormalizeRange(event)
+      e => this._handleCreateThread(e)
     );
     this.addEventListener('diff-context-expanded', event =>
       this._handleDiffContextExpanded(event)
@@ -312,24 +326,37 @@
 
   override connectedCallback() {
     super.connectedCallback();
+    this.subscriptions.push(
+      this.getBrowserModel().diffViewMode$.subscribe(
+        diffView => (this.viewMode = diffView)
+      )
+    );
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
-    changeComments$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(changeComments => {
+    this.subscriptions.push(
+      this.getCommentsModel().changeComments$.subscribe(changeComments => {
         this.changeComments = changeComments;
-      });
+      })
+    );
+    this.subscribeToChecks();
   }
 
   override disconnectedCallback() {
-    this.disconnected$.next();
+    if (this.checksSubscription) {
+      this.checksSubscription.unsubscribe();
+      this.checksSubscription = undefined;
+    }
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
     this.clear();
     super.disconnectedCallback();
   }
 
   async initLayers() {
-    const preferencesPromise = appContext.restApiService.getPreferences();
+    const preferencesPromise = this.restApiService.getPreferences();
     await getPluginLoader().awaitPluginsLoaded();
     const prefs = await preferencesPromise;
     const enableTokenHighlight = !prefs?.disable_token_highlighting;
@@ -343,15 +370,13 @@
     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.
    */
   async reload(shouldReportMetric?: boolean) {
+    this.reporting.time(Timing.DIFF_TOTAL);
+    this.reporting.time(Timing.DIFF_LOAD);
     this.clear();
     assertIsDefined(this.path, 'path');
     assertIsDefined(this.changeNum, 'changeNum');
@@ -359,11 +384,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,
@@ -372,6 +392,7 @@
       // assets in parallel.
       const layerPromise = this.initLayers();
       const diff = await this._getDiff();
+      this.subscribeToChecks();
       this._loadedWhitespaceLevel = whitespaceLevel;
       this._reportDiff(diff);
 
@@ -386,32 +407,64 @@
       this.editWeblinks = this._getEditWeblinks(diff);
       this.filesWeblinks = this._getFilesWeblinks(diff);
       this.diff = diff;
-      const event = (await waitForEventOnce(this, 'render')) as CustomEvent;
+      this.reporting.timeEnd(Timing.DIFF_LOAD, this.timingDetails());
+
+      this.reporting.time(Timing.DIFF_CONTENT);
+      const syntaxLayerPromise = this.syntaxLayer.process(diff);
+      await waitForEventOnce(this, 'render');
+      this.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);
-        }
-      }
-    } catch (e) {
+
+      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);
-      } else {
+      } else if (e instanceof Error) {
         this.reporting.error(e);
+      } else {
+        this.reporting.error(new Error('reload error'), undefined, e);
       }
     } finally {
-      this.reporting.timeEnd(Timing.DIFF_TOTAL);
+      this.reporting.timeEnd(Timing.DIFF_TOTAL, this.timingDetails());
     }
   }
 
+  /**
+   * Produces an event detail object for reporting.
+   */
+  private timingDetails() {
+    if (!this.diff) return {};
+    const metaLines =
+      (this.diff.meta_a?.lines ?? 0) + (this.diff.meta_b?.lines ?? 0);
+
+    let contentLines = 0;
+    let contentChanged = 0;
+    let contentUnchanged = 0;
+    for (const chunk of this.diff.content) {
+      const ab = chunk.ab?.length ?? 0;
+      const a = chunk.a?.length ?? 0;
+      const b = chunk.b?.length ?? 0;
+      contentLines += ab + ab + a + b;
+      contentChanged += a + b;
+      contentUnchanged += ab + ab;
+    }
+    return {
+      metaLines,
+      contentLines,
+      contentUnchanged,
+      contentChanged,
+      height:
+        this.$.diff?.shadowRoot?.querySelector('.diffContainer')?.clientHeight,
+    };
+  }
+
   private getLayers(path: string, enableTokenHighlight: boolean): DiffLayer[] {
     const layers = [];
     if (enableTokenHighlight) {
@@ -428,6 +481,106 @@
     this._layers = [];
   }
 
+  /**
+   * This should be called when either `path` or `patchRange` has changed.
+   * We will then subscribe to the checks model and filter the relevant
+   * check results for this diff. Path and patchset must match, and a code
+   * pointer must be included.
+   */
+  private subscribeToChecks() {
+    if (this.checksSubscription) {
+      this.checksSubscription.unsubscribe();
+      this.checksSubscription = undefined;
+      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;
+    this.checksSubscription = this.getChecksModel()
+      .allResults$.pipe(
+        map(results =>
+          results.filter(result => {
+            if (result.patchset !== patchNum) return false;
+            if (result.category === Category.SUCCESS) return false;
+            // Only one code pointer is supported. See API docs.
+            const pointer = result.codePointers?.[0];
+            return pointer?.path === this.path && !!pointer?.range;
+          })
+        ),
+        distinctUntilChanged(deepEqual)
+      )
+      .subscribe(results => this.checksChanged(results));
+  }
+
+  /**
+   * Similar to _threadsChanged(), but a bit simpler. We compare the elements
+   * that are already in <gr-diff> with the current results emitted from the
+   * model. Exists? Update. New? Create and attach. Old? Remove.
+   */
+  private checksChanged(checks: RunResult[]) {
+    const idToEl = new Map<string, GrDiffCheckResult>();
+    const checkEls = this.getCheckEls();
+    const dontRemove = new Set<GrDiffCheckResult>();
+    for (const el of checkEls) {
+      const id = el.result?.internalResultId;
+      assertIsDefined(id, 'result.internalResultId of gr-diff-check-result');
+      idToEl.set(id, el);
+    }
+    for (const check of checks) {
+      const id = check.internalResultId;
+      const existingEl = idToEl.get(id);
+      if (existingEl) {
+        existingEl.result = check;
+        dontRemove.add(existingEl);
+      } else {
+        const newEl = this.createCheckEl(check);
+        dontRemove.add(newEl);
+      }
+    }
+    // Remove all check els that don't have a matching check anymore.
+    for (const el of checkEls) {
+      if (dontRemove.has(el)) continue;
+      el.remove();
+    }
+  }
+
+  /**
+   * This is very similar to createThreadElement(). It creates a new
+   * <gr-diff-check-result> element, sets its props/attributes and adds it to
+   * <gr-diff>.
+   */
+  // Visible for testing
+  createCheckEl(check: RunResult) {
+    const pointer = check.codePointers?.[0];
+    assertIsDefined(pointer, 'code pointer of check result in diff');
+    const line: LineNumber =
+      pointer.range?.end_line || pointer.range?.start_line || 'FILE';
+    const el = document.createElement('gr-diff-check-result');
+    // This is what gr-diff expects, even though this is a check, not a comment.
+    el.className = 'comment-thread';
+    el.rootId = check.internalResultId;
+    el.result = check;
+    // These attributes are the "interface" between comments/checks and gr-diff.
+    // <gr-comment-thread> does not care about them and is not affected by them.
+    el.setAttribute('slot', `${Side.RIGHT}-${line}`);
+    el.setAttribute('diff-side', `${Side.RIGHT}`);
+    el.setAttribute('line-num', `${line}`);
+    if (
+      pointer.range?.start_line > 0 &&
+      pointer.range?.end_line > 0 &&
+      pointer.range?.start_character >= 0 &&
+      pointer.range?.end_character >= 0
+    ) {
+      el.setAttribute('range', `${JSON.stringify(pointer.range)}`);
+    }
+    this.$.diff.appendChild(el);
+    return el;
+  }
+
   _getCoverageData() {
     assertIsDefined(this.changeNum, 'changeNum');
     assertIsDefined(this.change, 'change');
@@ -538,7 +691,6 @@
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
     this.$.diff.cancel();
-    this.syntaxLayer.cancel();
   }
 
   getCursorStops() {
@@ -550,7 +702,7 @@
   }
 
   createRangeComment() {
-    return this.$.diff.createRangeComment();
+    this.$.diff.createRangeComment();
   }
 
   toggleLeftDiff() {
@@ -582,7 +734,11 @@
   }
 
   getThreadEls(): GrCommentThread[] {
-    return Array.from(this.$.diff.querySelectorAll('.comment-thread'));
+    return Array.from(this.$.diff.querySelectorAll('gr-comment-thread'));
+  }
+
+  getCheckEls(): GrDiffCheckResult[] {
+    return Array.from(this.$.diff.querySelectorAll('gr-diff-check-result'));
   }
 
   addDraftAtLine(el: Element) {
@@ -729,30 +885,68 @@
   }
 
   _threadsChanged(threads: CommentThread[]) {
-    const threadEls = new Set<GrCommentThread>();
     const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>();
+    const unsavedThreadEls: GrCommentThread[] = [];
     for (const threadEl of this.getThreadEls()) {
       if (threadEl.rootId) {
         rootIdToThreadEl.set(threadEl.rootId, threadEl);
+      } else {
+        // Unsaved thread els must have editing:true, just being defensive here.
+        if (threadEl.editing) unsavedThreadEls.push(threadEl);
       }
     }
+    const dontRemove = new Set<GrCommentThread>();
     for (const thread of threads) {
-      const existingThreadEl =
+      // Let's find an existing DOM element matching the thread. Normally this
+      // is as simple as matching the rootIds.
+      let existingThreadEl =
         thread.rootId && rootIdToThreadEl.get(thread.rootId);
-      if (existingThreadEl) {
-        this._updateThreadElement(existingThreadEl, thread);
-        threadEls.add(existingThreadEl);
+      // But unsaved threads don't have rootIds. The incoming thread might be
+      // the saved version of the unsaved thread element. To verify that we
+      // check that the thread only has one comment and that their location is
+      // identical.
+      // TODO(brohlfs): This matching is not perfect. You could quickly create
+      // two new threads on the same line/range. Then this code just makes a
+      // random guess.
+      if (!existingThreadEl && thread.comments?.length === 1) {
+        for (const unsavedThreadEl of unsavedThreadEls) {
+          if (equalLocation(unsavedThreadEl.thread, thread)) {
+            existingThreadEl = unsavedThreadEl;
+            break;
+          }
+        }
+      }
+      // There is a case possible where the rootIds match but the locations
+      // are different. Such as when a thread was originally attached on the
+      // right side of the diff but now should be attached on the left side of
+      // the diff.
+      // There is another case possible where the original thread element was
+      // associated with a ported thread, hence had the LineNum set to LOST.
+      // In this case we cannot reuse the thread element if the same thread
+      // now is being attached in it's proper location since the LineNum needs
+      // to be updated hence create a new thread element.
+      if (
+        existingThreadEl &&
+        existingThreadEl.getAttribute('diff-side') ===
+          this.getDiffSide(thread) &&
+        existingThreadEl.thread!.ported === thread.ported
+      ) {
+        existingThreadEl.thread = thread;
+        dontRemove.add(existingThreadEl);
       } else {
         const threadEl = this._createThreadElement(thread);
         this._attachThreadElement(threadEl);
-        threadEls.add(threadEl);
+        dontRemove.add(threadEl);
       }
     }
     // Remove all threads that are no longer existing.
     for (const threadEl of this.getThreadEls()) {
-      if (threadEls.has(threadEl)) continue;
-      const parent = threadEl.parentNode;
-      if (parent) parent.removeChild(threadEl);
+      if (dontRemove.has(threadEl)) continue;
+      // The user may have opened a couple of comment boxes for editing. They
+      // might be unsaved and thus not be reflected in `threads` yet, so let's
+      // keep them open.
+      if (threadEl.editing && threadEl.thread?.comments.length === 0) continue;
+      threadEl.remove();
     }
     const portedThreadsCount = threads.filter(thread => thread.ported).length;
     const portedThreadsWithoutRange = threads.filter(
@@ -780,10 +974,10 @@
     );
   }
 
-  _handleCreateComment(e: CustomEvent<CreateCommentEventDetail>) {
+  _handleCreateThread(e: CustomEvent<CreateCommentEventDetail>) {
     if (!this.patchRange) throw Error('patch range not set');
 
-    const {lineNum, side, range, path} = e.detail;
+    const {lineNum, side, range} = e.detail;
 
     // Usually, the comment is stored on the patchset shown on the side the
     // user added the comment on, and the commentSide will be REVISION.
@@ -801,18 +995,27 @@
         ? CommentSide.PARENT
         : CommentSide.REVISION;
     if (!this.canCommentOnPatchSetNum(patchNum)) return;
-    const threadEl = this._getOrCreateThread({
+    const path =
+      this.file?.basePath &&
+      side === Side.LEFT &&
+      commentSide === CommentSide.REVISION
+        ? this.file?.basePath
+        : this.path;
+    assertIsDefined(path, 'path');
+
+    const newThread: CommentThread = {
+      rootId: undefined,
       comments: [],
-      path,
-      diffSide: side,
-      commentSide,
       patchNum,
+      commentSide,
+      // TODO: Maybe just compute from patchRange.base on the fly?
+      mergeParentNum: this._parentIndex ?? undefined,
+      path,
       line: lineNum,
       range,
-    });
-    threadEl.addOrEditDraft(lineNum, range);
-
-    this.reporting.recordDraftInteraction();
+    };
+    const el = this._createThreadElement(newThread);
+    this._attachThreadElement(el);
   }
 
   private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
@@ -841,94 +1044,48 @@
     return true;
   }
 
-  /**
-   * Gets or creates a comment thread at a given location.
-   * May provide a range, to get/create a range comment.
-   */
-  _getOrCreateThread(thread: CommentThread): GrCommentThread {
-    let threadEl = this._getThreadEl(thread);
-    if (!threadEl) {
-      threadEl = this._createThreadElement(thread);
-      this._attachThreadElement(threadEl);
-    } else {
-      this._updateThreadElement(threadEl, thread);
-    }
-    return threadEl;
-  }
-
   _attachThreadElement(threadEl: Element) {
     this.$.diff.appendChild(threadEl);
   }
 
-  _clearThreads() {
-    for (const threadEl of this.getThreadEls()) {
-      const parent = threadEl.parentNode;
-      if (parent) parent.removeChild(threadEl);
+  private getDiffSide(thread: CommentThread) {
+    let diffSide: Side;
+    assertIsDefined(this.patchRange, 'patchRange');
+    const commentProps = {
+      patch_set: thread.patchNum,
+      side: thread.commentSide,
+      parent: thread.mergeParentNum,
+    };
+    if (isInBaseOfPatchRange(commentProps, this.patchRange)) {
+      diffSide = Side.LEFT;
+    } else if (isInRevisionOfPatchRange(commentProps, this.patchRange)) {
+      diffSide = Side.RIGHT;
+    } else {
+      const propsStr = JSON.stringify(commentProps);
+      const rangeStr = JSON.stringify(this.patchRange);
+      throw new Error(`comment ${propsStr} not in range ${rangeStr}`);
     }
+    return diffSide;
   }
 
   _createThreadElement(thread: CommentThread) {
+    const diffSide = this.getDiffSide(thread);
+
     const threadEl = document.createElement('gr-comment-thread');
     threadEl.className = 'comment-thread';
-    threadEl.setAttribute(
-      'slot',
-      `${thread.diffSide}-${thread.line || 'LOST'}`
-    );
-    this._updateThreadElement(threadEl, thread);
-    return threadEl;
-  }
-
-  _updateThreadElement(threadEl: GrCommentThread, thread: CommentThread) {
-    threadEl.comments = thread.comments;
-    threadEl.diffSide = thread.diffSide;
-    threadEl.isOnParent = thread.commentSide === CommentSide.PARENT;
-    threadEl.parentIndex = this._parentIndex;
-    // Use path before renmaing when comment added on the left when comparing
-    // two patch sets (not against base)
-    if (
-      this.file &&
-      this.file.basePath &&
-      thread.diffSide === Side.LEFT &&
-      !threadEl.isOnParent
-    ) {
-      threadEl.path = this.file.basePath;
-    } else {
-      threadEl.path = this.path;
-    }
-    threadEl.changeNum = this.changeNum;
-    threadEl.patchNum = thread.patchNum;
+    threadEl.rootId = thread.rootId;
+    threadEl.thread = thread;
     threadEl.showPatchset = false;
     threadEl.showPortedComment = !!thread.ported;
-    if (thread.rangeInfoLost) threadEl.lineNum = 'LOST';
-    // GrCommentThread does not understand 'FILE', but requires undefined.
-    else threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
-    threadEl.projectName = this.projectName;
-    threadEl.range = thread.range;
-  }
-
-  /**
-   * Gets a comment thread element at a given location.
-   * May provide a range, to get a range comment.
-   */
-  _getThreadEl(thread: CommentThread): GrCommentThread | null {
-    let line: LineInfo;
-    if (thread.diffSide === Side.LEFT) {
-      line = {beforeNumber: thread.line};
-    } else if (thread.diffSide === Side.RIGHT) {
-      line = {afterNumber: thread.line};
-    } else {
-      throw new Error(`Unknown side: ${thread.diffSide}`);
+    // These attributes are the "interface" between comment threads and gr-diff.
+    // <gr-comment-thread> does not care about them and is not affected by them.
+    threadEl.setAttribute('slot', `${diffSide}-${thread.line || 'LOST'}`);
+    threadEl.setAttribute('diff-side', `${diffSide}`);
+    threadEl.setAttribute('line-num', `${thread.line || 'LOST'}`);
+    if (thread.range) {
+      threadEl.setAttribute('range', `${JSON.stringify(thread.range)}`);
     }
-    function matchesRange(threadEl: GrCommentThread) {
-      return rangesEqual(getRange(threadEl), thread.range);
-    }
-
-    const filteredThreadEls = this._filterThreadElsForLocation(
-      this.getThreadEls(),
-      line,
-      thread.diffSide
-    ).filter(matchesRange);
-    return filteredThreadEls.length ? filteredThreadEls[0] : null;
+    return threadEl;
   }
 
   _filterThreadElsForLocation(
@@ -1044,10 +1201,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;
@@ -1055,33 +1212,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);
-  }
-
-  _handleRenderStart() {
-    this.reporting.time(Timing.DIFF_TOTAL);
-    this.reporting.time(Timing.DIFF_CONTENT);
-  }
-
-  _handleRenderContent() {
-    this.reporting.timeEnd(Timing.DIFF_CONTENT);
-  }
-
-  _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,
@@ -1176,8 +1306,6 @@
     'normalize-range': CustomEvent;
     'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
     'create-comment': CustomEvent;
-    'comment-update': CustomEvent;
-    'comment-save': CustomEvent;
     'root-id-changed': CustomEvent;
   }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
index e4efb5a..93b6157 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
@@ -19,7 +19,6 @@
 export const htmlTemplate = html`
   <gr-diff
     id="diff"
-    change-num="[[changeNum]]"
     no-auto-render="[[noAutoRender]]"
     path="[[path]]"
     prefs="[[prefs]]"
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 6facdca..6f8b1a4 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
@@ -20,13 +20,12 @@
 
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {createDefaultDiffPrefs, Side} from '../../../constants/constants.js';
-import {_testOnly_resetState} from '../../../services/comments/comments-model.js';
 import {createChange, createComment, createCommentThread} from '../../../test/test-data-generators.js';
 import {addListenerForTest, mockPromise, stubRestApi} 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 '../gr-diff-builder/gr-diff-builder-image.js';
+import {GrDiffBuilderImage} from '../../../embed/diff/gr-diff-builder/gr-diff-builder-image.js';
 
 const basicFixture = fixtureFromElement('gr-diff-host');
 
@@ -43,7 +42,6 @@
     element.path = 'some/path';
     sinon.stub(element.reporting, 'time');
     sinon.stub(element.reporting, 'timeEnd');
-    _testOnly_resetState();
     await flush();
   });
 
@@ -65,22 +63,6 @@
   });
 
   suite('render reporting', () => {
-    test('starts total and content timer on render-start', () => {
-      element.dispatchEvent(
-          new CustomEvent('render-start', {bubbles: true, composed: true}));
-      assert.isTrue(element.reporting.time.calledWithExactly(
-          'Diff Total Render'));
-      assert.isTrue(element.reporting.time.calledWithExactly(
-          'Diff Content Render'));
-    });
-
-    test('ends content timer on render-content', () => {
-      element.dispatchEvent(
-          new CustomEvent('render-content', {bubbles: true, composed: true}));
-      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
-          'Diff Content Render'));
-    });
-
     test('ends total and syntax timer after syntax layer', async () => {
       sinon.stub(element.reporting, 'diffViewContentDisplayed');
       let notifySyntaxProcessed;
@@ -99,30 +81,15 @@
       notifySyntaxProcessed();
       // Multiple cascading microtasks are scheduled.
       await flush();
-      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
-          'Diff Total Render'));
-      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
-          'Diff Syntax Render'));
+      const calls = element.reporting.timeEnd.getCalls();
+      assert.equal(calls.length, 4);
+      assert.equal(calls[0].args[0], 'Diff Load Render');
+      assert.equal(calls[1].args[0], 'Diff Content Render');
+      assert.equal(calls[2].args[0], 'Diff Syntax Render');
+      assert.equal(calls[3].args[0], 'Diff Total Render');
       assert.isTrue(element.reporting.diffViewContentDisplayed.called);
     });
 
-    test('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();
-      // Reporting can be called with other parameters (ex. PluginsLoaded),
-      // but only 'Diff Total Render' is important in this test.
-      assert.equal(
-          element.reporting.timeEnd.getCalls()
-              .filter(call => call.calledWithExactly('Diff Total Render'))
-              .length,
-          1);
-    });
-
     test('completes reload promise after syntax layer processing', async () => {
       let notifySyntaxProcessed;
       sinon.stub(element.syntaxLayer, 'process').returns(new Promise(
@@ -749,7 +716,7 @@
   });
 
   test('getThreadEls() returns .comment-threads', () => {
-    const threadEl = document.createElement('div');
+    const threadEl = document.createElement('gr-comment-thread');
     threadEl.className = 'comment-thread';
     element.$.diff.appendChild(threadEl);
     assert.deepEqual(element.getThreadEls(), [threadEl]);
@@ -778,11 +745,6 @@
     assert.equal(stub.lastCall.args.length, 0);
   });
 
-  test('passes in changeNum', () => {
-    element.changeNum = 12345;
-    assert.equal(element.$.diff.changeNum, 12345);
-  });
-
   test('passes in noAutoRender', () => {
     const value = true;
     element.noAutoRender = value;
@@ -801,11 +763,6 @@
     assert.equal(element.$.diff.prefs, value);
   });
 
-  test('passes in changeNum', () => {
-    element.changeNum = 12345;
-    assert.equal(element.$.diff.changeNum, 12345);
-  });
-
   test('passes in displayLine', () => {
     const value = true;
     element.displayLine = value;
@@ -838,7 +795,7 @@
   });
 
   test('passes in lineOfInterest', () => {
-    const value = {number: 123, leftSide: true};
+    const value = {lineNum: 123, side: Side.LEFT};
     element.lineOfInterest = value;
     assert.equal(element.$.diff.lineOfInterest, value);
   });
@@ -942,6 +899,69 @@
     });
   });
 
+  suite('createCheckEl method', () => {
+    test('start_line:12', () => {
+      const result = {
+        codePointers: [{range: {start_line: 12}}],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-12');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), '12');
+      assert.equal(el.getAttribute('range'), null);
+      assert.equal(el.result, result);
+    });
+
+    test('start_line:13 end_line:14 without char positions', () => {
+      const result = {
+        codePointers: [{range: {start_line: 13, end_line: 14}}],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-14');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), '14');
+      assert.equal(el.getAttribute('range'), null);
+      assert.equal(el.result, result);
+    });
+
+    test('start_line:13 end_line:14 with char positions', () => {
+      const result = {
+        codePointers: [
+          {
+            range: {
+              start_line: 13,
+              end_line: 14,
+              start_character: 5,
+              end_character: 7,
+            },
+          },
+        ],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-14');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), '14');
+      assert.equal(el.getAttribute('range'),
+          '{"start_line":13,' +
+          '"end_line":14,' +
+          '"start_character":5,' +
+          '"end_character":7}');
+      assert.equal(el.result, result);
+    });
+
+    test('empty range', () => {
+      const result = {
+        codePointers: [{range: {}}],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-FILE');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), 'FILE');
+      assert.equal(el.getAttribute('range'), null);
+      assert.equal(el.result, result);
+    });
+  });
+
   suite('create-comment', () => {
     setup(async () => {
       loggedIn = true;
@@ -950,7 +970,6 @@
     });
 
     test('creates comments if they do not exist yet', () => {
-      const diffSide = Side.LEFT;
       element.patchRange = {
         basePatchNum: 'PARENT',
         patchNum: 2,
@@ -959,7 +978,7 @@
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
           lineNum: 3,
-          side: diffSide,
+          side: Side.LEFT,
           path: '/p',
         },
       }));
@@ -968,10 +987,10 @@
           .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
-      assert.equal(threads[0].diffSide, diffSide);
-      assert.isTrue(threads[0].isOnParent);
-      assert.equal(threads[0].range, undefined);
-      assert.equal(threads[0].patchNum, 2);
+      assert.equal(threads[0].thread.commentSide, 'PARENT');
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[0].thread.range, undefined);
+      assert.equal(threads[0].thread.patchNum, 2);
 
       // Try to fetch a thread with a different range.
       const range = {
@@ -988,7 +1007,7 @@
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
           lineNum: 1,
-          side: diffSide,
+          side: Side.LEFT,
           path: '/p',
           range,
         },
@@ -998,10 +1017,10 @@
           .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 2);
-      assert.equal(threads[1].diffSide, diffSide);
-      assert.isTrue(threads[0].isOnParent);
-      assert.equal(threads[1].range, range);
-      assert.equal(threads[1].patchNum, 3);
+      assert.equal(threads[0].thread.commentSide, 'PARENT');
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[1].thread.range, range);
+      assert.equal(threads[1].thread.patchNum, 3);
     });
 
     test('should not be on parent if on the right', () => {
@@ -1016,10 +1035,11 @@
         },
       }));
 
-      const thread = dom(element.$.diff)
+      const threadEl = dom(element.$.diff)
           .queryDistributedElements('gr-comment-thread')[0];
 
-      assert.isFalse(thread.isOnParent);
+      assert.equal(threadEl.thread.commentSide, 'REVISION');
+      assert.equal(threadEl.getAttribute('diff-side'), Side.RIGHT);
     });
 
     test('should be on parent if right and base is PARENT', () => {
@@ -1034,10 +1054,11 @@
         },
       }));
 
-      const thread = dom(element.$.diff)
+      const threadEl = dom(element.$.diff)
           .queryDistributedElements('gr-comment-thread')[0];
 
-      assert.isTrue(thread.isOnParent);
+      assert.equal(threadEl.thread.commentSide, 'PARENT');
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
     });
 
     test('should be on parent if right and base negative', () => {
@@ -1052,10 +1073,11 @@
         },
       }));
 
-      const thread = dom(element.$.diff)
+      const threadEl = dom(element.$.diff)
           .queryDistributedElements('gr-comment-thread')[0];
 
-      assert.isTrue(thread.isOnParent);
+      assert.equal(threadEl.thread.commentSide, 'PARENT');
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
     });
 
     test('should not be on parent otherwise', () => {
@@ -1070,24 +1092,25 @@
         },
       }));
 
-      const thread = dom(element.$.diff)
+      const threadEl = dom(element.$.diff)
           .queryDistributedElements('gr-comment-thread')[0];
 
-      assert.isFalse(thread.isOnParent);
+      assert.equal(threadEl.thread.commentSide, 'REVISION');
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
     });
 
     test('thread should use old file path if first created ' +
-    'on patch set (left) before renaming', () => {
-      const diffSide = Side.LEFT;
+    'on patch set (left) before renaming', async () => {
       element.patchRange = {
         basePatchNum: 2,
         patchNum: 3,
       };
       element.file = {basePath: 'file_renamed.txt', path: element.path};
+      await flush();
 
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
-          side: diffSide,
+          side: Side.LEFT,
           path: '/p',
         },
       }));
@@ -1096,22 +1119,22 @@
           .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
-      assert.equal(threads[0].diffSide, diffSide);
-      assert.equal(threads[0].path, element.file.basePath);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[0].thread.path, element.file.basePath);
     });
 
-    test('thread should use new file path if first created' +
-    'on patch set (right) after renaming', () => {
-      const diffSide = Side.RIGHT;
+    test('thread should use new file path if first created ' +
+    'on patch set (right) after renaming', async () => {
       element.patchRange = {
         basePatchNum: 2,
         patchNum: 3,
       };
       element.file = {basePath: 'file_renamed.txt', path: element.path};
+      await flush();
 
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
-          side: diffSide,
+          side: Side.RIGHT,
           path: '/p',
         },
       }));
@@ -1120,23 +1143,27 @@
           .queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
-      assert.equal(threads[0].diffSide, diffSide);
-      assert.equal(threads[0].path, element.file.path);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.RIGHT);
+      assert.equal(threads[0].thread.path, element.file.path);
     });
 
-    test('multiple threads created on the same range', () => {
+    test('multiple threads created on the same range', async () => {
       element.patchRange = {
         basePatchNum: 2,
         patchNum: 3,
       };
       element.file = {basePath: 'file_renamed.txt', path: element.path};
+      await flush();
 
-      const comment = createComment();
-      comment.range = {
-        start_line: 1,
-        start_character: 1,
-        end_line: 2,
-        end_character: 2,
+      const comment = {
+        ...createComment(),
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 2,
+        },
+        patch_set: 3,
       };
       const thread = createCommentThread([comment]);
       element.threads = [thread];
@@ -1161,18 +1188,59 @@
       assert.equal(threads.length, 2);
     });
 
-    test('thread should use new file path if first created' +
-    'on patch set (left) but is base', () => {
-      const diffSide = Side.LEFT;
+    test('unsaved thread changes to draft', async () => {
+      element.patchRange = {
+        basePatchNum: 2,
+        patchNum: 3,
+      };
+      element.file = {basePath: 'file_renamed.txt', path: element.path};
+      element.threads = [];
+      await flush();
+
+      element.dispatchEvent(new CustomEvent('create-comment', {
+        detail: {
+          side: Side.RIGHT,
+          path: element.path,
+          lineNum: 13,
+        },
+      }));
+      await flush();
+      assert.equal(element.getThreadEls().length, 1);
+      const threadEl = element.getThreadEls()[0];
+      assert.equal(threadEl.thread.line, 13);
+      assert.isDefined(threadEl.unsavedComment);
+      assert.equal(threadEl.thread.comments.length, 0);
+
+      const draftThread = createCommentThread([{
+        path: element.path,
+        patch_set: 3,
+        line: 13,
+        __draft: true,
+      }]);
+      element.threads = [draftThread];
+      await flush();
+
+      // We expect that no additional thread element was created.
+      assert.equal(element.getThreadEls().length, 1);
+      // In fact the thread element must still be the same.
+      assert.equal(element.getThreadEls()[0], threadEl);
+      // But it must have been updated from unsaved to draft:
+      assert.isUndefined(threadEl.unsavedComment);
+      assert.equal(threadEl.thread.comments.length, 1);
+    });
+
+    test('thread should use new file path if first created ' +
+    'on patch set (left) but is base', async () => {
       element.patchRange = {
         basePatchNum: 'PARENT',
         patchNum: 3,
       };
       element.file = {basePath: 'file_renamed.txt', path: element.path};
+      await flush();
 
       element.dispatchEvent(new CustomEvent('create-comment', {
         detail: {
-          side: diffSide,
+          side: Side.LEFT,
           path: '/p',
         },
       }));
@@ -1181,8 +1249,8 @@
           dom(element.$.diff).queryDistributedElements('gr-comment-thread');
 
       assert.equal(threads.length, 1);
-      assert.equal(threads[0].diffSide, diffSide);
-      assert.equal(threads[0].path, element.file.path);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[0].thread.path, element.file.path);
     });
 
     test('cannot create thread on an edit', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
deleted file mode 100644
index b47c51c..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ /dev/null
@@ -1,106 +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 '@polymer/iron-icon/iron-icon';
-import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import '../../../styles/shared-styles';
-import '../../shared/gr-button/gr-button';
-import {DiffViewMode} from '../../../constants/constants';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-mode-selector_html';
-import {customElement, property} from '@polymer/decorators';
-import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {FixIronA11yAnnouncer} from '../../../types/types';
-import {appContext} from '../../../services/app-context';
-import {fireIronAnnounce} from '../../../utils/event-util';
-
-@customElement('gr-diff-mode-selector')
-export class GrDiffModeSelector extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: String, notify: true})
-  mode?: DiffViewMode;
-
-  /**
-   * 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})
-  showTooltipBelow = false;
-
-  private readonly userService = appContext.userService;
-
-  override connectedCallback() {
-    super.connectedCallback();
-    (
-      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
-    ).requestAvailability();
-  }
-
-  /**
-   * Set the mode. If save on change is enabled also update the preference.
-   */
-  setMode(newMode: DiffViewMode) {
-    if (this.saveOnChange && this.mode && this.mode !== newMode) {
-      this.userService.updatePreferences({diff_view: newMode});
-    }
-    this.mode = newMode;
-    let announcement;
-    if (this.isUnifiedSelected(newMode)) {
-      announcement = 'Changed diff view to unified';
-    } else if (this.isSideBySideSelected(newMode)) {
-      announcement = 'Changed diff view to side by side';
-    }
-    if (announcement) {
-      fireIronAnnounce(this, announcement);
-    }
-  }
-
-  _computeSideBySideSelected(mode?: DiffViewMode) {
-    return mode === DiffViewMode.SIDE_BY_SIDE ? 'selected' : '';
-  }
-
-  _computeUnifiedSelected(mode?: DiffViewMode) {
-    return mode === DiffViewMode.UNIFIED ? 'selected' : '';
-  }
-
-  isSideBySideSelected(mode?: DiffViewMode) {
-    return mode === DiffViewMode.SIDE_BY_SIDE;
-  }
-
-  isUnifiedSelected(mode?: DiffViewMode) {
-    return mode === DiffViewMode.UNIFIED;
-  }
-
-  _handleSideBySideTap() {
-    this.setMode(DiffViewMode.SIDE_BY_SIDE);
-  }
-
-  _handleUnifiedTap() {
-    this.setMode(DiffViewMode.UNIFIED);
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-mode-selector': GrDiffModeSelector;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
deleted file mode 100644
index 8a6d95d..0000000
--- a/polygerrit-ui/app/elements/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/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
deleted file mode 100644
index 8b06c75..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
+++ /dev/null
@@ -1,73 +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';
-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');
-
-suite('gr-diff-mode-selector tests', () => {
-  let element: GrDiffModeSelector;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_computeSelectedClass', () => {
-    assert.equal(
-      element._computeSideBySideSelected(DiffViewMode.SIDE_BY_SIDE),
-      'selected'
-    );
-    assert.equal(element._computeSideBySideSelected(DiffViewMode.UNIFIED), '');
-    assert.equal(
-      element._computeUnifiedSelected(DiffViewMode.UNIFIED),
-      'selected'
-    );
-    assert.equal(
-      element._computeUnifiedSelected(DiffViewMode.SIDE_BY_SIDE),
-      ''
-    );
-  });
-
-  test('setMode', () => {
-    const saveStub = stubUsers('updatePreferences');
-
-    // Setting the mode initially does not save prefs.
-    element.saveOnChange = true;
-    element.setMode(DiffViewMode.SIDE_BY_SIDE);
-    assert.isFalse(saveStub.called);
-
-    // Setting the mode to itself does not save prefs.
-    element.setMode(DiffViewMode.SIDE_BY_SIDE);
-    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);
-    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);
-    assert.isTrue(saveStub.calledOnce);
-  });
-});
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 5a8c55d..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,92 +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();
     });
   }
 
-  _handleSaveDiffPreferences() {
-    this.diffPrefs = this._editableDiffPrefs;
-    this.$.diffPreferences.save().then(() => {
-      this.dispatchEvent(
-        new CustomEvent('reload-diff-preference', {
-          composed: true,
-          bubbles: false,
-        })
-      );
-
-      this.$.diffPrefsOverlay.close();
-    });
+  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();
   }
+
+  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 5c62e7a..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;
 
+    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 flush();
-    assert.isTrue(element.$.diffPreferences.$.lineWrappingInput.checked);
+    await element.updateComplete;
+    assert.isUndefined(element.diffPrefsChanged);
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(
+        queryAndAssert(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);
+    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-selection/gr-diff-selection_test.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
deleted file mode 100644
index 15454f9..0000000
--- a/polygerrit-ui/app/elements/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/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 87d28f2..1c64a7e 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
@@ -25,14 +25,15 @@
 import '../../shared/gr-select/gr-select';
 import '../../shared/revision-info/revision-info';
 import '../gr-comment-api/gr-comment-api';
-import '../gr-diff-cursor/gr-diff-cursor';
+import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import '../gr-apply-fix-dialog/gr-apply-fix-dialog';
 import '../gr-diff-host/gr-diff-host';
-import '../gr-diff-mode-selector/gr-diff-mode-selector';
+import '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../gr-patch-range-select/gr-patch-range-select';
+import '../../change/gr-download-dialog/gr-download-dialog';
+import '../../shared/gr-overlay/gr-overlay';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-view_html';
 import {
   KeyboardShortcutMixin,
@@ -44,7 +45,7 @@
   GeneratedWebLink,
   GerritNav,
 } from '../../core/gr-navigation/gr-navigation';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
@@ -59,20 +60,19 @@
 } from '../../../utils/path-list-util';
 import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
 import {customElement, observe, property} from '@polymer/decorators';
-import {GrDiffHost} from '../gr-diff-host/gr-diff-host';
+import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
 import {
   DropdownItem,
   GrDropdownList,
 } from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {ChangeComments, GrCommentApi} from '../gr-comment-api/gr-comment-api';
-import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import {
   BasePatchSetNum,
   ChangeInfo,
   CommitId,
   ConfigInfo,
-  EditInfo,
   EditPatchSetNum,
   FileInfo,
   NumericChangeId,
@@ -83,36 +83,56 @@
   RepoName,
   RevisionInfo,
   RevisionPatchSetNum,
+  ServerInfo,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {ChangeViewState, CommitRange, FileRange} from '../../../types/types';
+import {
+  ChangeViewState,
+  CommitRange,
+  EditRevisionInfo,
+  FileRange,
+  ParsedChangeInfo,
+} from '../../../types/types';
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {GrDiffCursor} from '../gr-diff-cursor/gr-diff-cursor';
+import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
 import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
-import {LineOfInterest} from '../gr-diff/gr-diff';
 import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
 import {
   CommentMap,
   getPatchRangeForCommentUrl,
   isInBaseOfPatchRange,
 } from '../../../utils/comment-util';
-import {AppElementParams} from '../../gr-app-types';
-import {EventType, OpenFixPreviewEvent} from '../../../types/events';
-import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
+import {AppElementDiffViewParam, AppElementParams} from '../../gr-app-types';
+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';
 import {CursorMoveResult} from '../../../api/core';
-import {throttleWrap} from '../../../utils/async-util';
-import {changeComments$} from '../../../services/comments/comments-model';
-import {takeUntil} from 'rxjs/operators';
-import {Subject} from 'rxjs';
-import {preferences$} from '../../../services/user/user-model';
+import {isFalse, throttleWrap, until} from '../../../utils/async-util';
+import {filter, take, switchMap} from 'rxjs/operators';
+import {combineLatest, Subscription} from 'rxjs';
 import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {LoadingStatus} from '../../../models/change/change-model';
+import {DisplayLine} from '../../../api/diff';
+import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog';
+import {browserModelToken} from '../../../models/browser/browser-model';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {BehaviorSubject} from 'rxjs';
 
-const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const LOADING_BLAME = 'Loading blame...';
 const LOADED_BLAME = 'Blame loaded';
 
@@ -131,18 +151,19 @@
 
 export interface GrDiffView {
   $: {
-    commentAPI: GrCommentApi;
     diffHost: GrDiffHost;
     reviewed: HTMLInputElement;
     dropdown: GrDropdownList;
     diffPreferencesDialog: GrOverlay;
     applyFixDialog: GrApplyFixDialog;
     modeSelect: GrDiffModeSelector;
+    downloadOverlay: GrOverlay;
+    downloadDialog: GrDownloadDialog;
   };
 }
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
+const base = KeyboardShortcutMixin(DIPolymerElement);
 
 @customElement('gr-diff-view')
 export class GrDiffView extends base {
@@ -165,7 +186,7 @@
   @property({type: Object, observer: '_paramsChanged'})
   params?: AppElementParams;
 
-  @property({type: Object, notify: true, observer: '_changeViewStateChanged'})
+  @property({type: Object})
   changeViewState: Partial<ChangeViewState> = {};
 
   @property({type: Object})
@@ -175,7 +196,7 @@
   _commitRange?: CommitRange;
 
   @property({type: Object})
-  _change?: ChangeInfo;
+  _change?: ParsedChangeInfo;
 
   @property({type: Object})
   _changeComments?: ChangeComments;
@@ -220,13 +241,10 @@
   _projectConfig?: ConfigInfo;
 
   @property({type: Object})
-  _userPrefs?: PreferencesInfo;
+  _serverConfig?: ServerInfo;
 
-  @property({
-    type: String,
-    computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
-  })
-  _diffMode?: string;
+  @property({type: Object})
+  _userPrefs?: PreferencesInfo;
 
   @property({type: Boolean})
   _isImageDiff?: boolean;
@@ -264,27 +282,21 @@
   @property({type: Object, computed: '_getRevisionInfo(_change)'})
   _revisionInfo?: RevisionInfoObj;
 
-  @property({type: Object})
-  _reviewedFiles = new Set<string>();
-
   @property({type: Number})
   _focusLineNum?: number;
 
-  private getReviewedParams: {
-    changeNum?: NumericChangeId;
-    patchNum?: PatchSetNum;
-  } = {};
-
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
 
+  private reviewedFiles = new Set<string>();
+
   override keyboardShortcuts(): ShortcutListener[] {
     return [
-      listen(Shortcut.LEFT_PANE, _ => this.cursor.moveLeft()),
-      listen(Shortcut.RIGHT_PANE, _ => this.cursor.moveRight()),
+      listen(Shortcut.LEFT_PANE, _ => this.cursor?.moveLeft()),
+      listen(Shortcut.RIGHT_PANE, _ => this.cursor?.moveRight()),
       listen(Shortcut.NEXT_LINE, _ => this._handleNextLine()),
       listen(Shortcut.PREV_LINE, _ => this._handlePrevLine()),
-      listen(Shortcut.VISIBLE_LINE, _ => this.cursor.moveToVisibleArea()),
+      listen(Shortcut.VISIBLE_LINE, _ => this.cursor?.moveToVisibleArea()),
       listen(Shortcut.NEXT_FILE_WITH_COMMENTS, _ =>
         this._moveToNextFileWithComment()
       ),
@@ -345,48 +357,131 @@
     ];
   }
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private readonly commentsService = appContext.commentsService;
+  // Private but used in tests.
+  readonly routerModel = getAppContext().routerModel;
 
-  private readonly shortcuts = appContext.shortcutsService;
+  // Private but used in tests.
+  readonly userModel = getAppContext().userModel;
+
+  // Private but used in tests.
+  readonly getChangeModel = resolve(this, changeModelToken);
+
+  // Private but used in tests.
+  readonly getBrowserModel = resolve(this, browserModelToken);
+
+  // Private but used in tests.
+  readonly getCommentsModel = resolve(this, commentsModelToken);
+
+  private readonly shortcuts = getAppContext().shortcutsService;
 
   _throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
 
   _onRenderHandler?: EventListener;
 
-  private cursor = new GrDiffCursor();
+  private cursor?: GrDiffCursor;
 
-  disconnected$ = new Subject();
+  private subscriptions: Subscription[] = [];
+
+  private connected$ = new BehaviorSubject(false);
 
   override connectedCallback() {
     super.connectedCallback();
+    this.connected$.next(true);
     this._throttledToggleFileReviewed = throttleWrap(_ =>
       this._handleToggleFileReviewed()
     );
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
-    // TODO(brohlfs): This just ensures that the userService is instantiated at
-    // all. We need the service to manage the model, but we are not making any
-    // direct calls. Will need to find a better solution to this problem ...
-    assertIsDefined(appContext.userService);
-
-    changeComments$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(changeComments => {
-        this._changeComments = changeComments;
-      });
-
-    preferences$.pipe(takeUntil(this.disconnected$)).subscribe(preferences => {
-      this._userPrefs = preferences;
+    this.restApiService.getConfig().then(config => {
+      this._serverConfig = config;
     });
+
+    this.subscriptions.push(
+      this.getCommentsModel().changeComments$.subscribe(changeComments => {
+        this._changeComments = changeComments;
+      })
+    );
+
+    this.subscriptions.push(
+      this.userModel.preferences$.subscribe(preferences => {
+        this._userPrefs = preferences;
+      })
+    );
+    this.subscriptions.push(
+      this.userModel.diffPreferences$.subscribe(diffPreferences => {
+        this._prefs = diffPreferences;
+      })
+    );
+    this.subscriptions.push(
+      this.getChangeModel().change$.subscribe(change => {
+        // The diff view is tied to a specfic change number, so don't update
+        // _change to undefined.
+        if (change) this._change = change;
+      })
+    );
+
+    this.subscriptions.push(
+      this.getChangeModel().reviewedFiles$.subscribe(reviewedFiles => {
+        this.reviewedFiles = new Set(reviewedFiles) ?? new Set();
+      })
+    );
+
+    this.subscriptions.push(
+      this.getChangeModel().diffPath$.subscribe(path => (this._path = path))
+    );
+
+    this.subscriptions.push(
+      combineLatest(
+        this.getChangeModel().diffPath$,
+        this.getChangeModel().reviewedFiles$
+      ).subscribe(([path, files]) => {
+        this.$.reviewed.checked = !!path && !!files && files.includes(path);
+      })
+    );
+
+    // When user initially loads the diff view, we want to autmatically mark
+    // the file as reviewed if they have it enabled. We can't observe these
+    // properties since the method will be called anytime a property updates
+    // but we only want to call this on the initial load.
+    this.subscriptions.push(
+      this.getChangeModel()
+        .diffPath$.pipe(
+          filter(diffPath => !!diffPath),
+          switchMap(() =>
+            combineLatest(
+              this.getChangeModel().currentPatchNum$,
+              this.routerModel.routerView$,
+              this.userModel.diffPreferences$,
+              this.getChangeModel().reviewedFiles$
+            ).pipe(
+              filter(
+                ([currentPatchNum, routerView, diffPrefs, reviewedFiles]) =>
+                  !!currentPatchNum &&
+                  routerView === GerritView.DIFF &&
+                  !!diffPrefs &&
+                  !!reviewedFiles
+              ),
+              take(1)
+            )
+          )
+        )
+        .subscribe(([currentPatchNum, _routerView, diffPrefs]) => {
+          this.setReviewedStatus(currentPatchNum!, diffPrefs);
+        })
+    );
+    this.subscriptions.push(
+      this.getChangeModel().diffPath$.subscribe(path => (this._path = path))
+    );
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
+    this.cursor = new GrDiffCursor();
     this.cursor.replaceDiffs([this.$.diffHost]);
     this._onRenderHandler = (_: Event) => {
-      this.cursor.reInitCursor();
+      this.cursor?.reInitCursor();
     };
     this.$.diffHost.addEventListener('render', this._onRenderHandler);
     this.cleanups.push(
@@ -398,16 +493,37 @@
   }
 
   override disconnectedCallback() {
-    this.disconnected$.next();
-    this.cursor.dispose();
+    this.cursor?.dispose();
     if (this._onRenderHandler) {
       this.$.diffHost.removeEventListener('render', this._onRenderHandler);
+      this._onRenderHandler = undefined;
     }
     for (const cleanup of this.cleanups) cleanup();
     this.cleanups = [];
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+    this.connected$.next(false);
     super.disconnectedCallback();
   }
 
+  /**
+   * Set initial review status of the file.
+   * automatically mark the file as reviewed if manual review is not set.
+   */
+
+  async setReviewedStatus(
+    currentPatchNum: PatchSetNum,
+    diffPrefs: DiffPreferencesInfo
+  ) {
+    const loggedIn = await this._getLoggedIn();
+    if (!loggedIn) return;
+    if (!diffPrefs.manual_review) {
+      this._setReviewed(true, currentPatchNum as RevisionPatchSetNum);
+    }
+  }
+
   @observe('_changeComments', '_path', '_patchRange')
   computeThreads(
     changeComments?: ChangeComments,
@@ -440,19 +556,6 @@
     });
   }
 
-  _getChangeDetail(changeNum: NumericChangeId) {
-    return this.restApiService.getDiffChangeDetail(changeNum).then(change => {
-      if (!change) throw new Error('Missing "change" in API response.');
-      this._change = change;
-      return change;
-    });
-  }
-
-  _getChangeEdit() {
-    assertIsDefined(this._changeNum, '_changeNum');
-    return this.restApiService.getChangeEdit(this._changeNum);
-  }
-
   _getSortedFileList(files?: Files) {
     if (!files) return [];
     return files.sortedFileList;
@@ -502,12 +605,6 @@
       });
   }
 
-  _getDiffPreferences() {
-    return this.restApiService.getDiffPreferences().then(prefs => {
-      this._prefs = prefs;
-    });
-  }
-
   _getPreferences() {
     return this.restApiService.getPreferences();
   }
@@ -518,31 +615,19 @@
     );
   }
 
-  _setReviewed(reviewed: boolean) {
+  _setReviewed(
+    reviewed: boolean,
+    patchNum: RevisionPatchSetNum | undefined = this._patchRange?.patchNum
+  ) {
     if (this._editMode) return;
-    this.$.reviewed.checked = reviewed;
-    if (!this._patchRange?.patchNum || !this._path) return;
+    if (!patchNum || !this._path || !this._changeNum) return;
     const path = this._path;
     // if file is already reviewed then do not make a saveReview request
-    if (this._reviewedFiles.has(path) && reviewed) return;
-    if (reviewed) this._reviewedFiles.add(path);
-    else this._reviewedFiles.delete(path);
-    this._saveReviewedState(reviewed).catch(err => {
-      if (this._reviewedFiles.has(path)) this._reviewedFiles.delete(path);
-      else this._reviewedFiles.add(path);
-      fireAlert(this, ERR_REVIEW_STATUS);
-      throw err;
-    });
-  }
-
-  _saveReviewedState(reviewed: boolean): Promise<Response | undefined> {
-    if (!this._changeNum) return Promise.resolve(undefined);
-    if (!this._patchRange?.patchNum) return Promise.resolve(undefined);
-    if (!this._path) return Promise.resolve(undefined);
-    return this.restApiService.saveFileReviewed(
+    if (this.reviewedFiles.has(path) && reviewed) return;
+    this.getChangeModel().setReviewedFilesStatus(
       this._changeNum,
-      this._patchRange?.patchNum,
-      this._path,
+      patchNum,
+      path,
       reviewed
     );
   }
@@ -553,7 +638,7 @@
 
   _handlePrevLine() {
     this.$.diffHost.displayLine = true;
-    this.cursor.moveUp();
+    this.cursor?.moveUp();
   }
 
   _onOpenFixPreview(e: OpenFixPreviewEvent) {
@@ -562,7 +647,7 @@
 
   _handleNextLine() {
     this.$.diffHost.displayLine = true;
-    this.cursor.moveDown();
+    this.cursor?.moveDown();
   }
 
   _moveToPreviousFileWithComment() {
@@ -606,7 +691,7 @@
 
   _handleNewComment() {
     this.classList.remove('hideComments');
-    this.cursor.createCommentInPlace();
+    this.cursor?.createCommentInPlace();
   }
 
   _handlePrevFile() {
@@ -622,14 +707,14 @@
   }
 
   _handleNextChunk() {
-    const result = this.cursor.moveToNextChunk();
-    if (result === CursorMoveResult.CLIPPED && this.cursor.isAtEnd()) {
+    const result = this.cursor?.moveToNextChunk();
+    if (result === CursorMoveResult.CLIPPED && this.cursor?.isAtEnd()) {
       this.showToastAndNavigateFile('next', 'n');
     }
   }
 
   _handleNextCommentThread() {
-    const result = this.cursor.moveToNextCommentThread();
+    const result = this.cursor?.moveToNextCommentThread();
     if (result === CursorMoveResult.CLIPPED) {
       this._navigateToNextFileWithCommentThread();
     }
@@ -663,25 +748,25 @@
   private navigateToUnreviewedFile(direction: string) {
     if (!this._path) return;
     if (!this._fileList) return;
-    if (!this._reviewedFiles) return;
+    if (!this.reviewedFiles) return;
     // Ensure that the currently viewed file always appears in unreviewedFiles
     // so we resolve the right "next" file.
     const unreviewedFiles = this._fileList.filter(
-      file => file === this._path || !this._reviewedFiles.has(file)
+      file => file === this._path || !this.reviewedFiles.has(file)
     );
 
     this._navToFile(this._path, unreviewedFiles, direction === 'next' ? 1 : -1);
   }
 
   _handlePrevChunk() {
-    this.cursor.moveToPreviousChunk();
-    if (this.cursor.isAtStart()) {
+    this.cursor?.moveToPreviousChunk();
+    if (this.cursor?.isAtStart()) {
       this.showToastAndNavigateFile('previous', 'p');
     }
   }
 
   _handlePrevCommentThread() {
-    this.cursor.moveToPreviousCommentThread();
+    this.cursor?.moveToPreviousCommentThread();
   }
 
   // Similar to gr-change-view._handleOpenReplyDialog
@@ -693,6 +778,9 @@
       }
 
       this.set('changeViewState.showReplyDialog', true);
+      fire(this, 'view-state-change-view-changed', {
+        value: this.changeViewState as ChangeViewState,
+      });
       this._navToChangeView();
     });
   }
@@ -702,8 +790,16 @@
   }
 
   _handleOpenDownloadDialog() {
-    this.set('changeViewState.showDownloadDialog', true);
-    this._navToChangeView();
+    this.$.downloadOverlay.open().then(() => {
+      this.$.downloadOverlay.setFocusStops(
+        this.$.downloadDialog.getFocusStops()
+      );
+      this.$.downloadDialog.focus();
+    });
+  }
+
+  _handleDownloadDialogClose() {
+    this.$.downloadOverlay.close();
   }
 
   _handleUpToChange() {
@@ -716,10 +812,13 @@
   }
 
   _handleToggleDiffMode() {
-    if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
+    if (!this._userPrefs) return;
+    if (this._userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) {
+      this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
+      this.userModel.updatePreferences({
+        diff_view: DiffViewMode.SIDE_BY_SIDE,
+      });
     }
   }
 
@@ -810,7 +909,7 @@
     if (!this._patchRange) return;
 
     // TODO(taoalpha): add a shortcut for editing
-    const cursorAddress = this.cursor.getAddress();
+    const cursorAddress = this.cursor?.getAddress();
     const editUrl = GerritNav.getEditUrlForDiff(
       this._change,
       this._path,
@@ -856,28 +955,6 @@
     return {path: fileList[idx]};
   }
 
-  _getReviewedFiles(changeNum?: NumericChangeId, patchNum?: PatchSetNum) {
-    if (!changeNum || !patchNum) return;
-    if (
-      this.getReviewedParams.changeNum === changeNum &&
-      this.getReviewedParams.patchNum === patchNum
-    ) {
-      return;
-    }
-    this.getReviewedParams = {
-      changeNum,
-      patchNum,
-    };
-    this.restApiService.getReviewedFiles(changeNum, patchNum).then(files => {
-      this._reviewedFiles = new Set(files);
-    });
-  }
-
-  _getReviewedStatus(path: string) {
-    if (this._editMode) return false;
-    return this._reviewedFiles.has(path);
-  }
-
   _initLineOfInterestAndCursor(leftSide: boolean) {
     this.$.diffHost.lineOfInterest = this._getLineOfInterest(leftSide);
     this._initCursor(leftSide);
@@ -975,7 +1052,7 @@
         GerritNav.navigateToChange(this._change);
         return;
       }
-      this._path = comment.path;
+      this.getChangeModel().updatePath(comment.path);
 
       const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
       if (!latestPatchNum) throw new Error('Missing _allPatchSets');
@@ -985,7 +1062,7 @@
       this._focusLineNum = comment.line;
     } else {
       if (this.params.path) {
-        this._path = this.params.path;
+        this.getChangeModel().updatePath(this.params.path);
       }
       if (this.params.patchNum) {
         this._patchRange = {
@@ -1017,21 +1094,61 @@
     );
   }
 
+  private isSameDiffLoaded(value: AppElementDiffViewParam) {
+    return (
+      this._patchRange?.basePatchNum === value.basePatchNum &&
+      this._patchRange?.patchNum === value.patchNum &&
+      this._path === value.path
+    );
+  }
+
+  private async untilModelLoaded() {
+    // NOTE: Wait until this page is connected before determining whether the
+    // model is loaded.  This can happen when params are changed when setting up
+    // this view. It's unclear whether this issue is related to Polymer
+    // specifically.
+    if (!this.isConnected) {
+      await until(this.connected$, connected => connected);
+    }
+    await until(
+      this.getChangeModel().changeLoadingStatus$,
+      status => status === LoadingStatus.LOADED
+    );
+  }
+
   _paramsChanged(value: AppElementParams) {
     if (value.view !== GerritView.DIFF) {
       return;
     }
 
+    // The diff view is kept in the background once created. If the user
+    // scrolls in the change page, the scrolling is reflected in the diff view
+    // as well, which means the diff is scrolled to a random position based
+    // on how much the change view was scrolled.
+    // Hence, reset the scroll position here.
+    document.documentElement.scrollTop = 0;
+
     // Everything in the diff view is tied to the change. It seems better to
     // force the re-creation of the diff view when the change number changes.
     const changeChanged = this._changeNum !== value.changeNum;
     if (this._changeNum !== undefined && changeChanged) {
       fireEvent(this, EventType.RECREATE_DIFF_VIEW);
       return;
+    } else if (this._changeNum !== undefined && this.isSameDiffLoaded(value)) {
+      // changeNum has not changed, so check if there are changes in patchRange
+      // path. If no changes then we can simply render the view as is.
+      this.reporting.reportInteraction('diff-view-re-rendered');
+      // Make sure to re-initialize the cursor because this is typically
+      // done on the 'render' event which doesn't fire in this path as
+      // rerendering is avoided.
+      this.cursor?.reInitCursor();
+      return;
     }
 
     this._files = {sortedFileList: [], changeFilesByPath: {}};
-    this._path = undefined;
+    if (this.isConnected) {
+      this.getChangeModel().updatePath(undefined);
+    }
     this._patchRange = undefined;
     this._commitRange = undefined;
     this._focusLineNum = undefined;
@@ -1055,37 +1172,22 @@
     }
 
     const promises: Promise<unknown>[] = [];
-
-    promises.push(this._getDiffPreferences());
-
-    if (!this._change) promises.push(this._getChangeDetail(this._changeNum));
-
-    if (!this._changeComments) this._loadComments(value.patchNum);
-
-    promises.push(this._getChangeEdit());
+    if (!this._change) {
+      promises.push(this.untilModelLoaded());
+    }
+    promises.push(this.waitUntilCommentsLoaded());
 
     this.$.diffHost.cancel();
     this.$.diffHost.clearDiffContent();
     this._loading = true;
     return Promise.all(promises)
-      .then(r => {
+      .then(() => {
         this._loading = false;
         this._initPatchRange();
         this._initCommitRange();
-
-        const edit = r[4] as EditInfo | undefined;
-        if (edit) {
-          this.set(`_change.revisions.${edit.commit.commit}`, {
-            _number: EditPatchSetNum,
-            basePatchNum: edit.base_patch_set_number,
-            commit: edit.commit,
-          });
-        }
         return this.$.diffHost.reload(true);
       })
       .then(() => {
-        this.reporting.diffViewFullyLoaded();
-        // If diff view displayed has not ended yet, it ends here.
         this.reporting.diffViewDisplayed();
       })
       .then(() => {
@@ -1126,55 +1228,9 @@
       });
   }
 
-  _changeViewStateChanged(changeViewState: Partial<ChangeViewState>) {
-    if (changeViewState.diffMode === null) {
-      // If screen size is small, always default to unified view.
-      this.restApiService.getPreferences().then(prefs => {
-        if (prefs) {
-          this.set('changeViewState.diffMode', prefs.default_diff_view);
-        }
-      });
-    }
-  }
-
-  @observe('_loggedIn', '_path', '_prefs', '_reviewedFiles', '_patchRange')
-  _setReviewedObserver(
-    _loggedIn?: boolean,
-    path?: string,
-    prefs?: DiffPreferencesInfo,
-    reviewedFiles?: Set<string>,
-    patchRange?: PatchRange
-  ) {
-    if (_loggedIn === undefined) return;
-    if (prefs === undefined) return;
-    if (path === undefined) return;
-    if (reviewedFiles === undefined) return;
-    if (patchRange === undefined) return;
-    if (!_loggedIn) return;
-    if (prefs.manual_review) {
-      // Checkbox state needs to be set explicitly only when manual_review
-      // is specified.
-      this.$.reviewed.checked = this._getReviewedStatus(path);
-    } else {
-      this._setReviewed(true);
-    }
-  }
-
-  @observe('_loggedIn', '_changeNum', '_patchRange')
-  getReviewedFiles(
-    _loggedIn?: boolean,
-    _changeNum?: NumericChangeId,
-    patchRange?: PatchRange
-  ) {
-    if (_loggedIn === undefined) return;
-    if (_changeNum === undefined) return;
-    if (patchRange === undefined) return;
-
-    if (!_loggedIn) {
-      return;
-    }
-
-    this._getReviewedFiles(this._changeNum, patchRange.patchNum);
+  private async waitUntilCommentsLoaded() {
+    await until(this.connected$, c => c);
+    await until(this.getCommentsModel().commentsLoading$, isFalse);
   }
 
   /**
@@ -1184,6 +1240,7 @@
     if (this._focusLineNum === undefined) {
       return;
     }
+    if (!this.cursor) return;
     if (leftSide) {
       this.cursor.side = Side.LEFT;
     } else {
@@ -1192,14 +1249,17 @@
     this.cursor.initialLineNumber = this._focusLineNum;
   }
 
-  _getLineOfInterest(leftSide: boolean): LineOfInterest | undefined {
+  _getLineOfInterest(leftSide: boolean): DisplayLine | undefined {
     // If there is a line number specified, pass it along to the diff so that
     // it will not get collapsed.
     if (!this._focusLineNum) {
       return undefined;
     }
 
-    return {number: this._focusLineNum, leftSide};
+    return {
+      lineNum: this._focusLineNum,
+      side: leftSide ? Side.LEFT : Side.RIGHT,
+    };
   }
 
   _pathChanged(path: string) {
@@ -1210,9 +1270,16 @@
     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(change?: ChangeInfo, patchRange?: PatchRange, path?: string) {
+  _getDiffUrl(
+    change?: ChangeInfo | ParsedChangeInfo,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
     if (!change || !patchRange || !path) return '';
     return GerritNav.getUrlForDiff(
       change,
@@ -1229,7 +1296,7 @@
    */
   _getChangeUrlRange(
     patchRange?: PatchRange,
-    revisions?: {[revisionId: string]: RevisionInfo}
+    revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo}
   ) {
     let patchNum = undefined;
     let basePatchNum = undefined;
@@ -1251,29 +1318,31 @@
   }
 
   _getChangePath(
-    change?: ChangeInfo,
+    change?: ChangeInfo | ParsedChangeInfo,
     patchRange?: PatchRange,
-    revisions?: {[revisionId: string]: RevisionInfo}
+    revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo}
   ) {
     if (!change) return '';
     if (!patchRange) return '';
 
     const range = this._getChangeUrlRange(patchRange, revisions);
-    return GerritNav.getUrlForChange(
-      change,
-      range.patchNum,
-      range.basePatchNum
-    );
+    return GerritNav.getUrlForChange(change, {
+      patchNum: range.patchNum,
+      basePatchNum: range.basePatchNum,
+    });
   }
 
   _navigateToChange(
-    change?: ChangeInfo,
+    change?: ChangeInfo | ParsedChangeInfo,
     patchRange?: PatchRange,
-    revisions?: {[revisionId: string]: RevisionInfo}
+    revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo}
   ) {
     if (!change) return;
     const range = this._getChangeUrlRange(patchRange, revisions);
-    GerritNav.navigateToChange(change, range.patchNum, range.basePatchNum);
+    GerritNav.navigateToChange(change, {
+      patchNum: range.patchNum,
+      basePatchNum: range.basePatchNum,
+    });
   }
 
   _computeChangePath(
@@ -1354,29 +1423,6 @@
     this.$.diffPreferencesDialog.open();
   }
 
-  /**
-   * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
-   * the current state.
-   *
-   * The expected behavior is to use the mode specified in the user's
-   * preferences unless they have manually chosen the alternative view or they
-   * are on a mobile device. If the user navigates up to the change view, it
-   * should clear this choice and revert to the preference the next time a
-   * diff is viewed.
-   *
-   * Use side-by-side if the user is not logged in.
-   */
-  _getDiffViewMode() {
-    if (this.changeViewState.diffMode) {
-      return this.changeViewState.diffMode;
-    } else if (this._userPrefs) {
-      this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
-      return this._userPrefs.default_diff_view;
-    } else {
-      return 'SIDE_BY_SIDE';
-    }
-  }
-
   _computeModeSelectHideClass(diff?: DiffInfo) {
     return !diff || diff.binary ? 'hide' : '';
   }
@@ -1487,11 +1533,6 @@
     return url;
   }
 
-  _loadComments(patchSet?: PatchSetNum) {
-    assertIsDefined(this._changeNum, '_changeNum');
-    return this.commentsService.loadAll(this._changeNum, patchSet);
-  }
-
   @observe(
     '_changeComments',
     '_files.changeFilesByPath',
@@ -1747,7 +1788,7 @@
   }
 
   _handleReloadingDiffPreference() {
-    this._getDiffPreferences();
+    this.userModel.getDiffPreferences();
   }
 
   _computeCanEdit(
@@ -1794,6 +1835,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 308c353..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
@@ -277,7 +277,6 @@
         <gr-patch-range-select
           id="rangeSelect"
           change-num="[[_changeNum]]"
-          change-comments="[[_changeComments]]"
           patch-num="[[_patchRange.patchNum]]"
           base-patch-num="[[_patchRange.basePatchNum]]"
           files-weblinks="[[_filesWeblinks]]"
@@ -339,7 +338,6 @@
           <gr-diff-mode-selector
             id="modeSelect"
             save-on-change="[[_loggedIn]]"
-            mode="{{changeViewState.diffMode}}"
             show-tooltip-below=""
           ></gr-diff-mode-selector>
         </div>
@@ -409,7 +407,6 @@
     path="[[_path]]"
     prefs="[[_prefs]]"
     project-name="[[_change.project]]"
-    view-mode="[[_diffMode]]"
     is-blame-loaded="{{_isBlameLoaded}}"
     on-comment-anchor-tap="_onLineSelected"
     on-line-selected="_onLineSelected"
@@ -424,9 +421,16 @@
   </gr-apply-fix-dialog>
   <gr-diff-preferences-dialog
     id="diffPreferencesDialog"
-    diff-prefs="{{_prefs}}"
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
-  <gr-comment-api id="commentAPI"></gr-comment-api>
+  <gr-overlay id="downloadOverlay">
+    <gr-download-dialog
+      id="downloadDialog"
+      change="[[_change]]"
+      patch-num="[[_patchRange.patchNum]]"
+      config="[[_serverConfig.download]]"
+      on-close="_handleDownloadDialogClose"
+    ></gr-download-dialog>
+  </gr-overlay>
 `;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index e4a8aa4..331e527 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -18,23 +18,23 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-diff-view.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import {ChangeStatus, DiffViewMode, createDefaultDiffPrefs} from '../../../constants/constants.js';
+import {stubRestApi, stubUsers, waitUntil} from '../../../test/test-utils.js';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
 import {GerritView} from '../../../services/router/router-model.js';
 import {
   createChange,
   createRevisions,
   createComment,
+  TEST_NUMERIC_CHANGE_ID,
 } from '../../../test/test-data-generators.js';
 import {EditPatchSetNum} from '../../../types/common.js';
 import {CursorMoveResult} from '../../../api/core.js';
-import {EventType} from '../../../types/events.js';
+import {Side} from '../../../api/diff.js';
+import {assertIsDefined} from '../../../utils/common-util.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
-const blankFixture = fixtureFromElement('div');
-
 suite('gr-diff-view tests', () => {
   suite('basic tests', () => {
     let element;
@@ -54,14 +54,10 @@
       };
     }
 
-    let getDiffChangeDetailStub;
     setup(async () => {
-      clock = sinon.useFakeTimers();
       stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-      getDiffChangeDetailStub = stubRestApi('getDiffChangeDetail').returns(
-          Promise.resolve({}));
       stubRestApi('getChangeFiles').returns(Promise.resolve({}));
       stubRestApi('saveFileReviewed').returns(Promise.resolve());
       diffCommentsStub = stubRestApi('getDiffComments');
@@ -95,10 +91,19 @@
         },
       ]});
       await flush();
+
+      element.getCommentsModel().setState({
+        comments: {},
+        robotComments: {},
+        drafts: {},
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
     });
 
     teardown(() => {
-      clock.restore();
+      clock && clock.restore();
       sinon.restore();
     });
 
@@ -133,29 +138,37 @@
         sinon.stub(element.reporting, 'diffViewDisplayed');
         sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
         sinon.spy(element, '_paramsChanged');
-        sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
-          ...createChange(),
-          revisions: createRevisions(11),
-        }));
+        element.getChangeModel().setState({
+          change: {
+            ...createChange(),
+            revisions: createRevisions(11),
+          }});
       });
 
       test('comment url resolves to comment.patch_set vs latest', () => {
-        diffCommentsStub.returns(Promise.resolve({
-          '/COMMIT_MSG': [
-            {
-              ...createComment(),
-              id: 'c1',
-              line: 10,
-              patch_set: 2,
-              path: '/COMMIT_MSG',
-            }, {
-              ...createComment(),
-              id: 'c3',
-              line: 10,
-              patch_set: 'PARENT',
-              path: '/COMMIT_MSG',
-            },
-          ]}));
+        element.getCommentsModel().setState({
+          comments: {
+            '/COMMIT_MSG': [
+              {
+                ...createComment(),
+                id: 'c1',
+                line: 10,
+                patch_set: 2,
+                path: '/COMMIT_MSG',
+              }, {
+                ...createComment(),
+                id: 'c3',
+                line: 10,
+                patch_set: 'PARENT',
+                path: '/COMMIT_MSG',
+              },
+            ]},
+          robotComments: {},
+          drafts: {},
+          portedComments: {},
+          portedDrafts: {},
+          discardedDrafts: [],
+        });
         element.params = {
           view: GerritNav.View.DIFF,
           changeNum: '42',
@@ -207,31 +220,39 @@
     test('unchanged diff X vs latest from comment links navigates to base vs X'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-          diffCommentsStub.returns(Promise.resolve({
-            '/COMMIT_MSG': [
-              {
-                ...createComment(),
-                id: 'c1',
-                line: 10,
-                patch_set: 2,
-                path: '/COMMIT_MSG',
-              }, {
-                ...createComment(),
-                id: 'c3',
-                line: 10,
-                patch_set: 'PARENT',
-                path: '/COMMIT_MSG',
-              },
-            ]}));
+          element.getCommentsModel().setState({
+            comments: {
+              '/COMMIT_MSG': [
+                {
+                  ...createComment(),
+                  id: 'c1',
+                  line: 10,
+                  patch_set: 2,
+                  path: '/COMMIT_MSG',
+                }, {
+                  ...createComment(),
+                  id: 'c3',
+                  line: 10,
+                  patch_set: 'PARENT',
+                  path: '/COMMIT_MSG',
+                },
+              ]},
+            robotComments: {},
+            drafts: {},
+            portedComments: {},
+            portedDrafts: {},
+            discardedDrafts: [],
+          });
           sinon.stub(element.reporting, 'diffViewDisplayed');
           sinon.stub(element, '_loadBlame');
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
           sinon.stub(element, '_isFileUnchanged').returns(true);
           sinon.spy(element, '_paramsChanged');
-          sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
-            ...createChange(),
-            revisions: createRevisions(11),
-          }));
+          element.getChangeModel().setState({
+            change: {
+              ...createChange(),
+              revisions: createRevisions(11),
+            }});
           element.params = {
             view: GerritNav.View.DIFF,
             changeNum: '42',
@@ -252,31 +273,39 @@
     test('unchanged diff Base vs latest from comment does not navigate'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-          diffCommentsStub.returns(Promise.resolve({
-            '/COMMIT_MSG': [
-              {
-                ...createComment(),
-                id: 'c1',
-                line: 10,
-                patch_set: 2,
-                path: '/COMMIT_MSG',
-              }, {
-                ...createComment(),
-                id: 'c3',
-                line: 10,
-                patch_set: 'PARENT',
-                path: '/COMMIT_MSG',
-              },
-            ]}));
+          element.getCommentsModel().setState({
+            comments: {
+              '/COMMIT_MSG': [
+                {
+                  ...createComment(),
+                  id: 'c1',
+                  line: 10,
+                  patch_set: 2,
+                  path: '/COMMIT_MSG',
+                }, {
+                  ...createComment(),
+                  id: 'c3',
+                  line: 10,
+                  patch_set: 'PARENT',
+                  path: '/COMMIT_MSG',
+                },
+              ]},
+            robotComments: {},
+            drafts: {},
+            portedComments: {},
+            portedDrafts: {},
+            discardedDrafts: [],
+          });
           sinon.stub(element.reporting, 'diffViewDisplayed');
           sinon.stub(element, '_loadBlame');
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
           sinon.stub(element, '_isFileUnchanged').returns(true);
           sinon.spy(element, '_paramsChanged');
-          sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
-            ...createChange(),
-            revisions: createRevisions(11),
-          }));
+          element.getChangeModel().setState({
+            change: {
+              ...createChange(),
+              revisions: createRevisions(11),
+            }});
           element.params = {
             view: GerritNav.View.DIFF,
             changeNum: '42',
@@ -324,87 +353,41 @@
       assert.equal(element._isFileUnchanged(diff), true);
     });
 
-    test('change detail is not rerequested if changeNum doesnt change',
-        async () => {
-          const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-          assert.isFalse(getDiffChangeDetailStub.called);
-          sinon.stub(element.reporting, 'diffViewDisplayed');
-          sinon.stub(element, '_loadBlame');
-          sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-          sinon.spy(element, '_paramsChanged');
-          element._change = undefined;
-          getDiffChangeDetailStub.returns(
-              Promise.resolve({
-                ...createChange(),
-                revisions: createRevisions(11),
-              }));
-          element._patchRange = {
-            patchNum: 2,
-            basePatchNum: 1,
-          };
-          sinon.stub(element, '_isFileUnchanged').returns(false);
-
-          element.params = {
-            view: GerritNav.View.DIFF,
-            changeNum: '42',
-            project: 'p',
-            commentId: 'c1',
-            commentLink: true,
-          };
-          await element._paramsChanged.returnValues[0];
-
-          assert.equal(getDiffChangeDetailStub.callCount, 1);
-          element.params = {
-            view: GerritNav.View.DIFF,
-            changeNum: '42',
-            project: 'p',
-            commentId: 'c1',
-            commentLink: true,
-          };
-          await element._paramsChanged.returnValues[0];
-
-          assert.equal(getDiffChangeDetailStub.callCount, 1);
-          element.params = {
-            view: GerritNav.View.DIFF,
-            changeNum: '43',
-            project: 'p',
-            commentId: 'c1',
-            commentLink: true,
-          };
-          await element._paramsChanged.returnValues[0];
-
-          // change page is recreated now
-          assert.equal(dispatchEventStub.lastCall.args[0].type,
-              EventType.RECREATE_DIFF_VIEW);
-        });
-
     test('diff toast to go to latest is shown and not base', async () => {
-      diffCommentsStub.returns(Promise.resolve({
-        '/COMMIT_MSG': [
-          {
-            ...createComment(),
-            id: 'c1',
-            line: 10,
-            patch_set: 2,
-            path: '/COMMIT_MSG',
-          }, {
-            ...createComment(),
-            id: 'c3',
-            line: 10,
-            patch_set: 'PARENT',
-            path: '/COMMIT_MSG',
-          },
-        ]}));
+      element.getCommentsModel().setState({
+        comments: {
+          '/COMMIT_MSG': [
+            {
+              ...createComment(),
+              id: 'c1',
+              line: 10,
+              patch_set: 2,
+              path: '/COMMIT_MSG',
+            }, {
+              ...createComment(),
+              id: 'c3',
+              line: 10,
+              patch_set: 'PARENT',
+              path: '/COMMIT_MSG',
+            },
+          ]},
+        robotComments: {},
+        drafts: {},
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+
       sinon.stub(element.reporting, 'diffViewDisplayed');
       sinon.stub(element, '_loadBlame');
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sinon.spy(element, '_paramsChanged');
       element._change = undefined;
-      getDiffChangeDetailStub.returns(
-          Promise.resolve({
-            ...createChange(),
-            revisions: createRevisions(11),
-          }));
+      element.getChangeModel().setState({
+        change: {
+          ...createChange(),
+          revisions: createRevisions(11),
+        }});
       element._patchRange = {
         patchNum: 2,
         basePatchNum: 1,
@@ -431,7 +414,9 @@
     });
 
     test('keyboard shortcuts', () => {
+      clock = sinon.useFakeTimers();
       element._changeNum = '42';
+      element.getBrowserModel().setScreenWidth(0);
       element._patchRange = {
         basePatchNum: PARENT,
         patchNum: 10,
@@ -489,6 +474,7 @@
       MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
       assert(showPrefsStub.calledOnce);
 
+      assertIsDefined(element.cursor);
       let scrollStub = sinon.stub(element.cursor, 'moveToNextChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
       assert(scrollStub.calledOnce);
@@ -510,11 +496,11 @@
           '_computeContainerClass');
       MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
       assert(computeContainerClassStub.lastCall.calledWithExactly(
-          false, 'SIDE_BY_SIDE', true));
+          false, DiffViewMode.SIDE_BY_SIDE, true));
 
       MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'Escape');
       assert(computeContainerClassStub.lastCall.calledWithExactly(
-          false, 'SIDE_BY_SIDE', false));
+          false, DiffViewMode.SIDE_BY_SIDE, false));
 
       // Note that stubbing _setReviewed means that the value of the
       // `element.$.reviewed` checkbox is not flipped.
@@ -539,11 +525,13 @@
       MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
       assert.isTrue(element._handleToggleFileReviewed.calledTwice);
       assert.isTrue(element._setReviewed.calledTwice);
+      clock.restore();
     });
 
     test('moveToNextCommentThread navigates to next file', () => {
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
       const diffChangeStub = sinon.stub(element, '_navigateToChange');
+      assertIsDefined(element.cursor);
       sinon.stub(element.cursor, 'isAtEnd').returns(true);
       element._changeNum = '42';
       const comment = {
@@ -727,8 +715,8 @@
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       await flush();
       assert.isTrue(element.changeViewState.showReplyDialog);
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
-          5), 'Should navigate to /c/42/5..10');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, {
+        patchNum: 10, basePatchNum: 5}), 'Should navigate to /c/42/5..10');
       assert.isFalse(loggedInErrorSpy.called);
     });
 
@@ -753,8 +741,8 @@
           MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
           await flush();
           assert.isTrue(element.changeViewState.showReplyDialog);
-          assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
-              PARENT), 'Should navigate to /c/42/1');
+          assert(changeNavStub.lastCall.calledWithExactly(element._change, {
+            patchNum: 1, basePatchNum: PARENT}), 'Should navigate to /c/42/1');
           assert.isFalse(loggedInErrorSpy.called);
         });
 
@@ -779,8 +767,8 @@
       const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
 
       MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
-          5), 'Should navigate to /c/42/5..10');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change,
+          {patchNum: 10, basePatchNum: 5}), 'Should navigate to /c/42/5..10');
 
       MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert.isTrue(element._loading);
@@ -809,13 +797,14 @@
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
       assert.isTrue(element._loading);
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
-          5),
+      assert(changeNavStub.lastCall.calledWithExactly(element._change,
+          {patchNum: 10, basePatchNum: 5}),
       'Should navigate to /c/42/5..10');
 
-      assert.isUndefined(element.changeViewState.showDownloadDialog);
+      const downloadOverlayStub = sinon.stub(element.$.downloadOverlay, 'open')
+          .returns(Promise.resolve());
       MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
-      assert.isTrue(element.changeViewState.showDownloadDialog);
+      assert.isTrue(downloadOverlayStub.called);
     });
 
     test('keyboard shortcuts with old patch number', () => {
@@ -839,8 +828,8 @@
       const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
 
       MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
-          PARENT), 'Should navigate to /c/42/1');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change,
+          {patchNum: 1, basePatchNum: PARENT}), 'Should navigate to /c/42/1');
 
       MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
       assert(diffNavStub.lastCall.calledWithExactly(element._change,
@@ -865,8 +854,8 @@
 
       changeNavStub.reset();
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
-          PARENT), 'Should navigate to /c/42/1');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change,
+          {patchNum: 1, basePatchNum: PARENT}), 'Should navigate to /c/42/1');
       assert.isTrue(changeNavStub.calledOnce);
     });
 
@@ -918,6 +907,7 @@
           b: {_number: 2, commit: {parents: []}},
         },
       };
+      assertIsDefined(element.cursor);
       sinon.stub(element.cursor, 'getAddress')
           .returns({number: lineNumber, isLeftSide: false});
       const redirectStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
@@ -991,7 +981,9 @@
     });
 
     suite('diff prefs hidden', () => {
-      test('whenlogged out', () => {
+      test('when no prefs or logged out', () => {
+        element._prefs = undefined;
+        element.disableDiffPrefs = false;
         element._loggedIn = false;
         flush();
         assert.isTrue(element.$.diffPrefsContainer.hidden);
@@ -1035,7 +1027,8 @@
         sinon.stub(
             GerritNav
             , 'getUrlForChange')
-            .callsFake((c, pn, bpn) => `${c._number}-${pn}-${bpn}`);
+            .callsFake((c, ops) =>
+              `${c._number}-${ops.patchNum}-${ops.basePatchNum}`);
       });
 
       test('_formattedFiles', () => {
@@ -1199,77 +1192,131 @@
           element._path, 1, 'PARENT'));
     });
 
-    test('_prefs.manual_review is respected', () => {
-      const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
-          .callsFake(() => Promise.resolve());
-      const getReviewedStub = sinon.stub(element, '_getReviewedStatus')
-          .returns(false);
+    test('_prefs.manual_review true means set reviewed is not ' +
+      'automatically called', async () => {
+      const setReviewedFileStatusStub =
+        sinon.stub(element.getChangeModel(), 'setReviewedFilesStatus')
+            .callsFake(() => Promise.resolve());
+
+      const setReviewedStatusStub = sinon.spy(element, 'setReviewedStatus');
 
       sinon.stub(element.$.diffHost, 'reload');
-      element._loggedIn = true;
-      element._prefs = {manual_review: true};
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: 2,
-        basePatchNum: 1,
-        path: '/COMMIT_MSG',
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      const diffPreferences = {
+        ...createDefaultDiffPrefs(),
+        manual_review: true,
       };
+      element.userModel.setDiffPreferences(diffPreferences);
+      element.getChangeModel().setState({
+        change: createChange(),
+        diffPath: '/COMMIT_MSG',
+        reviewedFiles: [],
+      });
+
+      element.routerModel.setState({
+        changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
+      );
       element._patchRange = {
         patchNum: 2,
         basePatchNum: 1,
       };
-      flush();
 
-      assert.isFalse(saveReviewedStub.called);
-      assert.isTrue(getReviewedStub.called);
+      await waitUntil(() => setReviewedStatusStub.called);
 
-      const oldCount = getReviewedStub.callCount;
+      assert.isFalse(setReviewedFileStatusStub.called);
 
-      element._prefs = {};
-      element._path = 'abcd';
-      flush();
+      // if prefs are updated then the reviewed status should not be set again
+      element.userModel.setDiffPreferences(createDefaultDiffPrefs());
 
-      assert.isTrue(saveReviewedStub.called);
-      assert.equal(getReviewedStub.callCount, oldCount);
+      await flush();
+      assert.isFalse(setReviewedFileStatusStub.called);
     });
 
-    test('file review status', () => {
-      const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
-          .callsFake(() => Promise.resolve());
+    test('_prefs.manual_review false means set reviewed is called',
+        async () => {
+          const setReviewedFileStatusStub =
+              sinon.stub(element.getChangeModel(), 'setReviewedFilesStatus')
+                  .callsFake(() => Promise.resolve());
+
+          sinon.stub(element.$.diffHost, 'reload');
+          sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+          const diffPreferences = {
+            ...createDefaultDiffPrefs(),
+            manual_review: false,
+          };
+          element.userModel.setDiffPreferences(diffPreferences);
+          element.getChangeModel().setState({
+            change: createChange(),
+            diffPath: '/COMMIT_MSG',
+            reviewedFiles: [],
+          });
+
+          element.routerModel.setState({
+            changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF,
+            patchNum: 22}
+          );
+          element._patchRange = {
+            patchNum: 2,
+            basePatchNum: 1,
+          };
+
+          await waitUntil(() => setReviewedFileStatusStub.called);
+
+          assert.isTrue(setReviewedFileStatusStub.called);
+        });
+
+    test('file review status', async () => {
+      element.getChangeModel().setState({
+        change: createChange(),
+        diffPath: '/COMMIT_MSG',
+        reviewedFiles: [],
+      });
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      const saveReviewedStub =
+          sinon.stub(element.getChangeModel(), 'setReviewedFilesStatus')
+              .callsFake(() => Promise.resolve());
       sinon.stub(element.$.diffHost, 'reload');
 
-      element._loggedIn = true;
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: 2,
-        basePatchNum: 1,
-        path: '/COMMIT_MSG',
-      };
+      element.userModel.setDiffPreferences(createDefaultDiffPrefs());
+
+      element.routerModel.setState({
+        changeNum: TEST_NUMERIC_CHANGE_ID, view: GerritView.DIFF, patchNum: 2}
+      );
+
       element._patchRange = {
         patchNum: 2,
         basePatchNum: 1,
       };
-      element._path = 'abcd';
-      element._prefs = {};
-      flush();
 
-      const commitMsg = element.root.querySelector(
+      await waitUntil(() => saveReviewedStub.called);
+
+      element.getChangeModel().updateStateFileReviewed('/COMMIT_MSG', true);
+      await flush();
+
+      const reviewedStatusCheckBox = element.root.querySelector(
           'input[type="checkbox"]');
 
-      assert.isTrue(commitMsg.checked);
-      MockInteractions.tap(commitMsg);
-      assert.isFalse(commitMsg.checked);
-      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(false));
+      assert.isTrue(reviewedStatusCheckBox.checked);
+      assert.deepEqual(saveReviewedStub.lastCall.args,
+          ['42', 2, '/COMMIT_MSG', true]);
 
-      MockInteractions.tap(commitMsg);
-      assert.isTrue(commitMsg.checked);
-      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(true));
+      MockInteractions.tap(reviewedStatusCheckBox);
+      assert.isFalse(reviewedStatusCheckBox.checked);
+      assert.deepEqual(saveReviewedStub.lastCall.args,
+          ['42', 2, '/COMMIT_MSG', false]);
+
+      element.getChangeModel().updateStateFileReviewed('/COMMIT_MSG', false);
+      await flush();
+
+      MockInteractions.tap(reviewedStatusCheckBox);
+      assert.isTrue(reviewedStatusCheckBox.checked);
+      assert.deepEqual(saveReviewedStub.lastCall.args,
+          ['42', 2, '/COMMIT_MSG', true]);
+
       const callCount = saveReviewedStub.callCount;
 
       element.set('params.view', GerritNav.View.CHANGE);
-      flush();
+      await flush();
 
       // saveReviewedState observer observes params, but should not fire when
       // view !== GerritNav.View.DIFF.
@@ -1277,7 +1324,8 @@
     });
 
     test('file review status with edit loaded', () => {
-      const saveReviewedStub = sinon.stub(element, '_saveReviewedState');
+      const saveReviewedStub =
+          sinon.stub(element.getChangeModel(), 'setReviewedFilesStatus');
 
       element._patchRange = {patchNum: EditPatchSetNum};
       flush();
@@ -1308,47 +1356,23 @@
     test('diff mode selector correctly toggles the diff', () => {
       const select = element.$.modeSelect;
       const diffDisplay = element.$.diffHost;
-      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
+      element._userPrefs = {diff_view: DiffViewMode.SIDE_BY_SIDE};
+      element.getBrowserModel().setScreenWidth(0);
 
+      const userStub = stubUsers('updatePreferences');
+
+      flush();
       // The mode selected in the view state reflects the selected option.
-      assert.equal(element._getDiffViewMode(), select.mode);
+      // assert.equal(element._userPrefs.diff_view, select.mode);
 
       // The mode selected in the view state reflects the view rednered in the
       // diff.
       assert.equal(select.mode, diffDisplay.viewMode);
 
       // We will simulate a user change of the selected mode.
-      const newMode = 'UNIFIED_DIFF';
-
-      // Set the mode, and simulate the change event.
-      element.set('changeViewState.diffMode', newMode);
-
-      // Make sure the handler was called and the state is still coherent.
-      assert.equal(element._getDiffViewMode(), newMode);
-      assert.equal(element._getDiffViewMode(), select.mode);
-      assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
-    });
-
-    test('diff mode selector initializes from preferences', () => {
-      let resolvePrefs;
-      const prefsPromise = new Promise(resolve => {
-        resolvePrefs = resolve;
-      });
-      stubRestApi('getPreferences')
-          .callsFake(() => prefsPromise);
-
-      // Attach a new gr-diff-view so we can intercept the preferences fetch.
-      const view = document.createElement('gr-diff-view');
-      blankFixture.instantiate().appendChild(view);
-      flush();
-
-      // At this point the diff mode doesn't yet have the user's preference.
-      assert.equal(view._getDiffViewMode(), 'SIDE_BY_SIDE');
-
-      // Receive the overriding preference.
-      resolvePrefs({default_diff_view: 'UNIFIED'});
-      flush();
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+      element._handleToggleDiffMode();
+      assert.isTrue(userStub.calledWithExactly({
+        diff_view: DiffViewMode.UNIFIED}));
     });
 
     test('diff mode selector should be hidden for binary', async () => {
@@ -1385,8 +1409,6 @@
         sinon.stub(element.$.diffHost, 'reload');
         sinon.stub(element, '_initCursor');
         element._change = change;
-        sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
-            change));
       });
 
       test('uses the patchNum and basePatchNum ', async () => {
@@ -1422,6 +1444,7 @@
     });
 
     test('_initCursor', () => {
+      assertIsDefined(element.cursor);
       assert.isNotOk(element.cursor.initialLineNumber);
 
       // Does nothing when params specify no cursor address:
@@ -1457,17 +1480,18 @@
 
       element._focusLineNum = 12;
       let result = element._getLineOfInterest(false);
-      assert.equal(result.number, 12);
-      assert.isNotOk(result.leftSide);
+      assert.equal(result.lineNum, 12);
+      assert.equal(result.side, Side.RIGHT);
 
       result = element._getLineOfInterest(true);
-      assert.equal(result.number, 12);
-      assert.isOk(result.leftSide);
+      assert.equal(result.lineNum, 12);
+      assert.equal(result.side, Side.LEFT);
     });
 
     test('_onLineSelected', () => {
       const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
       const replaceStateStub = sinon.stub(history, 'replaceState');
+      assertIsDefined(element.cursor);
       sinon.stub(element.cursor, 'getAddress')
           .returns({number: 123, isLeftSide: false});
 
@@ -1490,6 +1514,7 @@
     test('line selected on left side', () => {
       const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
       const replaceStateStub = sinon.stub(history, 'replaceState');
+      assertIsDefined(element.cursor);
       sinon.stub(element.cursor, 'getAddress')
           .returns({number: 123, isLeftSide: true});
 
@@ -1509,32 +1534,22 @@
       assert.isTrue(getUrlStub.lastCall.args[6]);
     });
 
-    test('_getDiffViewMode', () => {
-      // No user prefs or change view state set.
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-
-      // User prefs but no change view state set.
-      element.changeViewState.diffMode = undefined;
-      element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
-      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
-
-      // User prefs and change view state set.
-      element.changeViewState = {diffMode: 'SIDE_BY_SIDE'};
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-    });
-
     test('_handleToggleDiffMode', () => {
+      const userStub = stubUsers('updatePreferences');
       const e = new CustomEvent('keydown', {
         detail: {keyboardEvent: new KeyboardEvent('keydown'), key: 'x'},
       });
-      // Initial state.
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+      element._userPrefs = {diff_view: DiffViewMode.SIDE_BY_SIDE};
 
       element._handleToggleDiffMode(e);
-      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
+      assert.deepEqual(userStub.lastCall.args[0], {
+        diff_view: DiffViewMode.UNIFIED});
+
+      element._userPrefs = {diff_view: DiffViewMode.UNIFIED};
 
       element._handleToggleDiffMode(e);
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+      assert.deepEqual(userStub.lastCall.args[0], {
+        diff_view: DiffViewMode.SIDE_BY_SIDE});
     });
 
     suite('_initPatchRange', () => {
@@ -1777,6 +1792,7 @@
         dispatchEventStub = sinon.stub(
             element, 'dispatchEvent').callThrough();
         navToFileStub = sinon.stub(element, '_navToFile');
+        assertIsDefined(element.cursor);
         moveToPreviousChunkStub =
             sinon.stub(element.cursor, 'moveToPreviousChunk');
         moveToNextChunkStub =
@@ -1802,7 +1818,7 @@
         isAtEndStub.returns(true);
 
         element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-        element._reviewedFiles = new Set(['file2']);
+        element.reviewedFiles = new Set(['file2']);
         element._path = 'file1';
 
         nowStub.returns(5);
@@ -1846,7 +1862,7 @@
         isAtStartStub.returns(true);
 
         element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-        element._reviewedFiles = new Set(['file2']);
+        element.reviewedFiles = new Set(['file2']);
         element._path = 'file3';
 
         nowStub.returns(5);
@@ -1893,7 +1909,7 @@
 
     test('shift+m navigates to next unreviewed file', () => {
       element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      element._reviewedFiles = new Set(['file1', 'file2']);
+      element.reviewedFiles = new Set(['file1', 'file2']);
       element._path = 'file1';
       const reviewedStub = sinon.stub(element, '_setReviewed');
       const navStub = sinon.stub(element, '_navToFile');
@@ -2059,7 +2075,6 @@
       stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
 
       stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-      stubRestApi('getDiffChangeDetail').returns(Promise.resolve({}));
       stubRestApi('getChangeFiles').returns(Promise.resolve(changedFiles));
       stubRestApi('saveFileReviewed').returns(Promise.resolve());
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
@@ -2069,7 +2084,6 @@
           Promise.resolve([]));
       element = basicFixture.instantiate();
       element._changeNum = '42';
-      return element._loadComments();
     });
 
     test('_getFiles add files with comments without changes', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
deleted file mode 100644
index 7393606..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
+++ /dev/null
@@ -1,174 +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 {CommentRange} from '../../../types/common';
-import {FILE, LineNumber} from './gr-diff-line';
-import {Side} from '../../../constants/constants';
-import {DiffInfo} from '../../../types/diff';
-
-// If any line of the diff is more than the character limit, then disable
-// syntax highlighting for the entire file.
-export const SYNTAX_MAX_LINE_LENGTH = 500;
-
-/**
- * Compare two ranges. Either argument may be falsy, but will only return
- * true if both are falsy or if neither are falsy and have the same position
- * values.
- */
-export function rangesEqual(a?: CommentRange, b?: CommentRange): boolean {
-  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
-  );
-}
-
-export function isLongCommentRange(range: CommentRange): boolean {
-  return range.end_line - range.start_line > 10;
-}
-
-export function getLineNumberByChild(node?: Node) {
-  return getLineNumber(getLineElByChild(node));
-}
-
-export function lineNumberToNumber(lineNumber?: LineNumber | null): number {
-  if (!lineNumber) return 0;
-  if (lineNumber === 'LOST') return 0;
-  if (lineNumber === 'FILE') return 0;
-  return lineNumber;
-}
-
-export function getLineElByChild(node?: Node): HTMLElement | null {
-  while (node) {
-    if (node instanceof Element) {
-      if (node.classList.contains('lineNum')) {
-        return node as HTMLElement;
-      }
-      if (node.classList.contains('section')) {
-        return null;
-      }
-    }
-    node = node.previousSibling ?? node.parentElement ?? undefined;
-  }
-  return null;
-}
-
-export function getSideByLineEl(lineEl: Element) {
-  return lineEl.classList.contains(Side.RIGHT) ? Side.RIGHT : Side.LEFT;
-}
-
-export function getLineNumber(lineEl?: Element | null): LineNumber | null {
-  if (!lineEl) return null;
-  const lineNumberStr = lineEl.getAttribute('data-value');
-  if (!lineNumberStr) return null;
-  if (lineNumberStr === FILE) return FILE;
-  if (lineNumberStr === 'LOST') return 'LOST';
-  const lineNumber = Number(lineNumberStr);
-  return Number.isInteger(lineNumber) ? lineNumber : null;
-}
-
-export function getLine(threadEl: HTMLElement): LineNumber {
-  const lineAtt = threadEl.getAttribute('line-num');
-  if (lineAtt === 'LOST') return lineAtt;
-  if (!lineAtt || lineAtt === 'FILE') return FILE;
-  const line = Number(lineAtt);
-  if (isNaN(line)) throw new Error(`cannot parse line number: ${lineAtt}`);
-  if (line < 1) throw new Error(`line number smaller than 1: ${line}`);
-  return line;
-}
-
-export function getSide(threadEl: HTMLElement): Side | undefined {
-  // TODO(dhruvsri): Remove check for comment-side once all users of gr-diff
-  // start setting diff-side
-  const sideAtt =
-    threadEl.getAttribute('diff-side') || threadEl.getAttribute('comment-side');
-  if (!sideAtt) {
-    console.warn('comment thread without side');
-    return undefined;
-  }
-  if (sideAtt !== Side.LEFT && sideAtt !== Side.RIGHT)
-    throw Error(`unexpected value for side: ${sideAtt}`);
-  return sideAtt as Side;
-}
-
-export function getRange(threadEl: HTMLElement): CommentRange | undefined {
-  const rangeAtt = threadEl.getAttribute('range');
-  if (!rangeAtt) return undefined;
-  const range = JSON.parse(rangeAtt) as CommentRange;
-  if (!range.start_line) throw new Error(`invalid range: ${rangeAtt}`);
-  return range;
-}
-
-// TODO: This type should be exposed to gr-diff clients in a separate type file.
-// For Gerrit these are instances of GrCommentThread, but other gr-diff users
-// have different HTML elements in use for comment threads.
-// TODO: Also document the required HTML attributes that thread elements must
-// have, e.g. 'diff-side', 'range', 'line-num', 'data-value'.
-export interface GrDiffThreadElement extends HTMLElement {
-  rootId: string;
-}
-
-const VISIBLE_TEXT_NODE_TYPES = [Node.TEXT_NODE, Node.ELEMENT_NODE];
-
-export function getPreviousContentNodes(node?: Node | null) {
-  const sibs = [];
-  while (node) {
-    const {parentNode, previousSibling} = node;
-    const topContentLevel =
-      parentNode &&
-      (parentNode as HTMLElement).classList.contains('contentText');
-    let previousEl: Node | undefined | null;
-    if (previousSibling) {
-      previousEl = previousSibling;
-    } else if (!topContentLevel) {
-      previousEl = parentNode?.previousSibling;
-    }
-    if (previousEl && VISIBLE_TEXT_NODE_TYPES.includes(previousEl.nodeType)) {
-      sibs.push(previousEl);
-    }
-    node = previousEl;
-  }
-  return sibs;
-}
-
-export function isThreadEl(node: Node): node is GrDiffThreadElement {
-  return (
-    node.nodeType === Node.ELEMENT_NODE &&
-    (node as Element).classList.contains('comment-thread')
-  );
-}
-
-/**
- * @return whether any of the lines in diff are longer
- * than SYNTAX_MAX_LINE_LENGTH.
- */
-export function anyLineTooLong(diff?: DiffInfo) {
-  if (!diff) return false;
-  return diff.content.some(section => {
-    const lines = section.ab
-      ? section.ab
-      : (section.a || []).concat(section.b || []);
-    return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
-  });
-}
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 857ffa2..1656f5f 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
@@ -14,15 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-a11y-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-select/gr-select';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-patch-range-select_html';
-import {pluralize} from '../../../utils/string-util';
-import {appContext} from '../../../services/app-context';
+import {convertToString, pluralize} from '../../../utils/string-util';
+import {getAppContext} from '../../../services/app-context';
 import {
   computeLatestPatchNum,
   findSortedIndex,
@@ -33,7 +28,6 @@
   PatchSet,
   convertToPatchSetNum,
 } from '../../../utils/patch-set-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {
@@ -44,7 +38,6 @@
   Timestamp,
 } from '../../../types/common';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {
   DropdownItem,
@@ -52,10 +45,23 @@
   GrDropdownList,
 } from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GeneratedWebLink} from '../../core/gr-navigation/gr-navigation';
+import {EditRevisionInfo} from '../../../types/types';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {subscribe} from '../../lit/subscription-controller';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {resolve} from '../../../models/dependency';
+import {ifDefined} from 'lit/directives/if-defined';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
 
+function getShaForPatch(patch: PatchSet) {
+  return patch.sha.substring(0, 10);
+}
+
 export interface PatchRangeChangeDetail {
   patchNum?: PatchSetNum;
   basePatchNum?: BasePatchSetNum;
@@ -68,10 +74,13 @@
   meta_b: GeneratedWebLink[];
 }
 
-export interface GrPatchRangeSelect {
-  $: {
-    patchNumDropdown: GrDropdownList;
-  };
+declare global {
+  interface HTMLElementEventMap {
+    'value-change': DropDownValueChangeEvent;
+  }
+  interface HTMLElementTagNameMap {
+    'gr-patch-range-select': GrPatchRangeSelect;
+  }
 }
 
 /**
@@ -83,37 +92,17 @@
  * @property {string} basePatchNum
  */
 @customElement('gr-patch-range-select')
-export class GrPatchRangeSelect extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrPatchRangeSelect extends LitElement {
+  @query('#patchNumDropdown')
+  patchNumDropdown?: GrDropdownList;
 
   @property({type: Array})
   availablePatches?: PatchSet[];
 
-  @property({
-    type: Object,
-    computed:
-      '_computeBaseDropdownContent(availablePatches, patchNum,' +
-      '_sortedRevisions, changeComments, revisionInfo)',
-  })
-  _baseDropdownContent?: DropdownItem[];
-
-  @property({
-    type: Object,
-    computed:
-      '_computePatchDropdownContent(availablePatches,' +
-      'basePatchNum, _sortedRevisions, changeComments)',
-  })
-  _patchDropdownContent?: DropdownItem[];
-
   @property({type: String})
   changeNum?: string;
 
   @property({type: Object})
-  changeComments?: ChangeComments;
-
-  @property({type: Object})
   filesWeblinks?: FilesWebLinks;
 
   @property({type: String})
@@ -122,68 +111,144 @@
   @property({type: String})
   basePatchNum?: BasePatchSetNum;
 
+  /** Not used directly. Translated into `sortedRevisions` in willUpdate(). */
   @property({type: Object})
-  revisions?: RevisionInfo[];
+  revisions: (RevisionInfo | EditRevisionInfo)[] = [];
 
   @property({type: Object})
   revisionInfo?: RevisionInfoClass;
 
-  @property({type: Array})
-  _sortedRevisions?: RevisionInfo[];
+  /** Private internal state, derived from `revisions` in willUpdate(). */
+  @state()
+  private sortedRevisions: (RevisionInfo | EditRevisionInfo)[] = [];
 
-  private readonly reporting: ReportingService = appContext.reportingService;
+  /** Private internal state, visible for testing. */
+  @state()
+  changeComments?: ChangeComments;
 
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
+  private readonly reporting: ReportingService =
+    getAppContext().reportingService;
+
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
+  override connectedCallback() {
+    super.connectedCallback();
+    subscribe(
+      this,
+      this.getCommentsModel().changeComments$,
+      x => (this.changeComments = x)
+    );
   }
 
-  _getShaForPatch(patch: PatchSet) {
-    return patch.sha.substring(0, 10);
+  static override get styles() {
+    return [
+      a11yStyles,
+      sharedStyles,
+      css`
+        :host {
+          align-items: center;
+          display: flex;
+        }
+        select {
+          max-width: 15em;
+        }
+        .arrow {
+          color: var(--deemphasized-text-color);
+          margin: 0 var(--spacing-m);
+        }
+        gr-dropdown-list {
+          --trigger-style-text-color: var(--deemphasized-text-color);
+          --trigger-style-font-family: var(--font-family);
+        }
+        @media screen and (max-width: 50em) {
+          .filesWeblinks {
+            display: none;
+          }
+          gr-dropdown-list {
+            --native-select-style: {
+              max-width: 5.25em;
+            }
+          }
+        }
+      `,
+    ];
   }
 
-  _computeBaseDropdownContent(
-    availablePatches?: PatchSet[],
-    patchNum?: PatchSetNum,
-    _sortedRevisions?: RevisionInfo[],
-    changeComments?: ChangeComments,
-    revisionInfo?: RevisionInfoClass
-  ): DropdownItem[] | undefined {
-    // Polymer 2: check for undefined
+  override render() {
+    return html`
+      <h3 class="assistive-tech-only">Patchset Range Selection</h3>
+      <span class="patchRange" aria-label="patch range starts with">
+        <gr-dropdown-list
+          id="basePatchDropdown"
+          .value=${convertToString(this.basePatchNum)}
+          .items=${this.computeBaseDropdownContent()}
+          @value-change=${this.handlePatchChange}
+        >
+        </gr-dropdown-list>
+      </span>
+      ${this.renderWeblinks(this.filesWeblinks?.meta_a)}
+      <span aria-hidden="true" class="arrow">→</span>
+      <span class="patchRange" aria-label="patch range ends with">
+        <gr-dropdown-list
+          id="patchNumDropdown"
+          .value=${convertToString(this.patchNum)}
+          .items=${this.computePatchDropdownContent()}
+          @value-change=${this.handlePatchChange}
+        >
+        </gr-dropdown-list>
+        ${this.renderWeblinks(this.filesWeblinks?.meta_b)}
+      </span>
+    `;
+  }
+
+  private renderWeblinks(fileLinks?: GeneratedWebLink[]) {
+    if (!fileLinks) return;
+    return html`<span class="filesWeblinks">
+      ${fileLinks.map(
+        weblink => html`
+          <a target="_blank" rel="noopener" href=${ifDefined(weblink.url)}>
+            ${weblink.name}
+          </a>
+        `
+      )}</span
+    > `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('revisions')) {
+      this.sortedRevisions = sortRevisions(Object.values(this.revisions || {}));
+    }
+  }
+
+  // Private method, but visible for testing.
+  computeBaseDropdownContent(): DropdownItem[] {
     if (
-      availablePatches === undefined ||
-      patchNum === undefined ||
-      _sortedRevisions === undefined ||
-      changeComments === undefined ||
-      revisionInfo === undefined
+      this.availablePatches === undefined ||
+      this.patchNum === undefined ||
+      this.changeComments === undefined ||
+      this.revisionInfo === undefined
     ) {
-      return undefined;
+      return [];
     }
 
-    const parentCounts = revisionInfo.getParentCountMap();
-    const currentParentCount = hasOwnProperty(parentCounts, patchNum)
-      ? parentCounts[patchNum as number]
+    const parentCounts = this.revisionInfo.getParentCountMap();
+    const currentParentCount = hasOwnProperty(parentCounts, this.patchNum)
+      ? parentCounts[this.patchNum as number]
       : 1;
-    const maxParents = revisionInfo.getMaxParents();
+    const maxParents = this.revisionInfo.getMaxParents();
     const isMerge = currentParentCount > 1;
 
     const dropdownContent: DropdownItem[] = [];
-    for (const basePatch of availablePatches) {
+    for (const basePatch of this.availablePatches) {
       const basePatchNum = basePatch.num;
-      const entry: DropdownItem = this._createDropdownEntry(
+      const entry: DropdownItem = this.createDropdownEntry(
         basePatchNum,
         'Patchset ',
-        _sortedRevisions,
-        changeComments,
-        this._getShaForPatch(basePatch)
+        getShaForPatch(basePatch)
       );
       dropdownContent.push({
         ...entry,
-        disabled: this._computeLeftDisabled(
-          basePatch.num,
-          patchNum,
-          _sortedRevisions
-        ),
+        disabled: this.computeLeftDisabled(basePatch.num, this.patchNum),
       });
     }
 
@@ -205,122 +270,84 @@
     return dropdownContent;
   }
 
-  _computeMobileText(
-    patchNum: PatchSetNum,
-    changeComments: ChangeComments,
-    revisions: RevisionInfo[]
-  ) {
+  private computeMobileText(patchNum: PatchSetNum) {
     return (
       `${patchNum}` +
-      `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
-      `${this._computePatchSetDescription(revisions, patchNum, true)}`
+      `${this.computePatchSetCommentsString(patchNum)}` +
+      `${this.computePatchSetDescription(patchNum, true)}`
     );
   }
 
-  _computePatchDropdownContent(
-    availablePatches?: PatchSet[],
-    basePatchNum?: BasePatchSetNum,
-    _sortedRevisions?: RevisionInfo[],
-    changeComments?: ChangeComments
-  ): DropdownItem[] | undefined {
-    // Polymer 2: check for undefined
+  // Private method, but visible for testing.
+  computePatchDropdownContent(): DropdownItem[] {
     if (
-      availablePatches === undefined ||
-      basePatchNum === undefined ||
-      _sortedRevisions === undefined ||
-      changeComments === undefined
+      this.availablePatches === undefined ||
+      this.basePatchNum === undefined ||
+      this.changeComments === undefined
     ) {
-      return undefined;
+      return [];
     }
 
     const dropdownContent: DropdownItem[] = [];
-    for (const patch of availablePatches) {
+    for (const patch of this.availablePatches) {
       const patchNum = patch.num;
-      const entry = this._createDropdownEntry(
+      const entry = this.createDropdownEntry(
         patchNum,
         patchNum === 'edit' ? '' : 'Patchset ',
-        _sortedRevisions,
-        changeComments,
-        this._getShaForPatch(patch)
+        getShaForPatch(patch)
       );
       dropdownContent.push({
         ...entry,
-        disabled: this._computeRightDisabled(
-          basePatchNum,
-          patchNum,
-          _sortedRevisions
-        ),
+        disabled: this.computeRightDisabled(this.basePatchNum, patchNum),
       });
     }
     return dropdownContent;
   }
 
-  _computeText(
-    patchNum: PatchSetNum,
-    prefix: string,
-    changeComments: ChangeComments,
-    sha: string
-  ) {
+  private computeText(patchNum: PatchSetNum, prefix: string, sha: string) {
     return (
       `${prefix}${patchNum}` +
-      `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
+      `${this.computePatchSetCommentsString(patchNum)}` +
       ` | ${sha}`
     );
   }
 
-  _createDropdownEntry(
+  private createDropdownEntry(
     patchNum: PatchSetNum,
     prefix: string,
-    sortedRevisions: RevisionInfo[],
-    changeComments: ChangeComments,
     sha: string
   ) {
     const entry: DropdownItem = {
       triggerText: `${prefix}${patchNum}`,
-      text: this._computeText(patchNum, prefix, changeComments, sha),
-      mobileText: this._computeMobileText(
-        patchNum,
-        changeComments,
-        sortedRevisions
-      ),
-      bottomText: `${this._computePatchSetDescription(
-        sortedRevisions,
-        patchNum
-      )}`,
+      text: this.computeText(patchNum, prefix, sha),
+      mobileText: this.computeMobileText(patchNum),
+      bottomText: `${this.computePatchSetDescription(patchNum)}`,
       value: patchNum,
     };
-    const date = this._computePatchSetDate(sortedRevisions, patchNum);
+    const date = this.computePatchSetDate(patchNum);
     if (date) {
       entry.date = date;
     }
     return entry;
   }
 
-  @observe('revisions.*')
-  _updateSortedRevisions(
-    revisionsRecord: PolymerDeepPropertyChange<RevisionInfo[], RevisionInfo[]>
-  ) {
-    const revisions = revisionsRecord.base;
-    if (!revisions) return;
-    this._sortedRevisions = sortRevisions(Object.values(revisions));
-  }
-
   /**
    * The basePatchNum should always be <= patchNum -- because sortedRevisions
    * is sorted in reverse order (higher patchset nums first), invalid base
    * patch nums have an index greater than the index of patchNum.
    *
+   * Private method, but visible for testing.
+   *
    * @param basePatchNum The possible base patch num.
    * @param patchNum The current selected patch num.
    */
-  _computeLeftDisabled(
+  computeLeftDisabled(
     basePatchNum: PatchSetNum,
-    patchNum: PatchSetNum,
-    sortedRevisions: RevisionInfo[]
+    patchNum: PatchSetNum
   ): boolean {
     return (
-      findSortedIndex(basePatchNum, sortedRevisions) <=
-      findSortedIndex(patchNum, sortedRevisions)
+      findSortedIndex(basePatchNum, this.sortedRevisions) <=
+      findSortedIndex(patchNum, this.sortedRevisions)
     );
   }
 
@@ -335,13 +362,14 @@
    * If the current basePatchNum is a parent index, then only patches that have
    * at least that many parents are valid.
    *
+   * Private method, but visible for testing.
+   *
    * @param basePatchNum The current selected base patch num.
    * @param patchNum The possible patch num.
    */
-  _computeRightDisabled(
+  computeRightDisabled(
     basePatchNum: PatchSetNum,
-    patchNum: PatchSetNum,
-    sortedRevisions: RevisionInfo[]
+    patchNum: PatchSetNum
   ): boolean {
     if (basePatchNum === ParentPatchSetNum) {
       return false;
@@ -359,21 +387,17 @@
     }
 
     return (
-      findSortedIndex(basePatchNum, sortedRevisions) <=
-      findSortedIndex(patchNum, sortedRevisions)
+      findSortedIndex(basePatchNum, this.sortedRevisions) <=
+      findSortedIndex(patchNum, this.sortedRevisions)
     );
   }
 
   // TODO(dhruvsri): have ported comments contribute to this count
-  _computePatchSetCommentsString(
-    changeComments: ChangeComments,
-    patchNum: PatchSetNum
-  ) {
-    if (!changeComments) {
-      return;
-    }
+  // Private method, but visible for testing.
+  computePatchSetCommentsString(patchNum: PatchSetNum): string {
+    if (!this.changeComments) return '';
 
-    const commentThreadCount = changeComments.computeCommentThreadCount(
+    const commentThreadCount = this.changeComments.computeCommentThreadCount(
       {
         patchNum,
       },
@@ -381,7 +405,7 @@
     );
     const commentThreadString = pluralize(commentThreadCount, 'comment');
 
-    const unresolvedCount = changeComments.computeUnresolvedNum(
+    const unresolvedCount = this.changeComments.computeUnresolvedNum(
       {patchNum},
       true
     );
@@ -400,23 +424,19 @@
     );
   }
 
-  _computePatchSetDescription(
-    revisions: RevisionInfo[],
+  private computePatchSetDescription(
     patchNum: PatchSetNum,
     addFrontSpace?: boolean
   ) {
-    const rev = getRevisionByPatchNum(revisions, patchNum);
+    const rev = getRevisionByPatchNum(this.sortedRevisions, patchNum);
     return rev?.description
       ? (addFrontSpace ? ' ' : '') +
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH)
       : '';
   }
 
-  _computePatchSetDate(
-    revisions: RevisionInfo[],
-    patchNum: PatchSetNum
-  ): Timestamp | undefined {
-    const rev = getRevisionByPatchNum(revisions, patchNum);
+  private computePatchSetDate(patchNum: PatchSetNum): Timestamp | undefined {
+    const rev = getRevisionByPatchNum(this.sortedRevisions, patchNum);
     return rev ? rev.created : undefined;
   }
 
@@ -424,22 +444,22 @@
    * Catches value-change events from the patchset dropdowns and determines
    * whether or not a patch change event should be fired.
    */
-  _handlePatchChange(e: DropDownValueChangeEvent) {
+  private handlePatchChange(e: DropDownValueChangeEvent) {
     const detail: PatchRangeChangeDetail = {
       patchNum: this.patchNum,
       basePatchNum: this.basePatchNum,
     };
-    const target = (dom(e) as EventApi).localTarget;
+    const target = e.target;
     const patchSetValue = convertToPatchSetNum(e.detail.value)!;
     const latestPatchNum = computeLatestPatchNum(this.availablePatches);
-    if (target === this.$.patchNumDropdown) {
-      if (detail.patchNum === e.detail.value) return;
+    if (target === this.patchNumDropdown) {
+      if (detail.patchNum === patchSetValue) return;
       this.reporting.reportInteraction('right-patchset-changed', {
         previous: detail.patchNum,
-        current: e.detail.value,
+        current: patchSetValue,
         latest: latestPatchNum,
         commentCount: this.changeComments?.computeCommentThreadCount({
-          patchNum: e.detail.value as PatchSetNum,
+          patchNum: patchSetValue,
         }),
       });
       detail.patchNum = patchSetValue;
@@ -447,7 +467,7 @@
       if (detail.basePatchNum === patchSetValue) return;
       this.reporting.reportInteraction('left-patchset-changed', {
         previous: detail.basePatchNum,
-        current: e.detail.value,
+        current: patchSetValue,
         commentCount: this.changeComments?.computeCommentThreadCount({
           patchNum: patchSetValue,
         }),
@@ -460,9 +480,3 @@
     );
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-patch-range-select': GrPatchRangeSelect;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
deleted file mode 100644
index 26944a4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-a11y-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      align-items: center;
-      display: flex;
-    }
-    select {
-      max-width: 15em;
-    }
-    .arrow {
-      color: var(--deemphasized-text-color);
-      margin: 0 var(--spacing-m);
-    }
-    gr-dropdown-list {
-      --trigger-style-text-color: var(--deemphasized-text-color);
-      --trigger-style-font-family: var(--font-family);
-    }
-    @media screen and (max-width: 50em) {
-      .filesWeblinks {
-        display: none;
-      }
-      gr-dropdown-list {
-        --native-select-style: {
-          max-width: 5.25em;
-        }
-      }
-    }
-  </style>
-  <h3 class="assistive-tech-only">Patchset Range Selection</h3>
-  <span class="patchRange" aria-label="patch range starts with">
-    <gr-dropdown-list
-      id="basePatchDropdown"
-      value="[[basePatchNum]]"
-      on-value-change="_handlePatchChange"
-      items="[[_baseDropdownContent]]"
-    >
-    </gr-dropdown-list>
-  </span>
-  <span is="dom-if" if="[[filesWeblinks.meta_a]]" class="filesWeblinks">
-    <template is="dom-repeat" items="[[filesWeblinks.meta_a]]" as="weblink">
-      <a target="_blank" rel="noopener" href$="[[weblink.url]]"
-        >[[weblink.name]]</a
-      >
-    </template>
-  </span>
-  <span aria-hidden="true" class="arrow">→</span>
-  <span class="patchRange" aria-label="patch range ends with">
-    <gr-dropdown-list
-      id="patchNumDropdown"
-      value="[[patchNum]]"
-      on-value-change="_handlePatchChange"
-      items="[[_patchDropdownContent]]"
-    >
-    </gr-dropdown-list>
-    <span is="dom-if" if="[[filesWeblinks.meta_b]]" class="filesWeblinks">
-      <template is="dom-repeat" items="[[filesWeblinks.meta_b]]" as="weblink">
-        <a target="_blank" href$="[[weblink.url]]">[[weblink.name]]</a>
-      </template>
-    </span>
-  </span>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
deleted file mode 100644
index 28ebbac..0000000
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
+++ /dev/null
@@ -1,395 +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 '../gr-comment-api/gr-comment-api.js';
-import '../../shared/revision-info/revision-info.js';
-import './gr-patch-range-select.js';
-import '../../../test/mocks/comment-api.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
-import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {EditPatchSetNum} from '../../../types/common.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-
-const commentApiMockElement = createCommentApiMockWithTemplateElement(
-    'gr-patch-range-select-comment-api-mock', html`
-    <gr-patch-range-select id="patchRange" auto
-        change-comments="[[_changeComments]]"></gr-patch-range-select>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
-`);
-
-const basicFixture = fixtureFromElement(commentApiMockElement.is);
-
-suite('gr-patch-range-select tests', () => {
-  let element;
-
-  let commentApiWrapper;
-
-  function getInfo(revisions) {
-    const revisionObj = {};
-    for (let i = 0; i < revisions.length; i++) {
-      revisionObj[i] = revisions[i];
-    }
-    return new RevisionInfo({revisions: revisionObj});
-  }
-
-  setup(() => {
-    stubRestApi('getDiffComments').returns(Promise.resolve({}));
-    stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-    stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-
-    // Element must be wrapped in an element with direct access to the
-    // comment API.
-    commentApiWrapper = basicFixture.instantiate();
-    element = commentApiWrapper.$.patchRange;
-
-    // Stub methods on the changeComments object after changeComments has
-    // been initialized.
-    element.changeComments = new ChangeComments();
-  });
-
-  test('enabled/disabled options', () => {
-    const patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 3,
-    };
-    const sortedRevisions = [
-      {_number: 3},
-      {_number: EditPatchSetNum, basePatchNum: 2},
-      {_number: 2},
-      {_number: 1},
-    ];
-    for (const patchNum of ['1', '2', '3']) {
-      assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum,
-          patchNum, sortedRevisions));
-    }
-    for (const basePatchNum of ['1', '2']) {
-      assert.isFalse(element._computeLeftDisabled(basePatchNum,
-          patchRange.patchNum, sortedRevisions));
-    }
-    assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum));
-
-    patchRange.basePatchNum = EditPatchSetNum;
-    assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum,
-        sortedRevisions));
-    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '1',
-        sortedRevisions));
-    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '2',
-        sortedRevisions));
-    assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum, '3',
-        sortedRevisions));
-    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum,
-        EditPatchSetNum, sortedRevisions));
-  });
-
-  test('_computeBaseDropdownContent', () => {
-    const availablePatches = [
-      {num: 'edit', sha: '1'},
-      {num: 3, sha: '2'},
-      {num: 2, sha: '3'},
-      {num: 1, sha: '4'},
-    ];
-    const revisions = [
-      {
-        commit: {parents: []},
-        _number: 2,
-        description: 'description',
-      },
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(revisions);
-    const patchNum = 1;
-    const sortedRevisions = [
-      {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-      {_number: EditPatchSetNum, basePatchNum: 2},
-      {_number: 2, description: 'description'},
-      {_number: 1},
-    ];
-    const expectedResult = [
-      {
-        disabled: true,
-        triggerText: 'Patchset edit',
-        text: 'Patchset edit | 1',
-        mobileText: 'edit',
-        bottomText: '',
-        value: 'edit',
-      },
-      {
-        disabled: true,
-        triggerText: 'Patchset 3',
-        text: 'Patchset 3 | 2',
-        mobileText: '3',
-        bottomText: '',
-        value: 3,
-        date: 'Mon, 01 Jan 2001 00:00:00 GMT',
-      },
-      {
-        disabled: true,
-        triggerText: 'Patchset 2',
-        text: 'Patchset 2 | 3',
-        mobileText: '2 description',
-        bottomText: 'description',
-        value: 2,
-      },
-      {
-        disabled: true,
-        triggerText: 'Patchset 1',
-        text: 'Patchset 1 | 4',
-        mobileText: '1',
-        bottomText: '',
-        value: 1,
-      },
-      {
-        text: 'Base',
-        value: 'PARENT',
-      },
-    ];
-    assert.deepEqual(element._computeBaseDropdownContent(availablePatches,
-        patchNum, sortedRevisions, element.changeComments,
-        element.revisionInfo),
-    expectedResult);
-  });
-
-  test('_computeBaseDropdownContent called when patchNum updates', () => {
-    element.revisions = [
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(element.revisions);
-    element.availablePatches = [
-      {num: 1, sha: '1'},
-      {num: 2, sha: '2'},
-      {num: 3, sha: '3'},
-      {num: 'edit', sha: '4'},
-    ];
-    element.patchNum = 2;
-    element.basePatchNum = 'PARENT';
-    flush();
-
-    sinon.stub(element, '_computeBaseDropdownContent');
-
-    // Should be recomputed for each available patch
-    element.set('patchNum', 1);
-    assert.equal(element._computeBaseDropdownContent.callCount, 1);
-  });
-
-  test('_computeBaseDropdownContent called when changeComments update',
-      async () => {
-        element.revisions = [
-          {commit: {parents: []}},
-          {commit: {parents: []}},
-          {commit: {parents: []}},
-          {commit: {parents: []}},
-        ];
-        element.revisionInfo = getInfo(element.revisions);
-        element.availablePatches = [
-          {num: 'edit', sha: '1'},
-          {num: 3, sha: '2'},
-          {num: 2, sha: '3'},
-          {num: 1, sha: '4'},
-        ];
-        element.patchNum = 2;
-        element.basePatchNum = 'PARENT';
-        await flush();
-
-        // Should be recomputed for each available patch
-        sinon.stub(element, '_computeBaseDropdownContent');
-        assert.equal(element._computeBaseDropdownContent.callCount, 0);
-        element.changeComments = new ChangeComments();
-        await flush();
-        assert.equal(element._computeBaseDropdownContent.callCount, 1);
-      });
-
-  test('_computePatchDropdownContent called when basePatchNum updates', () => {
-    element.revisions = [
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(element.revisions);
-    element.availablePatches = [
-      {num: 1, sha: '1'},
-      {num: 2, sha: '2'},
-      {num: 3, sha: '3'},
-      {num: 'edit', sha: '4'},
-    ];
-    element.patchNum = 2;
-    element.basePatchNum = 'PARENT';
-    flush();
-
-    // Should be recomputed for each available patch
-    sinon.stub(element, '_computePatchDropdownContent');
-    element.set('basePatchNum', 1);
-    assert.equal(element._computePatchDropdownContent.callCount, 1);
-  });
-
-  test('_computePatchDropdownContent', () => {
-    const availablePatches = [
-      {num: 'edit', sha: '1'},
-      {num: 3, sha: '2'},
-      {num: 2, sha: '3'},
-      {num: 1, sha: '4'},
-    ];
-    const basePatchNum = 1;
-    const sortedRevisions = [
-      {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-      {_number: EditPatchSetNum, basePatchNum: 2},
-      {_number: 2, description: 'description'},
-      {_number: 1},
-    ];
-
-    const expectedResult = [
-      {
-        disabled: false,
-        triggerText: 'edit',
-        text: 'edit | 1',
-        mobileText: 'edit',
-        bottomText: '',
-        value: 'edit',
-      },
-      {
-        disabled: false,
-        triggerText: 'Patchset 3',
-        text: 'Patchset 3 | 2',
-        mobileText: '3',
-        bottomText: '',
-        value: 3,
-        date: 'Mon, 01 Jan 2001 00:00:00 GMT',
-      },
-      {
-        disabled: false,
-        triggerText: 'Patchset 2',
-        text: 'Patchset 2 | 3',
-        mobileText: '2 description',
-        bottomText: 'description',
-        value: 2,
-      },
-      {
-        disabled: true,
-        triggerText: 'Patchset 1',
-        text: 'Patchset 1 | 4',
-        mobileText: '1',
-        bottomText: '',
-        value: 1,
-      },
-    ];
-
-    assert.deepEqual(element._computePatchDropdownContent(availablePatches,
-        basePatchNum, sortedRevisions, element.changeComments),
-    expectedResult);
-  });
-
-  test('filesWeblinks', () => {
-    element.filesWeblinks = {
-      meta_a: [
-        {
-          name: 'foo',
-          url: 'f.oo',
-        },
-      ],
-      meta_b: [
-        {
-          name: 'bar',
-          url: 'ba.r',
-        },
-      ],
-    };
-    flush();
-    const domApi = dom(element.root);
-    assert.equal(
-        domApi.querySelector('a[href="f.oo"]').textContent, 'foo');
-    assert.equal(
-        domApi.querySelector('a[href="ba.r"]').textContent, 'bar');
-  });
-
-  test('_computePatchSetCommentsString', () => {
-    // Test string with unresolved comments.
-    const comments = {
-      foo: [{
-        id: '27dcee4d_f7b77cfa',
-        message: 'test',
-        patch_set: 1,
-        unresolved: true,
-        updated: '2017-10-11 20:48:40.000000000',
-      }],
-      bar: [
-        {
-          id: '27dcee4d_f7b77cfa',
-          message: 'test',
-          patch_set: 1,
-          updated: '2017-10-12 20:48:40.000000000',
-        },
-        {
-          id: '27dcee4d_f7b77cfa',
-          message: 'test',
-          patch_set: 1,
-          updated: '2017-10-13 20:48:40.000000000',
-        },
-      ],
-      abc: [],
-      // Patchset level comment does not contribute to the count
-      [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-        id: '27dcee4d_f7b77cfa',
-        message: 'test',
-        patch_set: 1,
-        unresolved: true,
-        updated: '2017-10-11 20:48:40.000000000',
-      }],
-    };
-    element.changeComments = new ChangeComments(comments);
-
-    assert.equal(element._computePatchSetCommentsString(
-        element.changeComments, 1), ' (3 comments, 1 unresolved)');
-
-    // Test string with no unresolved comments.
-    delete element.changeComments._comments['foo'];
-    assert.equal(element._computePatchSetCommentsString(
-        element.changeComments, 1), ' (2 comments)');
-
-    // Test string with no comments.
-    delete element.changeComments._comments['bar'];
-    assert.equal(element._computePatchSetCommentsString(
-        element.changeComments, 1), '');
-  });
-
-  test('patch-range-change fires', () => {
-    const handler = sinon.stub();
-    element.basePatchNum = 1;
-    element.patchNum = 3;
-    element.addEventListener('patch-range-change', handler);
-
-    element.$.basePatchDropdown._handleValueChange(2, [{value: 2}]);
-    assert.isTrue(handler.calledOnce);
-    assert.deepEqual(handler.lastCall.args[0].detail,
-        {basePatchNum: 2, patchNum: 3});
-
-    // BasePatchNum should not have changed, due to one-way data binding.
-    element.$.patchNumDropdown._handleValueChange('edit', [{value: 'edit'}]);
-    assert.deepEqual(handler.lastCall.args[0].detail,
-        {basePatchNum: 1, patchNum: 'edit'});
-  });
-});
-
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
new file mode 100644
index 0000000..c4e710d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -0,0 +1,448 @@
+/**
+ * @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 '../gr-comment-api/gr-comment-api';
+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 {stubReporting, stubRestApi} from '../../../test/test-utils';
+import {
+  BasePatchSetNum,
+  EditPatchSetNum,
+  PatchSetNum,
+  RevisionInfo,
+  Timestamp,
+  UrlEncodedCommentId,
+  PathToCommentsInfoMap,
+} from '../../../types/common';
+import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
+import {SpecialFilePath} from '../../../constants/constants';
+import {
+  createEditRevision,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {PatchSet} from '../../../utils/patch-set-util';
+import {
+  DropdownItem,
+  GrDropdownList,
+} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {queryAndAssert} from '../../../test/test-utils';
+import {fire} from '../../../utils/event-util';
+
+const basicFixture = fixtureFromElement('gr-patch-range-select');
+
+type RevIdToRevisionInfo = {
+  [revisionId: string]: RevisionInfo | EditRevisionInfo;
+};
+
+suite('gr-patch-range-select tests', () => {
+  let element: GrPatchRangeSelect;
+
+  function getInfo(revisions: (RevisionInfo | EditRevisionInfo)[]) {
+    const revisionObj: Partial<RevIdToRevisionInfo> = {};
+    for (let i = 0; i < revisions.length; i++) {
+      revisionObj[i] = revisions[i];
+    }
+    return new RevisionInfoClass({revisions: revisionObj} as ParsedChangeInfo);
+  }
+
+  setup(async () => {
+    stubRestApi('getDiffComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+    stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+
+    // Element must be wrapped in an element with direct access to the
+    // comment API.
+    element = basicFixture.instantiate();
+
+    // Stub methods on the changeComments object after changeComments has
+    // been initialized.
+    element.changeComments = new ChangeComments();
+    await element.updateComplete;
+  });
+
+  test('enabled/disabled options', async () => {
+    element.revisions = [
+      createRevision(3),
+      createEditRevision(2),
+      createRevision(2),
+      createRevision(1),
+    ];
+    await element.updateComplete;
+
+    const parent = 'PARENT' as PatchSetNum;
+    const edit = EditPatchSetNum;
+
+    for (const patchNum of [1, 2, 3]) {
+      assert.isFalse(
+        element.computeRightDisabled(parent, patchNum as PatchSetNum)
+      );
+    }
+    for (const basePatchNum of [1, 2]) {
+      const base = basePatchNum as PatchSetNum;
+      assert.isFalse(element.computeLeftDisabled(base, 3 as PatchSetNum));
+    }
+    assert.isTrue(
+      element.computeLeftDisabled(3 as PatchSetNum, 3 as PatchSetNum)
+    );
+
+    assert.isTrue(
+      element.computeLeftDisabled(3 as PatchSetNum, 3 as PatchSetNum)
+    );
+    assert.isTrue(element.computeRightDisabled(edit, 1 as PatchSetNum));
+    assert.isTrue(element.computeRightDisabled(edit, 2 as PatchSetNum));
+    assert.isFalse(element.computeRightDisabled(edit, 3 as PatchSetNum));
+    assert.isTrue(element.computeRightDisabled(edit, edit));
+  });
+
+  test('computeBaseDropdownContent', async () => {
+    element.availablePatches = [
+      {num: 'edit', sha: '1'} as PatchSet,
+      {num: 3, sha: '2'} as PatchSet,
+      {num: 2, sha: '3'} as PatchSet,
+      {num: 1, sha: '4'} as PatchSet,
+    ];
+    element.revisions = [
+      createRevision(2),
+      createRevision(3),
+      createRevision(1),
+      createRevision(4),
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    const expectedResult: DropdownItem[] = [
+      {
+        disabled: true,
+        triggerText: 'Patchset edit',
+        text: 'Patchset edit | 1',
+        mobileText: 'edit',
+        bottomText: '',
+        value: 'edit',
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 3',
+        text: 'Patchset 3 | 2',
+        mobileText: '3',
+        bottomText: '',
+        value: 3,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
+      } as DropdownItem,
+      {
+        disabled: true,
+        triggerText: 'Patchset 2',
+        text: 'Patchset 2 | 3',
+        mobileText: '2',
+        bottomText: '',
+        value: 2,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
+      } as DropdownItem,
+      {
+        disabled: true,
+        triggerText: 'Patchset 1',
+        text: 'Patchset 1 | 4',
+        mobileText: '1',
+        bottomText: '',
+        value: 1,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
+      } as DropdownItem,
+      {
+        text: 'Base',
+        value: 'PARENT',
+      } as DropdownItem,
+    ];
+    element.patchNum = 1 as PatchSetNum;
+    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    await element.updateComplete;
+
+    assert.deepEqual(element.computeBaseDropdownContent(), expectedResult);
+  });
+
+  test('computeBaseDropdownContent called when patchNum updates', async () => {
+    element.revisions = [
+      createRevision(2),
+      createRevision(3),
+      createRevision(1),
+      createRevision(4),
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 1, sha: '1'} as PatchSet,
+      {num: 2, sha: '2'} as PatchSet,
+      {num: 3, sha: '3'} as PatchSet,
+      {num: 'edit', sha: '4'} as PatchSet,
+    ];
+    element.patchNum = 2 as PatchSetNum;
+    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    await element.updateComplete;
+
+    const baseDropDownStub = sinon.stub(element, 'computeBaseDropdownContent');
+
+    // Should be recomputed for each available patch
+    element.patchNum = 1 as PatchSetNum;
+    await element.updateComplete;
+    assert.equal(baseDropDownStub.callCount, 1);
+  });
+
+  test('computeBaseDropdownContent called when changeComments update', async () => {
+    element.revisions = [
+      createRevision(2),
+      createRevision(3),
+      createRevision(1),
+      createRevision(4),
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 3, sha: '2'} as PatchSet,
+      {num: 2, sha: '3'} as PatchSet,
+      {num: 1, sha: '4'} as PatchSet,
+    ];
+    element.patchNum = 2 as PatchSetNum;
+    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    await element.updateComplete;
+
+    // Should be recomputed for each available patch
+    const baseDropDownStub = sinon.stub(element, 'computeBaseDropdownContent');
+    assert.equal(baseDropDownStub.callCount, 0);
+    element.changeComments = new ChangeComments();
+    await element.updateComplete;
+    assert.equal(baseDropDownStub.callCount, 1);
+  });
+
+  test('computePatchDropdownContent called when basePatchNum updates', async () => {
+    element.revisions = [
+      createRevision(2),
+      createRevision(3),
+      createRevision(1),
+      createRevision(4),
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 1, sha: '1'} as PatchSet,
+      {num: 2, sha: '2'} as PatchSet,
+      {num: 3, sha: '3'} as PatchSet,
+      {num: 'edit', sha: '4'} as PatchSet,
+    ];
+    element.patchNum = 2 as PatchSetNum;
+    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    await element.updateComplete;
+
+    // Should be recomputed for each available patch
+    const baseDropDownStub = sinon.stub(element, 'computePatchDropdownContent');
+    element.basePatchNum = 1 as BasePatchSetNum;
+    await element.updateComplete;
+    assert.equal(baseDropDownStub.callCount, 1);
+  });
+
+  test('computePatchDropdownContent', async () => {
+    element.availablePatches = [
+      {num: 'edit', sha: '1'} as PatchSet,
+      {num: 3, sha: '2'} as PatchSet,
+      {num: 2, sha: '3'} as PatchSet,
+      {num: 1, sha: '4'} as PatchSet,
+    ];
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.revisions = [
+      createRevision(3),
+      createEditRevision(2),
+      createRevision(2, 'description'),
+      createRevision(1),
+    ];
+    await element.updateComplete;
+
+    const expectedResult: DropdownItem[] = [
+      {
+        disabled: false,
+        triggerText: 'edit',
+        text: 'edit | 1',
+        mobileText: 'edit',
+        bottomText: '',
+        value: 'edit',
+      },
+      {
+        disabled: false,
+        triggerText: 'Patchset 3',
+        text: 'Patchset 3 | 2',
+        mobileText: '3',
+        bottomText: '',
+        value: 3,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
+      } as DropdownItem,
+      {
+        disabled: false,
+        triggerText: 'Patchset 2',
+        text: 'Patchset 2 | 3',
+        mobileText: '2 description',
+        bottomText: 'description',
+        value: 2,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
+      } as DropdownItem,
+      {
+        disabled: true,
+        triggerText: 'Patchset 1',
+        text: 'Patchset 1 | 4',
+        mobileText: '1',
+        bottomText: '',
+        value: 1,
+        date: '2020-02-01 01:02:03.000000000' as Timestamp,
+      } as DropdownItem,
+    ];
+
+    assert.deepEqual(element.computePatchDropdownContent(), expectedResult);
+  });
+
+  test('filesWeblinks', async () => {
+    element.filesWeblinks = {
+      meta_a: [
+        {
+          name: 'foo',
+          url: 'f.oo',
+        },
+      ],
+      meta_b: [
+        {
+          name: 'bar',
+          url: 'ba.r',
+        },
+      ],
+    };
+    await element.updateComplete;
+    assert.equal(
+      queryAndAssert(element, 'a[href="f.oo"]').textContent!.trim(),
+      'foo'
+    );
+    assert.equal(
+      queryAndAssert(element, 'a[href="ba.r"]').textContent!.trim(),
+      'bar'
+    );
+  });
+
+  test('computePatchSetCommentsString', () => {
+    // Test string with unresolved comments.
+    const comments: PathToCommentsInfoMap = {
+      foo: [
+        {
+          id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
+          message: 'test',
+          patch_set: 1 as PatchSetNum,
+          unresolved: true,
+          updated: '2017-10-11 20:48:40.000000000' as Timestamp,
+        },
+      ],
+      bar: [
+        {
+          id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
+          message: 'test',
+          patch_set: 1 as PatchSetNum,
+          updated: '2017-10-12 20:48:40.000000000' as Timestamp,
+        },
+        {
+          id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
+          message: 'test',
+          patch_set: 1 as PatchSetNum,
+          updated: '2017-10-13 20:48:40.000000000' as Timestamp,
+        },
+      ],
+      abc: [],
+      // Patchset level comment does not contribute to the count
+      [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
+        {
+          id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
+          message: 'test',
+          patch_set: 1 as PatchSetNum,
+          unresolved: true,
+          updated: '2017-10-11 20:48:40.000000000' as Timestamp,
+        },
+      ],
+    };
+    element.changeComments = new ChangeComments(comments);
+
+    assert.equal(
+      element.computePatchSetCommentsString(1 as PatchSetNum),
+      ' (3 comments, 1 unresolved)'
+    );
+
+    // Test string with no unresolved comments.
+    delete comments['foo'];
+    element.changeComments = new ChangeComments(comments);
+    assert.equal(
+      element.computePatchSetCommentsString(1 as PatchSetNum),
+      ' (2 comments)'
+    );
+
+    // Test string with no comments.
+    delete comments['bar'];
+    element.changeComments = new ChangeComments(comments);
+    assert.equal(element.computePatchSetCommentsString(1 as PatchSetNum), '');
+  });
+
+  test('patch-range-change fires', () => {
+    const handler = sinon.stub();
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 3 as PatchSetNum;
+    element.addEventListener('patch-range-change', handler);
+
+    queryAndAssert<GrDropdownList>(
+      element,
+      '#basePatchDropdown'
+    )._handleValueChange('2', [{text: '', value: '2'}]);
+    assert.isTrue(handler.calledOnce);
+    assert.deepEqual(handler.lastCall.args[0].detail, {
+      basePatchNum: 2,
+      patchNum: 3,
+    });
+
+    // BasePatchNum should not have changed, due to one-way data binding.
+    queryAndAssert<GrDropdownList>(
+      element,
+      '#patchNumDropdown'
+    )._handleValueChange('edit', [{text: '', value: 'edit'}]);
+    assert.deepEqual(handler.lastCall.args[0].detail, {
+      basePatchNum: 1,
+      patchNum: 'edit',
+    });
+  });
+
+  test('handlePatchChange', async () => {
+    element.availablePatches = [
+      {num: 'edit', sha: '1'} as PatchSet,
+      {num: 3, sha: '2'} as PatchSet,
+      {num: 2, sha: '3'} as PatchSet,
+      {num: 1, sha: '4'} as PatchSet,
+    ];
+    element.revisions = [
+      createRevision(2),
+      createRevision(3),
+      createRevision(1),
+      createRevision(4),
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.patchNum = 1 as PatchSetNum;
+    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    await element.updateComplete;
+
+    const stub = stubReporting('reportInteraction');
+    fire(element.patchNumDropdown!, 'value-change', {value: '1'});
+    assert.isFalse(stub.called);
+
+    fire(element.patchNumDropdown!, 'value-change', {value: '2'});
+    assert.isTrue(stub.called);
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
deleted file mode 100644
index 11712dc..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ /dev/null
@@ -1,311 +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 {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';
-
-/**
- * Enhanced CommentRange by UI state. Interface for incoming ranges set from the
- * outside.
- *
- * TODO(TS): Unify with what is used in gr-diff when these objects are created.
- */
-export interface CommentRangeLayer {
-  side: Side;
-  range: CommentRange;
-  hovering: boolean;
-  rootId: string;
-}
-
-/**
- * This class breaks down all comment ranges into individual line segment
- * highlights.
- */
-interface CommentRangeLineLayer {
-  hovering: boolean;
-  longRange: boolean;
-  rootId: string;
-  start: number;
-  end: number;
-}
-
-type LinesMap = {
-  [line in number]: CommentRangeLineLayer[];
-};
-
-type RangesMap = {
-  [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';
-
-@customElement('gr-ranged-comment-layer')
-export class GrRangedCommentLayer extends PolymerElement implements DiffLayer {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * 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
-   */
-
-  @property({type: Array})
-  commentRanges: CommentRangeLayer[] = [];
-
-  @property({type: Array})
-  _listeners: DiffLayerListener[] = [];
-
-  @property({type: Object})
-  _rangesMap: RangesMap = {left: {}, right: {}};
-
-  get styleModuleName() {
-    return 'gr-ranged-comment-styles';
-  }
-
-  /**
-   * Layer method to add annotations to a line.
-   *
-   * @param el The DIV.contentText element to apply the annotation to.
-   */
-  annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-    let ranges: CommentRangeLineLayer[] = [];
-    if (
-      line.type === GrDiffLineType.REMOVE ||
-      (line.type === GrDiffLineType.BOTH &&
-        el.getAttribute('data-side') !== 'right')
-    ) {
-      ranges = ranges.concat(this._getRangesForLine(line, Side.LEFT));
-    }
-    if (
-      line.type === GrDiffLineType.ADD ||
-      (line.type === GrDiffLineType.BOTH &&
-        el.getAttribute('data-side') !== 'left')
-    ) {
-      ranges = ranges.concat(this._getRangesForLine(line, Side.RIGHT));
-    }
-
-    for (const range of ranges) {
-      GrAnnotation.annotateElement(
-        el,
-        range.start,
-        range.end - range.start,
-        (range.hovering
-          ? HOVER_HIGHLIGHT
-          : range.longRange
-          ? RANGE_BASE_ONLY
-          : RANGE_HIGHLIGHT) + ` ${strToClassName(range.rootId)}`
-      );
-    }
-  }
-
-  /**
-   * Register a listener for layer updates.
-   */
-  addListener(listener: DiffLayerListener) {
-    this._listeners.push(listener);
-  }
-
-  removeListener(listener: DiffLayerListener) {
-    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) {
-      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});
-          },
-        });
-      }
-    }
-
-    // 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;
-        },
-      });
-    }
-
-    // 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});
-            },
-          });
-        }
-      }
-    }
-  }
-
-  _updateRangesMap(options: {
-    side: Side;
-    range: CommentRange;
-    hovering: boolean;
-    operation: (
-      forLine: CommentRangeLineLayer[],
-      start: number,
-      end: number,
-      hovering: boolean
-    ) => void;
-    skipLayerUpdate?: boolean;
-  }) {
-    const {side, range, hovering, operation, skipLayerUpdate} = 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);
-    }
-    if (!skipLayerUpdate) {
-      this._notifyUpdateRange(range.start_line, range.end_line, side);
-    }
-  }
-
-  _getRangesForLine(line: GrDiffLine, side: Side) {
-    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;
-
-          // 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},
-              })
-            );
-          }
-
-          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;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/elements/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/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
deleted file mode 100644
index 8279ab1..0000000
--- a/polygerrit-ui/app/elements/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/elements/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
deleted file mode 100644
index 551889f..0000000
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ /dev/null
@@ -1,120 +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 '../../../styles/shared-styles';
-import '../../shared/gr-tooltip/gr-tooltip';
-import {GrTooltip} from '../../shared/gr-tooltip/gr-tooltip';
-import {customElement, property} from '@polymer/decorators';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-selection-action-box_html';
-import {fireEvent} from '../../../utils/event-util';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-selection-action-box': GrSelectionActionBox;
-  }
-}
-
-export interface GrSelectionActionBox {
-  $: {
-    tooltip: GrTooltip;
-  };
-}
-
-@customElement('gr-selection-action-box')
-export class GrSelectionActionBox extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the comment creation action was taken (click).
-   *
-   * @event create-comment-requested
-   */
-
-  @property({type: Boolean})
-  positionBelow = false;
-
-  constructor() {
-    super();
-    // See https://crbug.com/gerrit/4767
-    this.addEventListener('mousedown', e => this._handleMouseDown(e));
-  }
-
-  placeAbove(el: Text | Element | Range) {
-    flush();
-    const rect = this._getTargetBoundingRect(el);
-    const boxRect = this.$.tooltip.getBoundingClientRect();
-    const parentRect = this._getParentBoundingClientRect();
-    if (parentRect === null) {
-      return;
-    }
-    this.style.top = `${rect.top - parentRect.top - boxRect.height - 6}px`;
-    this.style.left = `${
-      rect.left - parentRect.left + (rect.width - boxRect.width) / 2
-    }px`;
-  }
-
-  placeBelow(el: Text | Element | Range) {
-    flush();
-    const rect = this._getTargetBoundingRect(el);
-    const boxRect = this.$.tooltip.getBoundingClientRect();
-    const parentRect = this._getParentBoundingClientRect();
-    if (parentRect === null) {
-      return;
-    }
-    this.style.top = `${rect.top - parentRect.top + boxRect.height - 6}px`;
-    this.style.left = `${
-      rect.left - parentRect.left + (rect.width - boxRect.width) / 2
-    }px`;
-  }
-
-  private _getParentBoundingClientRect() {
-    // With native shadow DOM, the parent is the shadow root, not the gr-diff
-    // element
-    if (this.parentElement) {
-      return this.parentElement.getBoundingClientRect();
-    }
-    if (this.parentNode !== null) {
-      return (this.parentNode as ShadowRoot).host.getBoundingClientRect();
-    }
-    return null;
-  }
-
-  private _getTargetBoundingRect(el: Text | Element | Range) {
-    let rect;
-    if (el instanceof Text) {
-      const range = document.createRange();
-      range.selectNode(el);
-      rect = range.getBoundingClientRect();
-      range.detach();
-    } else {
-      rect = el.getBoundingClientRect();
-    }
-    return rect;
-  }
-
-  private _handleMouseDown(e: MouseEvent) {
-    if (e.button !== 0) {
-      return;
-    } // 0 = main button
-    e.preventDefault();
-    e.stopPropagation();
-    fireEvent(this, 'create-comment-requested');
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts
deleted file mode 100644
index 24d63b3..0000000
--- a/polygerrit-ui/app/elements/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/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js
deleted file mode 100644
index 81cf0d6..0000000
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js
+++ /dev/null
@@ -1,119 +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-selection-action-box.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-  <div>
-    <gr-selection-action-box></gr-selection-action-box>
-    <div class="target">some text</div>
-  </div>
-`);
-
-suite('gr-selection-action-box', () => {
-  let container;
-  let element;
-
-  setup(() => {
-    container = basicFixture.instantiate();
-    element = container.querySelector('gr-selection-action-box');
-
-    sinon.stub(element, 'dispatchEvent');
-  });
-
-  test('ignores regular keys', () => {
-    MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
-    assert.isFalse(element.dispatchEvent.called);
-  });
-
-  suite('mousedown reacts only to main button', () => {
-    let e;
-
-    setup(() => {
-      e = {
-        button: 0,
-        preventDefault: sinon.stub(),
-        stopPropagation: sinon.stub(),
-      };
-    });
-
-    test('event handled if main button', () => {
-      element._handleMouseDown(e);
-      assert.isTrue(e.preventDefault.called);
-      assert.equal(
-          element.dispatchEvent.lastCall.args[0].type,
-          'create-comment-requested'
-      );
-    });
-
-    test('event ignored if not main button', () => {
-      e.button = 1;
-      element._handleMouseDown(e);
-      assert.isFalse(e.preventDefault.called);
-      assert.isFalse(element.dispatchEvent.called);
-    });
-  });
-
-  suite('placeAbove', () => {
-    let target;
-
-    setup(() => {
-      target = container.querySelector('.target');
-      sinon.stub(container, 'getBoundingClientRect').returns(
-          {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
-      sinon.stub(element, '_getTargetBoundingRect').returns(
-          {top: 42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
-      sinon.stub(element.$.tooltip, 'getBoundingClientRect').returns(
-          {width: 10, height: 10});
-    });
-
-    test('placeAbove for Element argument', () => {
-      element.placeAbove(target);
-      assert.equal(element.style.top, '25px');
-      assert.equal(element.style.left, '72px');
-    });
-
-    test('placeAbove for Text Node argument', () => {
-      element.placeAbove(target.firstChild);
-      assert.equal(element.style.top, '25px');
-      assert.equal(element.style.left, '72px');
-    });
-
-    test('placeBelow for Element argument', () => {
-      element.placeBelow(target);
-      assert.equal(element.style.top, '45px');
-      assert.equal(element.style.left, '72px');
-    });
-
-    test('placeBelow for Text Node argument', () => {
-      element.placeBelow(target.firstChild);
-      assert.equal(element.style.top, '45px');
-      assert.equal(element.style.left, '72px');
-    });
-
-    test('uses document.createRange', () => {
-      sinon.spy(document, 'createRange');
-      element._getTargetBoundingRect.restore();
-      sinon.spy(element, '_getTargetBoundingRect');
-      element.placeAbove(target.firstChild);
-      assert.isTrue(document.createRange.called);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
deleted file mode 100644
index ad671da..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
+++ /dev/null
@@ -1,585 +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 '../../shared/gr-lib-loader/gr-lib-loader';
-import {HLJS_LIBRARY_CONFIG} from '../../shared/gr-lib-loader/highlightjs_config';
-import {Side} from '../../../constants/constants';
-
-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-go', 'go'],
-  ['text/x-groovy', 'groovy'],
-  ['text/x-haml', 'haml'],
-  ['text/x-handlebars', 'handlebars'],
-  ['text/x-haskell', 'haskell'],
-  ['text/x-haxe', 'haxe'],
-  ['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, state.lineNums.left, Side.LEFT);
-      state.lastNotify.left = state.lineNums.left;
-    }
-    if (state.lineNums.right - state.lastNotify.right) {
-      this._notifyRange(
-        state.lastNotify.right,
-        state.lineNums.right,
-        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/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
deleted file mode 100644
index c907a80..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
+++ /dev/null
@@ -1,473 +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');
-    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);
-
-    // 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/elements/diff/gr-syntax-themes/gr-syntax-theme.ts b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
deleted file mode 100644
index 7f495fa..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
+++ /dev/null
@@ -1,123 +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.
- */
-
-// 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 {};
-
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-syntax-theme">
-  <template>
-    <style>
-      /**
-       * @overview Highlight.js emits the following classes that do not have
-       * styles here:
-       *    subst, symbol, class, function, doctag, meta-string, section, name,
-       *    builtin-name, bulletm, code, formula, quote, addition, deletion,
-       *    attribute
-       * @see {@link http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html}
-       */
-
-      .contentText {
-        color: var(--syntax-default-color);
-      }
-      .gr-syntax-attribute {
-        color: var(--syntax-attribute-color);
-      }
-      .gr-syntax-function {
-        color: var(--syntax-function-color);
-      }
-      .gr-syntax-meta {
-        color: var(--syntax-meta-color);
-      }
-      .gr-syntax-keyword,
-      .gr-syntax-name {
-        color: var(--syntax-keyword-color);
-      }
-      .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-property {
-        color: var(--syntax-property-color);
-      }
-      .gr-syntax-selector-pseudo {
-        color: var(--syntax-selector-pseudo-color);
-      }
-      .gr-syntax-regexp {
-        color: var(--syntax-regexp-color);
-      }
-      .gr-syntax-selector-attr {
-        color: var(--syntax-selector-attr-color);
-      }
-      .gr-syntax-template-tag {
-        color: var(--syntax-template-tag-color);
-      }
-      .gr-syntax-params {
-        color: var(--syntax-params-color);
-      }
-      .gr-syntax-doctag {
-        font-weight: var(--syntax-doctag-weight);
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
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 3adb0f3..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
@@ -14,77 +14,117 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-list-view/gr-list-view';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-documentation-search_html';
 import {getBaseUrl} from '../../../utils/url-util';
-import {customElement, property} from '@polymer/decorators';
 import {DocResult} from '../../../types/common';
 import {fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ListViewParams} from '../../gr-app-types';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {LitElement, PropertyValues, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
 
 @customElement('gr-documentation-search')
-export class GrDocumentationSearch extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDocumentationSearch extends LitElement {
   /**
    * URL params passed from the router.
    */
-  @property({type: Object, observer: '_paramsChanged'})
+  @property({type: Object})
   params?: ListViewParams;
 
-  @property({type: Array})
-  _documentationSearches?: DocResult[];
+  // private but used in test
+  @state() documentationSearches?: DocResult[];
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: String})
-  _filter?: string;
+  @state() private filter = '';
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
     fireTitleChange(this, 'Documentation Search');
   }
 
-  _paramsChanged(params: ListViewParams) {
-    this._loading = true;
-    this._filter = params?.filter ?? '';
-
-    return this._getDocumentationSearches(this._filter);
+  static override get styles() {
+    return [sharedStyles, tableStyles];
   }
 
-  _getDocumentationSearches(filter: string) {
-    this._documentationSearches = [];
+  override render() {
+    return html` <gr-list-view
+      .filter=${this.filter}
+      .offset=${0}
+      .loading=${this.loading}
+      .path=${'/Documentation'}
+    >
+      <table id="list" class="genericList">
+        <tbody>
+          <tr class="headerRow">
+            <th class="name topHeader">Name</th>
+            <th class="name topHeader"></th>
+            <th class="name topHeader"></th>
+          </tr>
+          <tr id="loading" class="loadingMsg ${this.loading ? 'loading' : ''}">
+            <td>Loading...</td>
+          </tr>
+        </tbody>
+        <tbody class=${this.loading ? 'loading' : ''}>
+          ${this.documentationSearches?.map(search =>
+            this.renderDocumentationList(search)
+          )}
+        </tbody>
+      </table>
+    </gr-list-view>`;
+  }
+
+  private renderDocumentationList(search: DocResult) {
+    return html`
+      <tr class="table">
+        <td class="name">
+          <a href=${this.computeSearchUrl(search.url)}>${search.title}</a>
+        </td>
+        <td></td>
+        <td></td>
+      </tr>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this.paramsChanged();
+    }
+  }
+
+  // private but used in test
+  paramsChanged() {
+    this.loading = true;
+    this.filter = this.params?.filter ?? '';
+
+    return this.getDocumentationSearches(this.filter);
+  }
+
+  private getDocumentationSearches(filter: string) {
+    this.documentationSearches = [];
     return this.restApiService
       .getDocumentationSearches(filter)
       .then(searches => {
         // Late response.
-        if (filter !== this._filter || !searches) {
+        if (filter !== this.filter || !searches) {
           return;
         }
-        this._documentationSearches = searches;
-        this._loading = false;
+        this.documentationSearches = searches;
+      })
+      .finally(() => {
+        this.loading = false;
       });
   }
 
-  _computeSearchUrl(url?: string) {
-    if (!url) {
-      return '';
-    }
+  private computeSearchUrl(url?: string) {
+    if (!url) return '';
     return `${getBaseUrl()}/${url}`;
   }
-
-  computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
deleted file mode 100644
index 95ce1ec..0000000
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
+++ /dev/null
@@ -1,56 +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-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <gr-list-view
-    filter="[[_filter]]"
-    offset="0"
-    loading="[[_loading]]"
-    path="/Documentation"
-  >
-    <table id="list" class="genericList">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Name</th>
-          <th class="name topHeader"></th>
-          <th class="name topHeader"></th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_documentationSearches]]">
-          <tr class="table">
-            <td class="name">
-              <a href$="[[_computeSearchUrl(item.url)]]">[[item.title]]</a>
-            </td>
-            <td></td>
-            <td></td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </gr-list-view>
-`;
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index bf6a0d5..4bba9cd 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -19,50 +19,53 @@
 import './gr-documentation-search';
 import {GrDocumentationSearch} from './gr-documentation-search';
 import {page} from '../../../utils/page-wrapper-utils';
-import 'lodash/lodash';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {DocResult} from '../../../types/common';
-import {ListViewParams} from '../../gr-app-types';
 
 const basicFixture = fixtureFromElement('gr-documentation-search');
 
-let counter: number;
-const documentationGenerator = () => {
+function documentationGenerator(counter: number) {
   return {
-    title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
+    title: `Gerrit Code Review - REST API Developers Notes${counter}`,
     url: 'Documentation/dev-rest-api.html',
   };
-};
+}
+
+function createDocumentationList(n: number) {
+  const list = [];
+  for (let i = 0; i < n; ++i) {
+    list.push(documentationGenerator(i));
+  }
+  return list;
+}
 
 suite('gr-documentation-search tests', () => {
   let element: GrDocumentationSearch;
   let documentationSearches: DocResult[];
 
-  let value: ListViewParams;
-
-  setup(() => {
+  setup(async () => {
     sinon.stub(page, 'show');
     element = basicFixture.instantiate();
-    counter = 0;
+    await element.updateComplete;
   });
 
   suite('list with searches for documentation', () => {
     setup(async () => {
-      documentationSearches = _.times(26, documentationGenerator);
+      documentationSearches = createDocumentationList(26);
       stubRestApi('getDocumentationSearches').returns(
         Promise.resolve(documentationSearches)
       );
-      await element._paramsChanged(value);
-      await flush();
+      await element.paramsChanged();
+      await element.updateComplete;
     });
 
     test('test for test repo in the list', async () => {
       assert.equal(
-        element._documentationSearches![0].title,
+        element.documentationSearches![1].title,
         'Gerrit Code Review - REST API Developers Notes1'
       );
       assert.equal(
-        element._documentationSearches![0].url,
+        element.documentationSearches![1].url,
         'Documentation/dev-rest-api.html'
       );
     });
@@ -70,30 +73,34 @@
 
   suite('filter', () => {
     setup(() => {
-      documentationSearches = _.times(25, documentationGenerator);
+      documentationSearches = createDocumentationList(25);
     });
 
-    test('_paramsChanged', async () => {
+    test('paramsChanged', async () => {
       const stub = stubRestApi('getDocumentationSearches').returns(
         Promise.resolve(documentationSearches)
       );
-      const value = {filter: 'test'};
-      await element._paramsChanged(value);
+      element.params = {filter: 'test'};
+      await element.paramsChanged();
       assert.isTrue(stub.lastCall.calledWithExactly('test'));
     });
   });
 
   suite('loading', () => {
     test('correct contents are displayed', async () => {
-      assert.isTrue(element._loading);
-      assert.equal(element.computeLoadingClass(element._loading), 'loading');
-      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+      assert.isTrue(element.loading);
+      assert.equal(
+        getComputedStyle(queryAndAssert(element, '#loading')).display,
+        'block'
+      );
 
-      element._loading = false;
+      element.loading = false;
 
-      await flush();
-      assert.equal(element.computeLoadingClass(element._loading), '');
-      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyle(queryAndAssert(element, '#loading')).display,
+        'none'
+      );
     });
   });
 });
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 1615a23..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,78 +14,287 @@
  * 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 {appContext} from '../../../services/app-context';
-import {IronInputElement} from '@polymer/iron-input';
+import {getAppContext} from '../../../services/app-context';
 import {fireAlert, fireReload} from '../../../utils/event-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;
+  change?: ChangeInfo;
 
   @property({type: String})
-  patchNum!: PatchSetNum;
+  patchNum?: PatchSetNum;
 
   @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 = appContext.restApiService;
+  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:
@@ -101,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) {
@@ -202,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
@@ -236,68 +456,70 @@
         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
       .queryChangeFiles(this.change._number, this.patchNum, input)
       .then(res => {
@@ -309,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;
       }
@@ -345,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 6198f17..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,22 +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();
-    showDialogSpy = sinon.spy(element, '_showDialog');
-    closeDialogSpy = sinon.spy(element, '_closeDialog');
-    hideDialogStub = sinon.stub(element, '_hideAllDialogs');
+    element.patchNum = 1 as PatchSetNum;
+    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
     );
   });
 
@@ -64,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 () => {
@@ -82,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,
@@ -105,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, '');
       });
     });
   });
@@ -129,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);
     });
@@ -163,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);
 
@@ -187,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 === '');
       });
     });
   });
@@ -210,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);
     });
@@ -249,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);
 
@@ -278,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 === '');
       });
     });
   });
@@ -306,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);
         });
@@ -332,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');
@@ -350,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, '');
       });
     });
   });
@@ -372,7 +390,7 @@
       fileStub = stubRestApi('saveFileUploadChangeEdit');
     });
 
-    test('_handleUploadConfirm', () => {
+    test('handleUploadConfirm', () => {
       fileStub.returns(Promise.resolve({ok: true}));
 
       element.change = {
@@ -392,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));
       });
     });
@@ -400,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 24ebd67..dc8e7a6 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,39 +14,37 @@
  * 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 {
-  ChangeInfo,
   PatchSetNum,
   EditPreferencesInfo,
   Base64FileContent,
   NumericChangeId,
   EditPatchSetNum,
 } from '../../../types/common';
+import {ParsedChangeInfo} from '../../../types/types';
 import {HttpMethod, NotifyType} from '../../../constants/constants';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 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,53 +69,47 @@
    * @event show-alert
    */
 
-  @property({type: Object, observer: '_paramsChanged'})
+  @property({type: Object})
   params?: GenerateUrlEditViewParameters;
 
-  @property({type: Object, observer: '_editChange'})
-  _change?: ChangeInfo | null;
+  // 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;
+  @state() private lineNum?: number;
 
-  @property({type: Number})
-  _lineNum?: number;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly storage = getAppContext().storageService;
 
-  private readonly storage = appContext.storageService;
+  private readonly reporting = getAppContext().reportingService;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly userModel = getAppContext().userModel;
 
   // Tests use this so needs to be non private
   storeTask?: DelayedTask;
@@ -143,23 +120,23 @@
   constructor() {
     super();
     this.addEventListener('content-change', e => {
-      this._handleContentChange(e as CustomEvent<{value: string}>);
+      this.handleContentChange(e as CustomEvent<{value: string}>);
     });
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._getEditPrefs().then(prefs => {
-      this._prefs = prefs;
+    subscribe(this, this.userModel.editPreferences$, editPreferences => {
+      this.editPrefs = editPreferences;
     });
     this.cleanups.push(
-      addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, e =>
-        this._handleSaveShortcut(e)
+      addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
+        this.handleSaveShortcut()
       )
     );
     this.cleanups.push(
-      addShortcut(this, {key: 's', modifiers: [Modifier.META_KEY]}, e =>
-        this._handleSaveShortcut(e)
+      addShortcut(this, {key: 's', modifiers: [Modifier.META_KEY]}, () =>
+        this.handleSaveShortcut()
       )
     );
   }
@@ -171,98 +148,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);
   }
 
-  _getChangeDetail(changeNum: NumericChangeId) {
-    return this.restApiService.getDiffChangeDetail(changeNum).then(change => {
-      this._change = change;
-    });
+  private async getChangeDetail(changeNum: NumericChangeId) {
+    this.change = await this.restApiService.getChangeDetail(changeNum);
   }
 
-  _editChange(value?: ChangeInfo | 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?: ChangeInfo | 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();
+  }
+
+  // private but used in test
+  viewEditInChangeView() {
+    if (this.change)
+      GerritNav.navigateToChange(this.change, {
+        isEdit: true,
+        forceReload: true,
       });
   }
 
-  _viewEditInChangeView() {
-    if (this._change)
-      GerritNav.navigateToChange(this._change, undefined, undefined, true);
-  }
-
-  _getFileData(
+  // private but used in test
+  getFileData(
     changeNum: NumericChangeId,
     path: string,
     patchNum?: PatchSetNum
@@ -283,89 +403,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(
@@ -377,19 +495,19 @@
           handleError
         )
         .then(() => {
-          assertIsDefined(this._change, '_change');
-          GerritNav.navigateToChange(this._change);
+          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);
@@ -399,10 +517,10 @@
     );
   }
 
-  _handleSaveShortcut(e: KeyboardEvent) {
-    e.preventDefault();
-    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 f591ab2..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
@@ -16,10 +16,16 @@
  */
 
 import '../../../test/common-test-setup-karma';
+import './gr-editor-view';
 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,
@@ -30,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');
 
@@ -41,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('getDiffChangeDetail');
-    navigateStub = sinon.stub(element, '_viewEditInChangeView');
+    changeDetailStub = stubRestApi('getChangeDetail');
+    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,
@@ -75,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,
@@ -123,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');
   });
@@ -134,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,
@@ -175,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;
@@ -243,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);
     });
 
@@ -276,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');
         });
     });
 
@@ -295,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, '');
         });
     });
 
@@ -317,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');
         });
     });
 
@@ -335,77 +372,77 @@
       );
 
       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();
-    assert.equal(navStub.lastCall.args[1], undefined);
-    assert.equal(navStub.lastCall.args[3], true);
+    element.patchNum = EditPatchSetNum;
+    element.viewEditInChangeView();
+    assert.equal(navStub.lastCall.args[1]!.patchNum, undefined);
+    assert.equal(navStub.lastCall.args[1]!.isEdit, true);
   });
 
   suite('keyboard shortcuts', () => {
     // 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);
@@ -431,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');
         });
     });
 
@@ -459,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 d88eeaf..32661f0 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -14,8 +14,9 @@
  * 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';
 import './admin/gr-admin-view/gr-admin-view';
 import './documentation/gr-documentation-search/gr-documentation-search';
@@ -25,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';
@@ -37,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 {loginUrl} from '../utils/url-util';
+import {Shortcut} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GerritNav} from './core/gr-navigation/gr-navigation';
-import {appContext} from '../services/app-context';
-import {flush} from '@polymer/polymer/lib/utils/flush';
-import {customElement, observe, property} from '@polymer/decorators';
+import {getAppContext} from '../services/app-context';
 import {GrRouter} from './core/gr-router/gr-router';
-import {
-  AccountDetailInfo,
-  ElementPropertyDeepChange,
-  ServerInfo,
-} from '../types/common';
+import {AccountDetailInfo, ServerInfo} from '../types/common';
 import {
   constructServerErrorMsg,
   GrErrorManager,
@@ -64,6 +52,8 @@
 import {
   AppElementJustRegisteredParams,
   AppElementParams,
+  AppElementPluginScreenParams,
+  AppElementSearchParam,
   isAppElementJustRegisteredParams,
 } from './gr-app-types';
 import {GrMainHeader} from './core/gr-main-header/gr-main-header';
@@ -75,13 +65,21 @@
   PageErrorEventDetail,
   RpcLogEvent,
   TitleChangeEventDetail,
+  ValueChangedEvent,
 } from '../types/events';
-import {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 {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 {listen} from '../services/shortcuts/shortcuts-service';
+import './gr-css-mixins';
 
 interface ErrorInfo {
   text: string;
@@ -89,179 +87,152 @@
   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(PolymerElement);
-
 // 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 loadRegistrationDialog = false;
 
-  @property({type: String})
-  _feedbackUrl?: string;
-
-  @property({type: Boolean})
-  mobileSearch = 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;
 
-  private reporting = appContext.reportingService;
+  // Triggers dom-if unsetting/setting restamp behaviour in lit
+  @state() private invalidateChangeViewCache = false;
 
-  private readonly restApiService = appContext.restApiService;
+  // Triggers dom-if unsetting/setting restamp behaviour in lit
+  @state() private invalidateDiffViewCache = false;
 
-  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()),
-    ];
-  }
+  readonly router = new GrRouter();
+
+  private reporting = getAppContext().reportingService;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getBrowserModel = resolve(this, browserModelToken);
+
+  private readonly shortcuts = new ShortcutController(this);
 
   constructor() {
     super();
-    // We just want to instantiate this service somewhere. It is reacting to
-    // model changes and updates the config model, but at the moment the service
-    // is not called from anywhere.
-    appContext.configService;
     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)
+    document.addEventListener(EventType.LOCATION_CHANGE, 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 ready() {
-    super.ready();
-    this._updateLoginUrl();
+  override connectedCallback() {
+    super.connectedCallback();
+    const resizeObserver = this.getBrowserModel().observeWidth();
+    resizeObserver.observe(this);
+
     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 {
@@ -269,32 +240,28 @@
       }
     });
     this.restApiService.getConfig().then(config => {
-      this._serverConfig = config;
-
-      if (config && config.gerrit && config.gerrit.report_bug_url) {
-        this._feedbackUrl = config.gerrit.report_bug_url;
-      }
+      this.serverConfig = config;
     });
     this.restApiService.getVersion().then(version => {
-      this._version = version;
-      this._logWelcome();
+      this.version = version;
+      this.logWelcome();
     });
 
-    if (window.localStorage.getItem('dark-theme')) {
-      applyDarkTheme();
-    }
+    const isDarkTheme = !!window.localStorage.getItem('dark-theme');
+    document.documentElement.classList.toggle('darkTheme', isDarkTheme);
+    document.documentElement.classList.toggle('lightTheme', !isDarkTheme);
+    if (isDarkTheme) applyDarkTheme();
 
     // 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,
         selectedFileIndex: 0,
         showReplyDialog: false,
-        showDownloadDialog: false,
         diffMode: null,
         numFilesShown: null,
       },
@@ -307,85 +274,374 @@
     };
   }
 
-  _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.handleShowKeyboardShortcuts}
+        .mobileSearchHidden=${!this.mobileSearch}
+        .loginUrl=${loginUrl(this.serverConfig?.auth)}
+        .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
+        ?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=${loginUrl(this.serverConfig?.auth)}
+        .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
+      ></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');
+      this.registrationOverlay.open();
+      this.registrationDialog.loadData().then(() => {
+        this.registrationOverlay!.refit();
       });
     }
     // To fix bug announce read after each new view, we reset announce with
@@ -393,27 +649,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) {
@@ -427,60 +684,31 @@
             errorText: text,
             trace,
           });
-          this._lastError = err;
+          this.lastError = err;
         });
       }
     }
   }
 
-  _handleLocationChange(e: LocationChangeEvent) {
-    this._updateLoginUrl();
-
+  private handleLocationChange(e: LocationChangeEvent) {
+    this.requestUpdate();
     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() {
-    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 =
-        baseUrl +
-        '/login/' +
-        encodeURIComponent(
-          '/' +
-            window.location.pathname.substring(baseUrl.length) +
-            window.location.search +
-            window.location.hash
-        );
-    } else {
-      this._loginUrl =
-        '/login/' +
-        encodeURIComponent(
-          window.location.pathname +
-            window.location.search +
-            window.location.hash
-        );
-    }
-  }
-
-  @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 {
@@ -488,104 +716,96 @@
     }
   }
 
-  _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 handleShowKeyboardShortcuts() {
     this.loadKeyboardShortcutsDialog = true;
-    flush();
-    (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).open();
+    await this.updateComplete;
+    assertIsDefined(this.keyboardShortcuts, 'keyboardShortcuts');
+    this.keyboardShortcuts.open();
   }
 
-  _showKeyboardShortcuts() {
+  private async 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();
+    await this.updateComplete;
+    if (!this.keyboardShortcuts) return;
+    if (this.keyboardShortcuts.opened) {
+      this.keyboardShortcuts.cancel();
       return;
     }
-    keyboardShortcuts.open();
-    this._footerHeaderAriaHidden = true;
-    this._mainAriaHidden = true;
+    this.keyboardShortcuts.open();
+    this.footerHeaderAriaHidden = true;
+    this.mainAriaHidden = true;
   }
 
-  _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}`);
     }
-    if (this._feedbackUrl) {
-      console.info(`Please file bugs and feedback at: ${this._feedbackUrl}`);
-    }
     console.groupEnd();
   }
 
@@ -594,11 +814,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;
   }
 
@@ -608,6 +828,24 @@
       ? 'app-theme-dark'
       : 'app-theme-light';
   }
+
+  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,
+    };
+  }
 }
 
 declare global {
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 a1e6ac9..0000000
--- a/polygerrit-ui/app/elements/gr-app-element_html.ts
+++ /dev/null
@@ -1,245 +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);
-    }
-    .feedback {
-      color: var(--error-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}}"
-        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}}"
-      ></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>
-      <template is="dom-if" if="[[_feedbackUrl]]">
-        <a
-          class="feedback"
-          href$="[[_feedbackUrl]]"
-          rel="noopener"
-          target="_blank"
-          >Report bug</a
-        >
-        |
-      </template>
-      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-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index de749df..243e3d5 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -22,14 +22,15 @@
  * expose these variables until plugins switch to direct import from polygerrit.
  */
 
-import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
+import {GrAnnotation} from '../embed/diff/gr-diff-highlight/gr-annotation';
 import {page} from '../utils/page-wrapper-utils';
 import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
 import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
+import {AppContext} from '../services/app-context';
 
-export function initGlobalVariables() {
+export function initGlobalVariables(appContext: AppContext) {
   window.GrAnnotation = GrAnnotation;
   window.page = page;
   window.GrPluginActionContext = GrPluginActionContext;
-  initGerritPluginApi();
+  initGerritPluginApi(appContext);
 }
diff --git a/polygerrit-ui/app/elements/gr-app-init.ts b/polygerrit-ui/app/elements/gr-app-init.ts
deleted file mode 100644
index ab38326..0000000
--- a/polygerrit-ui/app/elements/gr-app-init.ts
+++ /dev/null
@@ -1,28 +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 {initAppContext} from '../services/app-context-init';
-import {
-  initVisibilityReporter,
-  initPerformanceReporter,
-  initErrorReporter,
-} from '../services/gr-reporting/gr-reporting_impl';
-import {appContext} from '../services/app-context';
-
-initAppContext();
-initVisibilityReporter(appContext);
-initPerformanceReporter(appContext);
-initErrorReporter(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 6c8bdb9..bd6b229 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import {
+  DashboardSection,
   GenerateUrlParameters,
   GroupDetailView,
   RepoDetailView,
@@ -42,7 +43,7 @@
   project?: RepoName;
   dashboard: DashboardId;
   user?: string;
-  sections: Array<{name: string; query: string}>;
+  sections?: DashboardSection[];
   title?: string;
 }
 
@@ -124,8 +125,15 @@
   edit?: boolean;
   patchNum?: RevisionPatchSetNum;
   basePatchNum?: BasePatchSetNum;
-  queryMap?: Map<string, string> | URLSearchParams;
   commentId?: UrlEncodedCommentId;
+  forceReload?: boolean;
+  tab?: string;
+  /** regular expression for filtering check runs */
+  filter?: string;
+  /** regular expression for selecting check runs */
+  select?: string;
+  /** selected attempt for selected check runs */
+  attempt?: number;
 }
 
 export interface AppElementJustRegisteredParams {
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 463fab9..1d0b1ad 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -16,7 +16,6 @@
  */
 
 import {safeTypesBridge} from '../utils/safe-types-util';
-import './gr-app-init';
 import './font-roboto-local-loader';
 // Sets up global Polymer variable, because plugins requires it.
 import '../scripts/bundled-polymer';
@@ -36,18 +35,55 @@
 
 import {initGlobalVariables} from './gr-app-global-var-init';
 import './gr-app-element';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-app_html';
-import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
-import {customElement} from '@polymer/decorators';
+import {Finalizable} from '../services/registry';
+import {provide} from '../models/dependency';
 import {installPolymerResin} from '../scripts/polymer-resin-install';
 
+import {
+  createAppContext,
+  createAppDependencies,
+} from '../services/app-context-init';
+import {
+  initVisibilityReporter,
+  initPerformanceReporter,
+  initErrorReporter,
+} from '../services/gr-reporting/gr-reporting_impl';
+import {injectAppContext} from '../services/app-context';
+import {html, LitElement} from 'lit';
+import {customElement} from 'lit/decorators';
+
+const appContext = createAppContext();
+injectAppContext(appContext);
+const reportingService = appContext.reportingService;
+initVisibilityReporter(reportingService);
+initPerformanceReporter(reportingService);
+initErrorReporter(reportingService);
+
 installPolymerResin(safeTypesBridge);
 
 @customElement('gr-app')
-export class GrApp extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrApp extends LitElement {
+  private finalizables: Finalizable[] = [];
+
+  override connectedCallback() {
+    super.connectedCallback();
+    const dependencies = createAppDependencies(appContext);
+    for (const [token, service] of dependencies) {
+      this.finalizables.push(service);
+      provide(this, token, () => service);
+    }
+  }
+
+  override disconnectedCallback() {
+    for (const f of this.finalizables) {
+      f.finalize();
+    }
+    this.finalizables = [];
+    super.disconnectedCallback();
+  }
+
+  override render() {
+    return html`<gr-app-element id="app-element"></gr-app-element>`;
   }
 }
 
@@ -57,5 +93,4 @@
   }
 }
 
-initGlobalVariables();
-initGerritPluginApi();
+initGlobalVariables(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app_test.js b/polygerrit-ui/app/elements/gr-app_test.js
deleted file mode 100644
index 5a3b1f2..0000000
--- a/polygerrit-ui/app/elements/gr-app_test.js
+++ /dev/null
@@ -1,77 +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-app.js';
-import {appContext} from '../services/app-context.js';
-import {GerritNav} from './core/gr-navigation/gr-navigation.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {stubRestApi} from '../test/test-utils.js';
-
-const basicFixture = fixtureFromTemplate(html`<gr-app id="app"></gr-app>`);
-
-suite('gr-app tests', () => {
-  let element;
-  let configStub;
-
-  setup(async () => {
-    sinon.stub(appContext.reportingService, 'appStarted');
-    stub('gr-account-dropdown', '_getTopContent');
-    stub('gr-router', 'start');
-    stubRestApi('getAccount').returns(Promise.resolve({}));
-    stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
-    configStub = stubRestApi('getConfig').returns(Promise.resolve({
-      plugin: {},
-      auth: {
-        auth_type: undefined,
-      },
-    }));
-    stubRestApi('getPreferences').returns(Promise.resolve({my: []}));
-    stubRestApi('getVersion').returns(Promise.resolve(42));
-    stubRestApi('probePath').returns(Promise.resolve(42));
-
-    element = basicFixture.instantiate();
-    await flush();
-  });
-
-  const appElement = () => element.$['app-element'];
-
-  test('reporting', () => {
-    assert.isTrue(appElement().reporting.appStarted.calledOnce);
-  });
-
-  test('reporting called before router start', () => {
-    const element = appElement();
-    const appStartedStub = element.reporting.appStarted;
-    const routerStartStub = element.$.router.start;
-    sinon.assert.callOrder(appStartedStub, routerStartStub);
-  });
-
-  test('passes config to gr-plugin-host', () =>
-    configStub.lastCall.returnValue.then(config => {
-      assert.deepEqual(appElement().$.plugins.config, config);
-    })
-  );
-
-  test('_paramsChanged sets search page', () => {
-    appElement()._paramsChanged({base: {view: GerritNav.View.CHANGE}});
-    assert.notOk(appElement()._lastSearchPage);
-    appElement()._paramsChanged({base: {view: GerritNav.View.SEARCH}});
-    assert.ok(appElement()._lastSearchPage);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
new file mode 100644
index 0000000..251f3ea
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -0,0 +1,80 @@
+/**
+ * @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 './gr-app';
+import {getAppContext} from '../services/app-context';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {queryAndAssert, stubRestApi} from '../test/test-utils';
+import {GrApp} from './gr-app';
+import {
+  createAppElementChangeViewParams,
+  createAppElementSearchViewParams,
+  createPreferences,
+  createServerInfo,
+} 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;
+  const config = createServerInfo();
+  let appStartedStub: sinon.SinonStub;
+  let routerStartStub: sinon.SinonStub;
+
+  setup(async () => {
+    appStartedStub = sinon.stub(getAppContext().reportingService, 'appStarted');
+    stub('gr-account-dropdown', '_getTopContent');
+    routerStartStub = sinon.stub(GrRouter.prototype, 'start');
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
+    stubRestApi('getConfig').returns(Promise.resolve(config));
+    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
+    stubRestApi('getVersion').returns(Promise.resolve('42'));
+    stubRestApi('probePath').returns(Promise.resolve(false));
+
+    grApp = await fixture<GrApp>(html`<gr-app id="app"></gr-app>`);
+    await grApp.updateComplete;
+  });
+
+  test('reporting', () => {
+    assert.isTrue(appStartedStub.calledOnce);
+  });
+
+  test('reporting called before router start', () => {
+    sinon.assert.callOrder(appStartedStub, routerStartStub);
+  });
+
+  test('passes config to gr-plugin-host', () => {
+    const grAppElement = queryAndAssert<GrAppElement>(grApp, '#app-element');
+    const pluginHost = queryAndAssert<GrPluginHost>(grAppElement, '#plugins');
+    assert.deepEqual(pluginHost.config, config);
+  });
+
+  test('_paramsChanged sets search page', () => {
+    const grAppElement = queryAndAssert<GrAppElement>(grApp, '#app-element');
+
+    grAppElement.params = createAppElementChangeViewParams();
+    grAppElement.paramsChanged();
+    assert.notOk(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/shortcut-controller.ts b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
new file mode 100644
index 0000000..4fcc1d2
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
@@ -0,0 +1,101 @@
+/**
+ * @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 {ReactiveController, ReactiveControllerHost} from 'lit';
+import {Binding} from '../../utils/dom-util';
+import {ShortcutsService} from '../../services/shortcuts/shortcuts-service';
+import {getAppContext} from '../../services/app-context';
+import {Shortcut} from '../../services/shortcuts/shortcuts-config';
+
+interface ShortcutListener {
+  binding: Binding;
+  listener: (e: KeyboardEvent) => void;
+}
+
+interface AbstractListener {
+  shortcut: Shortcut;
+  listener: (e: KeyboardEvent) => void;
+}
+
+type Cleanup = () => void;
+
+export class ShortcutController implements ReactiveController {
+  private readonly service: ShortcutsService = getAppContext().shortcutsService;
+
+  private readonly listenersLocal: ShortcutListener[] = [];
+
+  private readonly listenersGlobal: ShortcutListener[] = [];
+
+  private readonly listenersAbstract: AbstractListener[] = [];
+
+  private cleanups: Cleanup[] = [];
+
+  constructor(private readonly host: ReactiveControllerHost & HTMLElement) {
+    host.addController(this);
+  }
+
+  // Note that local shortcuts are *not* suppressed when the user has shortcuts
+  // disabled or when the event comes from elements like <input>. So this method
+  // is intended for shortcuts like ESC and Ctrl-ENTER.
+  // If you need suppressed local shortcuts, then just add an options parameter.
+  addLocal(binding: Binding, listener: (e: KeyboardEvent) => void) {
+    this.listenersLocal.push({binding, listener});
+  }
+
+  addGlobal(binding: Binding, listener: (e: KeyboardEvent) => void) {
+    this.listenersGlobal.push({binding, listener});
+  }
+
+  /**
+   * `Shortcut` is more abstract than a concrete `Binding`. A `Shortcut` has a
+   * description text and (several) bindings configured in the file
+   * `shortcuts-config.ts`.
+   *
+   * Use this method when you are migrating from Polymer to Lit. Call it for
+   * each entry of keyboardShortcuts().
+   */
+  addAbstract(shortcut: Shortcut, listener: (e: KeyboardEvent) => void) {
+    this.listenersAbstract.push({shortcut, listener});
+  }
+
+  hostConnected() {
+    for (const {binding, listener} of this.listenersLocal) {
+      const cleanup = this.service.addShortcut(this.host, binding, listener, {
+        shouldSuppress: false,
+      });
+      this.cleanups.push(cleanup);
+    }
+    for (const {shortcut, listener} of this.listenersAbstract) {
+      const cleanup = this.service.addShortcutListener(shortcut, listener);
+      this.cleanups.push(cleanup);
+    }
+    for (const {binding, listener} of this.listenersGlobal) {
+      const cleanup = this.service.addShortcut(
+        document.body,
+        binding,
+        listener
+      );
+      this.cleanups.push(cleanup);
+    }
+  }
+
+  hostDisconnected() {
+    for (const cleanup of this.cleanups) {
+      cleanup();
+    }
+    this.cleanups = [];
+  }
+}
diff --git a/polygerrit-ui/app/elements/lit/subscription-controller.ts b/polygerrit-ui/app/elements/lit/subscription-controller.ts
index ab4ed64..b37a978 100644
--- a/polygerrit-ui/app/elements/lit/subscription-controller.ts
+++ b/polygerrit-ui/app/elements/lit/subscription-controller.ts
@@ -1,22 +1,31 @@
 /**
  * @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';
 
+const SUBSCRIPTION_SYMBOL = Symbol('subscriptions');
+
+// Checks whether a subscription can be added. Returns true if it can be added,
+// return false if it's already present.
+// Subscriptions are stored on the host so they have the same life-time as the
+// host.
+function checkSubscription<T>(
+  host: ReactiveControllerHost,
+  obs$: Observable<T>,
+  setProp: (t: T) => void
+): boolean {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const hostSubscriptions = ((host as any)[SUBSCRIPTION_SYMBOL] ||= new Map());
+  if (!hostSubscriptions.has(obs$)) hostSubscriptions.set(obs$, new Set());
+  const obsSubscriptions = hostSubscriptions.get(obs$);
+  if (obsSubscriptions.has(setProp)) return false;
+  obsSubscriptions.add(setProp);
+  return true;
+}
+
 /**
  * Enables components to simply hook up a property with an Observable like so:
  *
@@ -27,9 +36,9 @@
   obs$: Observable<T>,
   setProp: (t: T) => void
 ) {
+  if (!checkSubscription(host, obs$, setProp)) return;
   host.addController(new SubscriptionController(obs$, setProp));
 }
-
 export class SubscriptionController<T> implements ReactiveController {
   private sub?: Subscription;
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
index 7a91c68..cf0e23a 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
@@ -16,7 +16,7 @@
  */
 import {EventType, PluginApi} from '../../../api/plugin';
 import {AdminPluginApi, MenuLink} from '../../../api/admin';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 
 /**
  * GrAdminApi class.
@@ -27,7 +27,7 @@
   // TODO(TS): maybe define as enum if its a limited set
   private menuLinks: MenuLink[] = [];
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   constructor(private readonly plugin: PluginApi) {
     this.reporting.trackApi(this.plugin, 'admin', 'constructor');
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 69%
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 9a8f75e..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,28 +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 {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
+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;
-    pluginApi.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);
   });
@@ -52,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-attribute-helper/gr-attribute-helper.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
index c3d9e4d..0654914 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
@@ -16,13 +16,13 @@
  */
 import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
 import {PluginApi} from '../../../api/plugin';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 
 export class GrAttributeHelper implements AttributeHelperPluginApi {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   private readonly _promises = new Map<string, Promise<any>>();
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   // TODO(TS): Change any to something more like HTMLElement.
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
index 2d83012..94eb292 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
@@ -17,7 +17,6 @@
 
 import '../../../test/common-test-setup-karma.js';
 import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 
 Polymer({
   is: 'gr-attribute-helper-some-element',
@@ -31,15 +30,13 @@
 
 const basicFixture = fixtureFromElement('gr-attribute-helper-some-element');
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-attribute-helper tests', () => {
   let element;
   let instance;
 
   setup(() => {
     let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     element = basicFixture.instantiate();
     instance = plugin.attributeHelper(element);
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
index 087779e..8bd3f15 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -22,7 +22,7 @@
   CheckResult,
   CheckRun,
 } from '../../../api/checks';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 
 const DEFAULT_CONFIG: ChecksApiConfig = {
   fetchPollingIntervalSeconds: 60,
@@ -43,9 +43,9 @@
 export class GrChecksApi implements ChecksPluginApi {
   private state = State.NOT_REGISTERED;
 
-  private readonly checksService = appContext.checksService;
+  private readonly reporting = getAppContext().reportingService;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly pluginsModel = getAppContext().pluginsModel;
 
   constructor(readonly plugin: PluginApi) {
     this.reporting.trackApi(this.plugin, 'checks', 'constructor');
@@ -53,14 +53,18 @@
 
   announceUpdate() {
     this.reporting.trackApi(this.plugin, 'checks', 'announceUpdate');
-    this.checksService.reload(this.plugin.getPluginName());
+    this.pluginsModel.checksAnnounce(this.plugin.getPluginName());
   }
 
   updateResult(run: CheckRun, result: CheckResult) {
     if (result.externalId === undefined) {
       throw new Error('ChecksApi.updateResult() was called without externalId');
     }
-    this.checksService.updateResult(this.plugin.getPluginName(), run, result);
+    this.pluginsModel.checksUpdate({
+      pluginName: this.plugin.getPluginName(),
+      run,
+      result,
+    });
   }
 
   register(provider: ChecksProvider, config?: ChecksApiConfig): void {
@@ -68,10 +72,10 @@
     if (this.state === State.REGISTERED)
       throw new Error('Only one provider can be registered per plugin.');
     this.state = State.REGISTERED;
-    this.checksService.register(
-      this.plugin.getPluginName(),
+    this.pluginsModel.checksRegister({
+      pluginName: this.plugin.getPluginName(),
       provider,
-      config ?? DEFAULT_CONFIG
-    );
+      config: config ?? DEFAULT_CONFIG,
+    });
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
index e1ec158..596c54b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
@@ -17,18 +17,15 @@
 
 import '../../../test/common-test-setup-karma';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
 import {PluginApi} from '../../../api/plugin';
 import {ChecksPluginApi} from '../../../api/checks';
 
-const gerritPluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-settings-api tests', () => {
   let checksApi: ChecksPluginApi | undefined;
 
   setup(() => {
     let pluginApi: PluginApi | undefined = undefined;
-    gerritPluginApi.install(
+    window.Gerrit.install(
       p => {
         pluginApi = p;
       },
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 883f2a6..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
+++ /dev/null
@@ -1,125 +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';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-dom-hooks tests', () => {
-  let instance;
-  let hook;
-
-  setup(() => {
-    let plugin;
-    pluginApi.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 00fa77d..64548ac 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,38 +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.
@@ -42,37 +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>();
+  private readonly 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 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
@@ -85,15 +85,16 @@
       if (slot && slotEl?.parentNode) {
         slotEl.parentNode.insertBefore(el, slotEl.nextSibling);
       } else {
-        this._appendChild(el);
+        this.appendChild(el);
       }
       return el;
     });
   }
 
-  // As of March 2021 the only known plugin that replaces an endpoint instead
-  // of decorating it is codemirror_editor.
-  _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];
@@ -101,22 +102,23 @@
     [...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
   ) {
+    const pluginName = plugin.getPluginName();
     el.plugin = plugin;
     // The content is (only?) used in ChangeReplyPluginApi.
     // Maybe it would be better for the consumer side to figure out the content
@@ -126,11 +128,18 @@
     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');
-      if (!paramName) throw Error('plugin endpoint parameter missing a name');
+      if (!paramName) {
+        this.reporting.error(
+          new Error(
+            `plugin '${pluginName}' endpoint '${this.name}': param is missing a name.`
+          )
+        );
+        return;
+      }
       return helper.get('value').then(() =>
         helper.bind('value', value =>
           // Note that despite the naming this sets the property, not the
@@ -146,9 +155,11 @@
         // if window is not specified, then the function is pulled from node
         // and the return type is NodeJS.Timeout object
         (timeoutId = window.setTimeout(() => {
-          console.warn(
-            'Timeout waiting for endpoint properties initialization: ' +
-              `plugin ${plugin.getPluginName()}, endpoint ${this.name}`
+          this.reporting.error(
+            new Error(
+              'Timeout waiting for endpoint properties initialization: ' +
+                `plugin ${pluginName}, endpoint ${this.name}`
+            )
           );
         }, INIT_PROPERTIES_TIMEOUT_MS))
     );
@@ -159,53 +170,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.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
deleted file mode 100644
index 1be5e82..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
+++ /dev/null
@@ -1,198 +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-endpoint-decorator.js';
-import '../gr-endpoint-param/gr-endpoint-param.js';
-import '../gr-endpoint-slot/gr-endpoint-slot.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-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;
-
-  let plugin;
-  let decorationHook;
-  let decorationHookWithSlot;
-  let replacementHook;
-
-  setup(async () => {
-    resetPlugins();
-    container = basicFixture.instantiate();
-    pluginApi.install(p => plugin = p, '0.1',
-        'http://some/plugin/url.js');
-    // Decoration
-    decorationHook = plugin.registerCustomComponent('first', 'some-module');
-    decorationHookWithSlot = plugin.registerCustomComponent(
-        'first',
-        'some-module-2',
-        {slot: 'test'}
-    );
-    // Replacement
-    replacementHook = plugin.registerCustomComponent(
-        'second', 'other-module', {replace: true});
-    // Mimic all plugins loaded.
-    getPluginLoader().loadPlugins([]);
-    await flush();
-  });
-
-  teardown(() => {
-    resetPlugins();
-  });
-
-  test('imports plugin-provided modules into endpoints', () => {
-    const endpoints =
-        Array.from(container.querySelectorAll('gr-endpoint-decorator'));
-    assert.equal(endpoints.length, 3);
-  });
-
-  test('decoration', () => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="first"]');
-    const modules = Array.from(element.root.children).filter(
-        element => element.nodeName === 'SOME-MODULE');
-    assert.equal(modules.length, 1);
-    const [module] = modules;
-    assert.isOk(module);
-    assert.equal(module['someparam'], 'barbar');
-    return decorationHook.getLastAttached()
-        .then(element => {
-          assert.strictEqual(element, module);
-        })
-        .then(() => {
-          element.remove();
-          assert.equal(decorationHook.getAllAttached().length, 0);
-        });
-  });
-
-  test('decoration with slot', () => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="first"]');
-    const modules = [...element.querySelectorAll('some-module-2')];
-    assert.equal(modules.length, 1);
-    const [module] = modules;
-    assert.isOk(module);
-    assert.equal(module['someparam'], 'barbar');
-    return decorationHookWithSlot.getLastAttached()
-        .then(element => {
-          assert.strictEqual(element, module);
-        })
-        .then(() => {
-          element.remove();
-          assert.equal(decorationHookWithSlot.getAllAttached().length, 0);
-        });
-  });
-
-  test('replacement', () => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="second"]');
-    const module = Array.from(element.root.children).find(
-        element => element.nodeName === 'OTHER-MODULE');
-    assert.isOk(module);
-    assert.equal(module['someparam'], 'foofoo');
-    return replacementHook.getLastAttached()
-        .then(element => {
-          assert.strictEqual(element, module);
-        })
-        .then(() => {
-          element.remove();
-          assert.equal(replacementHook.getAllAttached().length, 0);
-        });
-  });
-
-  test('late registration', async () => {
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    await flush();
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="banana"]');
-    const module = Array.from(element.root.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"]');
-    const module1 = Array.from(element.root.children).find(
-        element => element.nodeName === 'MOD-ONE');
-    assert.isOk(module1);
-    const module2 = Array.from(element.root.children).find(
-        element => element.nodeName === 'MOD-TWO');
-    assert.isOk(module2);
-  });
-
-  test('late param setup', async () => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="banana"]');
-    const param = element.querySelector('gr-endpoint-param');
-    param['value'] = undefined;
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    await flush();
-    let module = Array.from(element.root.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 flush();
-    module = Array.from(element.root.children).find(
-        element => element.nodeName === 'NOOB-NOOB');
-    assert.isOk(module);
-    assert.strictEqual(module['someParam'], value);
-  });
-
-  test('param is bound', async () => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="banana"]');
-    const param = element.querySelector('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(
-        element => element.nodeName === 'NOOB-NOOB');
-    assert.strictEqual(module['someParam'], value1);
-    param.value = value2;
-    assert.strictEqual(module['someParam'], value2);
-  });
-});
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
new file mode 100644
index 0000000..d7acc61
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
@@ -0,0 +1,267 @@
+/**
+ * @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 {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';
+
+suite('gr-endpoint-decorator', () => {
+  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 = 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;
+      },
+      '0.1',
+      'http://some/plugin/url.js'
+    );
+    // 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 decorationHookPromise;
+    await decorationHookSlotPromise;
+    await replacementHookPromise;
+  });
+
+  teardown(() => {
+    resetPlugins();
+  });
+
+  test('imports plugin-provided modules into endpoints', () => {
+    const endpoints = Array.from(
+      container.querySelectorAll('gr-endpoint-decorator')
+    );
+    assert.equal(endpoints.length, 3);
+  });
+
+  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)['first-param'], 'barbar');
+    return decorationHook
+      .getLastAttached()
+      .then((element: any) => {
+        assert.strictEqual(element, module);
+      })
+      .then(() => {
+        element.remove();
+        assert.equal(decorationHook.getAllAttached().length, 0);
+      });
+  });
+
+  test('decoration with slot', () => {
+    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)['first-param'], 'barbar');
+    return decorationHookWithSlot
+      .getLastAttached()
+      .then((element: any) => {
+        assert.strictEqual(element, module);
+      })
+      .then(() => {
+        element.remove();
+        assert.equal(decorationHookWithSlot.getAllAttached().length, 0);
+      });
+  });
+
+  test('replacement', () => {
+    const element = second;
+    const module = Array.from(element.children).find(
+      element => element.nodeName === 'OTHER-MODULE'
+    );
+    assert.isOk(module);
+    assert.equal((module as any)['second-param'], 'foofoo');
+    return replacementHook
+      .getLastAttached()
+      .then((element: any) => {
+        assert.strictEqual(element, module);
+      })
+      .then(() => {
+        element.remove();
+        assert.equal(replacementHook.getAllAttached().length, 0);
+      });
+  });
+
+  test('late registration', async () => {
+    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 () => {
+    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.children).find(
+      element => element.nodeName === 'MOD-TWO'
+    );
+    assert.isOk(module2);
+  });
+
+  test('late param setup', async () => {
+    let element = banana;
+    const param = queryAndAssert<GrEndpointParam>(element, 'gr-endpoint-param');
+    param['value'] = undefined;
+    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;
+
+    module = Array.from(element.children).find(
+      element => element.nodeName === 'NOOB-NOOB'
+    );
+    assert.isOk(module);
+    assert.strictEqual((module as any)['banana-param'], value);
+  });
+
+  test('param is bound', async () => {
+    const element = banana;
+    const param = queryAndAssert<GrEndpointParam>(element, 'gr-endpoint-param');
+    const value1 = {abc: 'def'};
+    const value2 = {def: 'abc'};
+    param.value = value1;
+    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.isOk(module);
+    assert.strictEqual((module as any)['banana-param'], value1);
+
+    param.value = 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 4999716..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,28 +1,23 @@
 /**
  * @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 {
+    'gr-endpoint-slot': GrEndpointSlot;
+  }
+}
 
 /**
  * `gr-endpoint-slot` is used when need control over where
  * 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;
 }
@@ -34,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-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
index 0c36cd5..24fc613 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -19,10 +19,10 @@
   UnsubscribeCallback,
 } from '../../../api/event-helper';
 import {PluginApi} from '../../../api/plugin';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 
 export class GrEventHelper implements EventHelperPluginApi {
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   constructor(readonly plugin: PluginApi, readonly element: HTMLElement) {
     this.reporting.trackApi(this.plugin, 'event', 'constructor');
@@ -56,8 +56,12 @@
         let mayContinue = true;
         try {
           mayContinue = callback(e);
-        } catch (exception) {
-          this.reporting.error(exception);
+        } catch (exception: unknown) {
+          this.reporting.error(
+            new Error('event listener callback error'),
+            undefined,
+            exception
+          );
         }
         if (mayContinue === false) {
           e.stopImmediatePropagation();
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
index 4e3d657..13bd535 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
@@ -18,7 +18,6 @@
 import '../../../test/common-test-setup-karma.js';
 import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
 import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 import {mockPromise} from '../../../test/test-utils.js';
 
 Polymer({
@@ -34,15 +33,13 @@
 
 const basicFixture = fixtureFromElement('gr-event-helper-some-element');
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-event-helper tests', () => {
   let element;
   let instance;
 
   setup(() => {
     let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     element = basicFixture.instantiate();
     instance = plugin.eventHelper(element);
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
index 88964bf..1554e1f 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
@@ -14,31 +14,32 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-external-style_html';
 import {getPluginEndpoints} 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 {LitElement, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
 
 @customElement('gr-external-style')
-export class GrExternalStyle extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrExternalStyle extends LitElement {
   // This is a required value for this component.
   @property({type: String})
   name!: string;
 
-  @property({type: Array})
-  _stylesApplied: string[] = [];
+  // private but used in test
+  @state() stylesApplied: string[] = [];
 
-  _applyStyle(name: string) {
-    if (this._stylesApplied.includes(name)) {
+  override render() {
+    return html`<slot></slot>`;
+  }
+
+  // private but used in test
+  applyStyle(name: string) {
+    if (this.stylesApplied.includes(name)) {
       return;
     }
-    this._stylesApplied.push(name);
+    this.stylesApplied.push(name);
 
     const s = document.createElement('style');
     s.setAttribute('include', name);
@@ -51,23 +52,19 @@
     updateStyles();
   }
 
-  _importAndApply() {
+  private importAndApply() {
     const moduleNames = getPluginEndpoints().getModules(this.name);
     for (const name of moduleNames) {
-      this._applyStyle(name);
+      this.applyStyle(name);
     }
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._importAndApply();
-  }
-
-  override ready() {
-    super.ready();
+    this.importAndApply();
     getPluginLoader()
       .awaitPluginsLoaded()
-      .then(() => this._importAndApply());
+      .then(() => this.importAndApply());
   }
 }
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
deleted file mode 100644
index a192f80..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
+++ /dev/null
@@ -1,98 +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 {resetPlugins} from '../../../test/test-utils.js';
-import './gr-external-style.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-const basicFixture = fixtureFromTemplate(
-    html`<gr-external-style name="foo"></gr-external-style>`
-);
-
-suite('gr-external-style integration tests', () => {
-  const TEST_URL = 'http://some.com/plugins/url.js';
-
-  let element;
-  let plugin;
-
-  const installPlugin = () => {
-    if (plugin) { return; }
-    pluginApi.install(p => {
-      plugin = p;
-    }, '0.1', TEST_URL);
-  };
-
-  const createElement = () => {
-    element = basicFixture.instantiate();
-    sinon.spy(element, '_applyStyle');
-  };
-
-  /**
-   * Installs the plugin, creates the element, registers style module.
-   */
-  const lateRegister = () => {
-    installPlugin();
-    createElement();
-    plugin.registerStyleModule('foo', 'some-module');
-  };
-
-  /**
-   * Installs the plugin, registers style module, creates the element.
-   */
-  const earlyRegister = () => {
-    installPlugin();
-    plugin.registerStyleModule('foo', 'some-module');
-    createElement();
-  };
-
-  setup(() => {
-    sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
-        .returns(Promise.resolve());
-  });
-
-  teardown(() => {
-    resetPlugins();
-    document.body.querySelectorAll('custom-style')
-        .forEach(style => style.remove());
-  });
-
-  test('applies plugin-provided styles', async () => {
-    lateRegister();
-    await new Promise(flush);
-    assert.isTrue(element._applyStyle.calledWith('some-module'));
-  });
-
-  test('does not double apply', async () => {
-    earlyRegister();
-    await new Promise(flush);
-    plugin.registerStyleModule('foo', 'some-module');
-    await new Promise(flush);
-    const stylesApplied =
-        element._stylesApplied.filter(name => name === 'some-module');
-    assert.strictEqual(stylesApplied.length, 1);
-  });
-
-  test('loads and applies preloaded modules', async () => {
-    earlyRegister();
-    await new Promise(flush);
-    assert.isTrue(element._applyStyle.calledWith('some-module'));
-  });
-});
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts
new file mode 100644
index 0000000..2b8444b
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts
@@ -0,0 +1,108 @@
+/**
+ * @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 {resetPlugins} from '../../../test/test-utils';
+import './gr-external-style.js';
+import {GrExternalStyle} from './gr-external-style.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {PluginApi} from '../../../api/plugin';
+
+const basicFixture = fixtureFromTemplate(
+  html`<gr-external-style name="foo"></gr-external-style>`
+);
+
+suite('gr-external-style integration tests', () => {
+  const TEST_URL = 'http://some.com/plugins/url.js';
+
+  let element: GrExternalStyle;
+  let plugin: PluginApi;
+  let applyStyleSpy: sinon.SinonSpy;
+
+  const installPlugin = () => {
+    if (plugin) {
+      return;
+    }
+    window.Gerrit.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      TEST_URL
+    );
+  };
+
+  const createElement = async () => {
+    element = basicFixture.instantiate() as GrExternalStyle;
+    applyStyleSpy = sinon.spy(element, 'applyStyle');
+    await element.updateComplete;
+  };
+
+  /**
+   * Installs the plugin, creates the element, registers style module.
+   */
+  const lateRegister = () => {
+    installPlugin();
+    createElement();
+    plugin.registerStyleModule('foo', 'some-module');
+  };
+
+  /**
+   * Installs the plugin, registers style module, creates the element.
+   */
+  const earlyRegister = () => {
+    installPlugin();
+    plugin.registerStyleModule('foo', 'some-module');
+    createElement();
+  };
+
+  setup(() => {
+    sinon
+      .stub(getPluginLoader(), 'awaitPluginsLoaded')
+      .returns(Promise.resolve());
+  });
+
+  teardown(() => {
+    resetPlugins();
+    document.body
+      .querySelectorAll('custom-style')
+      .forEach(style => style.remove());
+  });
+
+  test('applies plugin-provided styles', async () => {
+    lateRegister();
+    await element.updateComplete;
+    assert.isTrue(applyStyleSpy.calledWith('some-module'));
+  });
+
+  test('does not double apply', async () => {
+    earlyRegister();
+    await element.updateComplete;
+    plugin.registerStyleModule('foo', 'some-module');
+    await element.updateComplete;
+    const stylesApplied = element.stylesApplied.filter(
+      name => name === 'some-module'
+    );
+    assert.strictEqual(stylesApplied.length, 1);
+  });
+
+  test('loads and applies preloaded modules', async () => {
+    earlyRegister();
+    await element.updateComplete;
+    assert.isTrue(applyStyleSpy.calledWith('some-module'));
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
index 7fee4a0..9b62743 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
@@ -1,18 +1,7 @@
 /**
  * @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 {LitElement, PropertyValues} from 'lit';
 import {customElement, property} from 'lit/decorators';
@@ -25,16 +14,10 @@
   config?: ServerInfo;
 
   _configChanged(config: ServerInfo) {
-    const plugins = config.plugin;
-    const jsPlugins = (plugins && plugins.js_resource_paths) || [];
-    const shouldLoadTheme = !!config.default_theme;
-    // config.default_theme is defined when shouldLoadTheme is true
-    const themeToLoad: string[] = shouldLoadTheme
-      ? [config.default_theme!]
-      : [];
-    // Theme should be loaded first for better UX.
-    const pluginsPending = themeToLoad.concat(jsPlugins);
-    getPluginLoader().loadPlugins(pluginsPending);
+    const jsPlugins = config.plugin?.js_resource_paths ?? [];
+    const themes: string[] = config.default_theme ? [config.default_theme] : [];
+    const instanceId = config.gerrit?.instance_id;
+    getPluginLoader().loadPlugins([...themes, ...jsPlugins], instanceId);
   }
 
   override updated(changedProperties: PropertyValues<GrPluginHost>) {
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 45a93bf..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,25 +1,13 @@
 /**
  * @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';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 
 interface CustomPolymerPluginEl extends HTMLElement {
   plugin: PluginApi;
@@ -36,7 +24,7 @@
 
   private popup: GrPluginPopup | null = null;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   constructor(
     readonly plugin: PluginApi,
@@ -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 2889333..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
+++ /dev/null
@@ -1,97 +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 {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.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');
-
-const pluginApi = _testOnly_initGerritPluginApi();
-suite('gr-popup-interface tests', () => {
-  let container;
-  let instance;
-  let plugin;
-
-  setup(() => {
-    pluginApi.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/plugins/gr-theme-api/gr-custom-plugin-header.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
deleted file mode 100644
index 6580ad6..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
+++ /dev/null
@@ -1,54 +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.
- */
-/* eslint-disable lit/no-legacy-template-syntax,lit/prefer-static-styles */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {customElement, property} from '@polymer/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-custom-plugin-header': GrCustomPluginHeader;
-  }
-}
-
-@customElement('gr-custom-plugin-header')
-export class GrCustomPluginHeader extends PolymerElement {
-  @property({type: String})
-  logoUrl = '';
-
-  @property({type: String})
-  override title = '';
-
-  static get template() {
-    return html`
-      <style>
-        img {
-          width: 1em;
-          height: 1em;
-          vertical-align: middle;
-        }
-        .title {
-          margin-left: var(--spacing-xs);
-        }
-      </style>
-      <span>
-        <img src="[[logoUrl]]" hidden$="[[!logoUrl]]" />
-        <span class="title">[[title]]</span>
-      </span>
-    `;
-  }
-}
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 bd6835c..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 {appContext} from '../../../services/app-context';
-import {fireEvent} from '../../../utils/event-util';
+import {getAppContext} from '../../../services/app-context';
+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;
+  @state() private avatarChangeUrl = '';
 
-  @property({type: String})
-  _avatarChangeUrl = '';
+  private readonly restApiService = getAppContext().restApiService;
 
-  private readonly restApiService = appContext.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 51259c8..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">Status (e.g. "Vacation")</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 f1813a4..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');
@@ -60,17 +62,70 @@
     account = createAccountWithIdNameAndEmail(123) as AccountDetailInfo;
     config = createServerInfo();
 
-    stubRestApi('getAccount').returns(Promise.resolve(account));
-    stubRestApi('getConfig').returns(Promise.resolve(config));
-    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
+    stubRestApi('getAccount').resolves(account);
+    stubRestApi('getConfig').resolves(config);
+    stubRestApi('getPreferences').resolves(createPreferences());
 
     element = basicFixture.instantiate();
     await element.loadData();
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="gr-form-styles">
+        <section>
+          <span class="title"></span>
+          <span class="value">
+            <gr-avatar hidden="" imagesize="120"></gr-avatar>
+          </span>
+        </section>
+        <section>
+          <span class="title">ID</span>
+          <span class="value">123</span>
+        </section>
+        <section>
+          <span class="title">Email</span>
+          <span class="value">user-123@</span>
+        </section>
+        <section>
+          <span class="title">Registered</span>
+          <span class="value">
+            <gr-date-formatter withtooltip=""></gr-date-formatter>
+          </span>
+        </section>
+        <section id="usernameSection">
+          <span class="title">Username</span>
+          <span class="value"></span>
+        </section>
+        <section id="nameSection">
+          <label class="title" for="nameInput">Full name</label>
+          <span class="value">User-123</span>
+        </section>
+        <section>
+          <label class="title" for="displayNameInput">Display name</label>
+          <span class="value">
+            <iron-input>
+              <input id="displayNameInput" />
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <label class="title" for="statusInput">
+            About me (e.g. employer)
+          </label>
+          <span class="value">
+            <iron-input id="statusIronInput">
+              <input id="statusInput" />
+            </iron-input>
+          </span>
+        </section>
+      </div>
+    `);
   });
 
   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);
@@ -78,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
@@ -135,36 +197,37 @@
   });
 
   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,
+          ],
+        },
+      };
 
-      nameStub = stubRestApi('setAccountName').returns(Promise.resolve());
-      usernameStub = stubRestApi('setAccountUsername').returns(
-        Promise.resolve()
-      );
-      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
+      await element.updateComplete;
+      nameStub = stubRestApi('setAccountName').resolves();
+      usernameStub = stubRestApi('setAccountUsername').resolves();
+      statusStub = stubRestApi('setAccountStatus').resolves();
     });
 
     test('name', async () => {
       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();
@@ -176,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();
@@ -197,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();
@@ -213,34 +288,37 @@
   });
 
   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').returns(Promise.resolve());
-      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
-      stubRestApi('setAccountUsername').returns(Promise.resolve());
+      nameStub = stubRestApi('setAccountName').resolves();
+      statusStub = stubRestApi('setAccountStatus').resolves();
+      stubRestApi('setAccountUsername').resolves();
     });
 
     test('set name and status', async () => {
       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);
 
@@ -255,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').returns(Promise.resolve());
+      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];
 
@@ -276,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);
 
@@ -291,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 a972db3..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
@@ -17,7 +17,7 @@
 
 import {getBaseUrl} from '../../../utils/url-util';
 import {ContributorAgreementInfo} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement} from 'lit';
@@ -28,7 +28,7 @@
   @property({type: Array})
   _agreements?: ContributorAgreementInfo[];
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
@@ -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>`;
   }
 
@@ -94,8 +94,8 @@
     return `${getBaseUrl()}/settings/new-agreement`;
   }
 
-  getUrlBase(item: string) {
-    return `${getBaseUrl()}/${item}`;
+  getUrlBase(item?: string) {
+    return item ? `${getBaseUrl()}/${item}` : '';
   }
 }
 
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 96b1ded..34e376b 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,26 +15,24 @@
  * 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 {appContext} from '../../../services/app-context';
+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';
 
 @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})
@@ -43,65 +41,140 @@
   @property({type: Array})
   defaultColumns: string[] = [];
 
-  private readonly flagsService = appContext.flagsService;
+  private readonly flagsService = getAppContext().flagsService;
 
-  @observe('serverConfig')
-  _configChanged(config: ServerInfo) {
-    this.defaultColumns = columnNames.filter(col =>
-      this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
+  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 = columnNames.filter(column =>
+      this.isColumnEnabled(column)
     );
     if (!this.displayedColumns) return;
     this.displayedColumns = this.displayedColumns.filter(column =>
-      this._isColumnEnabled(
-        column,
-        config,
-        this.flagsService.enabledExperiments
-      )
+      this.isColumnEnabled(column)
     );
   }
 
   /**
-   * Is the column disabled by a server config or experiment? For example the
-   * assignee feature might be disabled and thus the corresponding column is
-   * also disabled.
-   *
+   * Is the column disabled by a server config or experiment?
+   * private but used in test
    */
-  _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
-    if (!config || !config.change) return true;
-    if (column === 'Assignee') return !!config.change.enable_assignee;
-    if (column === 'Comments') return experiments.includes('comments-column');
+  isColumnEnabled(column: string) {
+    if (!this.serverConfig?.change) return true;
+    if (column === 'Comments')
+      return this.flagsService.isEnabled('comments-column');
+    if (column === 'Status')
+      return !this.flagsService.isEnabled(
+        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+      );
+    if (column === ' Status ')
+      return this.flagsService.isEnabled(
+        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+      );
     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) {
@@ -114,22 +187,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 4f8d0a0..c37c3f9 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,28 +14,26 @@
  * 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';
-import {ServerInfo} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-change-table-editor');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 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',
       'Status',
       'Owner',
-      'Assignee',
       'Reviewers',
       'Comments',
       'Repo',
@@ -43,10 +41,84 @@
       '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="Status"> Status </label></td>
+            <td class="checkboxContainer">
+              <input checked="" id="Status" name="Status" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Owner"> Owner </label></td>
+            <td class="checkboxContainer">
+              <input checked="" id="Owner" name="Owner" type="checkbox" />
+            </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>
+        </tbody>
+      </table>
+    </div>`);
   });
 
   test('renders', () => {
@@ -62,17 +134,7 @@
     }
   });
 
-  test('disabled experiments are hidden', () => {
-    assert.isFalse(element.displayedColumns.includes('Assignee'));
-    element.set('displayedColumns', columns);
-    const config: ServerInfo = {...createServerInfo()};
-    config.change.enable_assignee = true;
-    element.serverConfig = config;
-    flush();
-    assert.isTrue(element.displayedColumns.includes('Assignee'));
-  });
-
-  test('hide item', () => {
+  test('hide item', async () => {
     const checkbox = queryAndAssert<HTMLInputElement>(
       element,
       'table tr:nth-child(2) input'
@@ -81,24 +143,17 @@
     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',
-      'Assignee',
-      'Repo',
-      'Branch',
-      'Updated',
-    ]);
+  test('show item', async () => {
+    element.displayedColumns = ['Status', 'Owner', 'Repo', 'Branch', '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'
@@ -109,74 +164,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 5b757e6..c8c06c9 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,21 +16,22 @@
  */
 
 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,
   ContributorAgreementInfo,
 } from '../../../types/common';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+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,33 +40,26 @@
 }
 
 @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;
+  @state() private agreementsUrl?: string;
 
-  @property({type: String})
-  _agreementsUrl?: string;
-
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
@@ -74,18 +68,136 @@
     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);
+        }
+        .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()}
+      </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,70 +207,59 @@
       this.restApiService
         .getAccountAgreements()
         .then((agreements: ContributorAgreementInfo[] | undefined) => {
-          this._signedAgreements = agreements || [];
+          this.signedAgreements = agreements || [];
         })
     );
 
     return Promise.all(promises);
   }
 
-  _getAgreementsUrl(configUrl: string) {
-    let url;
-    if (!configUrl) {
-      return '';
-    }
+  // private but used in test
+  getAgreementsUrl(configUrl: string) {
+    if (!configUrl) return '';
+
     if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
-      url = configUrl;
-    } else {
-      url = getBaseUrl() + '/' + configUrl;
+      return configUrl;
     }
 
-    return url;
+    return `${getBaseUrl()}/${configUrl}`;
   }
 
-  _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,34 +267,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'
-      : '';
+    );
   }
+
+  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 ce95ccb..0000000
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
+++ /dev/null
@@ -1,121 +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="[[_serverConfig.auth.contributor_agreements]]"
-    >
-      <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 7cfd1b3..0f7e065 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,308 @@
  * 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 {appContext} from '../../../services/app-context';
+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 = appContext.restApiService;
+  @query('#editSyntaxHighlighting')
+  private editSyntaxHighlighting?: HTMLInputElement;
 
-  loadData() {
-    return this.restApiService.getEditPreferences().then(prefs => {
-      this.editPrefs = prefs;
+  @query('#showAutoCloseBrackets')
+  private showAutoCloseBrackets?: HTMLInputElement;
+
+  @query('#showIndentWithTabs') private showIndentWithTabs?: HTMLInputElement;
+
+  @query('#showMatchBrackets') private showMatchBrackets?: HTMLInputElement;
+
+  @query('#editShowLineWrapping')
+  private editShowLineWrapping?: HTMLInputElement;
+
+  @query('#editShowTabs') private editShowTabs?: HTMLInputElement;
+
+  @query('#editShowTrailingWhitespaceInput')
+  private editShowTrailingWhitespaceInput?: HTMLInputElement;
+
+  @state() editPrefs?: EditPreferencesInfo;
+
+  @state() private originalEditPrefs?: EditPreferencesInfo;
+
+  private readonly userModel = getAppContext().userModel;
+
+  override connectedCallback() {
+    super.connectedCallback();
+    subscribe(this, this.userModel.editPreferences$, editPreferences => {
+      this.originalEditPrefs = editPreferences;
+      this.editPrefs = {...editPreferences};
     });
   }
 
-  _handleEditPrefsChanged() {
-    this.hasUnsavedChanges = true;
+  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);
+        }
+      `,
+    ];
   }
 
-  _handleEditTabWidthChanged() {
-    this.set('editPrefs.tab_size', Number(this.$.editTabWidth.value));
-    this._handleEditPrefsChanged();
+  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>
+    `;
   }
 
-  _handleEditLineLengthChanged() {
-    this.set('editPrefs.line_length', Number(this.$.editColumns.value));
-    this._handleEditPrefsChanged();
-  }
+  private readonly handleEditTabWidthChanged = () => {
+    this.editPrefs!.tab_size = Number(this.editTabWidth!.value);
+    this.requestUpdate();
+  };
 
-  _handleEditIndentUnitChanged() {
-    this.set('editPrefs.indent_unit', Number(this.$.editIndentUnit.value));
-    this._handleEditPrefsChanged();
-  }
+  private readonly handleEditLineLengthChanged = () => {
+    this.editPrefs!.line_length = Number(this.editColumns!.value);
+    this.requestUpdate();
+  };
 
-  _handleEditSyntaxHighlightingChanged() {
-    this.set(
-      'editPrefs.syntax_highlighting',
-      this.$.editSyntaxHighlighting.checked
+  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();
   }
 
-  _handleEditShowTabsChanged() {
-    this.set('editPrefs.show_tabs', this.$.editShowTabs.checked);
-    this._handleEditPrefsChanged();
-  }
-
-  _handleEditShowTrailingWhitespaceTap() {
-    this.set(
-      'editPrefs.show_whitespace_errors',
-      this.$.editShowTrailingWhitespaceInput.checked
-    );
-    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 7f25a86..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 {appContext} from '../../../services/app-context';
+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;
+
+  /* private but used in test */
+  @state() emails: EmailInfo[] = [];
+
+  /* private but used in test */
+  @state() emailsToRemove: EmailInfo[] = [];
+
+  /* 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>`;
   }
 
-  @property({type: Boolean, notify: true})
-  hasUnsavedChanges = false;
-
-  @property({type: Array})
-  _emails: EmailInfo[] = [];
-
-  @property({type: Array})
-  _emailsToRemove: EmailInfo[] = [];
-
-  @property({type: String})
-  _newPreferred: string | null = null;
-
-  readonly restApiService = appContext.restApiService;
+  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 afbd67e..97804946 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
@@ -15,28 +15,21 @@
  * limitations under the License.
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import '../../../styles/gr-form-styles';
 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 {appContext} from '../../../services/app-context';
-
-export interface GrGpgEditor {
-  $: {
-    viewKeyOverlay: GrOverlay;
-    addButton: GrButton;
-    newKey: IronAutogrowTextareaElement;
-  };
-}
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
+import {getAppContext} from '../../../services/app-context';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {assertIsDefined} from '../../../utils/common-util';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -44,35 +37,160 @@
   }
 }
 @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 = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
+  static override styles = [
+    formStyles,
+    sharedStyles,
+    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);
+      }
+      iron-autogrow-textarea {
+        background-color: var(--view-background-color);
+      }
+    `,
+  ];
+
+  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 +198,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.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
deleted file mode 100644
index ba736a2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
+++ /dev/null
@@ -1,184 +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-gpg-editor.js';
-import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-gpg-editor');
-
-suite('gr-gpg-editor tests', () => {
-  let element;
-  let keys;
-
-  setup(async () => {
-    const fingerprint1 = '0192 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
-    const fingerprint2 = '0196 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
-    keys = {
-      AFC8A49B: {
-        fingerprint: fingerprint1,
-        user_ids: [
-          'John Doe john.doe@example.com',
-        ],
-        key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-             '\nVersion: BCPG v1.52\n\t<key 1>',
-        status: 'TRUSTED',
-        problems: [],
-      },
-      AED9B59C: {
-        fingerprint: fingerprint2,
-        user_ids: [
-          'Gerrit gerrit@example.com',
-        ],
-        key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-             '\nVersion: BCPG v1.52\n\t<key 2>',
-        status: 'TRUSTED',
-        problems: [],
-      },
-    };
-
-    stubRestApi('getAccountGPGKeys').returns(Promise.resolve(keys));
-
-    element = basicFixture.instantiate();
-
-    await element.loadData();
-    await flush();
-  });
-
-  test('renders', () => {
-    const rows = element.root.querySelectorAll('tbody tr');
-
-    assert.equal(rows.length, 2);
-
-    let cells = rows[0].querySelectorAll('td');
-    assert.equal(cells[0].textContent, 'AFC8A49B');
-
-    cells = rows[1].querySelectorAll('td');
-    assert.equal(cells[0].textContent, 'AED9B59C');
-  });
-
-  test('remove key', async () => {
-    const lastKey = keys[Object.keys(keys)[1]];
-
-    const saveStub = stubRestApi('deleteAccountGPGKey')
-        .callsFake(() => Promise.resolve());
-
-    assert.equal(element._keysToRemove.length, 0);
-    assert.isFalse(element.hasUnsavedChanges);
-
-    // Get the delete button for the last row.
-    const button = element.root.querySelector(
-        'tbody tr:last-of-type td:nth-child(6) gr-button');
-
-    MockInteractions.tap(button);
-
-    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.isFalse(element.hasUnsavedChanges);
-  });
-
-  test('show key', () => {
-    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
-
-    // Get the show button for the last row.
-    const button = element.root.querySelector(
-        'tbody tr:last-of-type td:nth-child(4) gr-button');
-
-    MockInteractions.tap(button);
-
-    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>';
-    const newKeyObject = {
-      ADE8A59B: {
-        fingerprint: '0194 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B',
-        user_ids: [
-          'John john@example.com',
-        ],
-        key: newKeyString,
-        status: 'TRUSTED',
-        problems: [],
-      },
-    };
-
-    const addStub = stubRestApi(
-        'addAccountGPGKey').callsFake(
-        () => Promise.resolve(newKeyObject));
-
-    element._newKey = newKeyString;
-
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
-
-    const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
-      promise.resolve();
-    });
-
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
-
-    assert.isTrue(addStub.called);
-    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
-    await promise;
-  });
-
-  test('add invalid key', async () => {
-    const newKeyString = 'not even close to valid';
-
-    const addStub = stubRestApi(
-        'addAccountGPGKey').callsFake(
-        () => Promise.reject(new Error('error')));
-
-    element._newKey = newKeyString;
-
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
-
-    const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
-      promise.resolve();
-    });
-
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
-
-    assert.isTrue(addStub.called);
-    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
-    await promise;
-  });
-});
-
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
new file mode 100644
index 0000000..0f775b80
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
@@ -0,0 +1,331 @@
+/**
+ * @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-gpg-editor';
+import {
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrGpgEditor} from './gr-gpg-editor';
+import {
+  GpgKeyFingerprint,
+  GpgKeyInfo,
+  GpgKeyInfoStatus,
+  OpenPgpUserIds,
+} from '../../../api/rest-api';
+import {GrButton} from '../../shared/gr-button/gr-button';
+
+const basicFixture = fixtureFromElement('gr-gpg-editor');
+
+suite('gr-gpg-editor tests', () => {
+  let element: GrGpgEditor;
+  let keys: Record<string, GpgKeyInfo>;
+
+  setup(async () => {
+    const fingerprint1 =
+      '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;
+    keys = {
+      AFC8A49B: {
+        fingerprint: fingerprint1,
+        user_ids: ['John Doe john.doe@example.com'] as OpenPgpUserIds[],
+        key:
+          '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+          '\nVersion: BCPG v1.52\n\t<key 1>',
+        status: 'TRUSTED' as GpgKeyInfoStatus,
+        problems: [],
+      },
+      AED9B59C: {
+        fingerprint: fingerprint2,
+        user_ids: ['Gerrit gerrit@example.com'] as OpenPgpUserIds[],
+        key:
+          '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+          '\nVersion: BCPG v1.52\n\t<key 2>',
+        status: 'TRUSTED' as GpgKeyInfoStatus,
+        problems: [],
+      },
+    };
+
+    stubRestApi('getAccountGPGKeys').returns(Promise.resolve(keys));
+
+    element = basicFixture.instantiate();
+
+    await element.loadData();
+    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', () => {
+    const rows = queryAll(element, 'tbody tr');
+
+    assert.equal(rows.length, 2);
+
+    let cells = rows[0].querySelectorAll('td');
+    assert.equal(cells[0].textContent, 'AFC8A49B');
+
+    cells = rows[1].querySelectorAll('td');
+    assert.equal(cells[0].textContent, 'AED9B59C');
+  });
+
+  test('remove key', async () => {
+    const lastKey = keys[Object.keys(keys)[1]];
+
+    const saveStub = stubRestApi('deleteAccountGPGKey').callsFake(() =>
+      Promise.resolve(new Response())
+    );
+
+    assert.equal(element.keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+
+    // Get the delete button for the last row.
+    const button = queryAndAssert<GrButton>(
+      element,
+      'tbody tr:last-of-type td:nth-child(6) gr-button'
+    );
+
+    button.click();
+
+    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.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('show key', () => {
+    const openSpy = sinon.spy(element.viewKeyOverlay!, 'open');
+
+    // Get the show button for the last row.
+    const button = queryAndAssert<GrButton>(
+      element,
+      'tbody tr:last-of-type td:nth-child(4) gr-button'
+    );
+
+    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-----' + ' Version: BCPG v1.52 \t<key 3>';
+    const newKeyObject = {
+      ADE8A59B: {
+        fingerprint:
+          '0194 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B' as GpgKeyFingerprint,
+        user_ids: ['John john@example.com'] as OpenPgpUserIds[],
+        key: newKeyString,
+        status: 'TRUSTED' as GpgKeyInfoStatus,
+        problems: [],
+      },
+    };
+
+    const addStub = stubRestApi('addAccountGPGKey').callsFake(() =>
+      Promise.resolve(newKeyObject)
+    );
+
+    element.newKey = newKeyString;
+    await element.updateComplete;
+
+    assert.isFalse(element.addButton!.disabled);
+    assert.isFalse(element.newKeyTextarea!.disabled);
+
+    const promise = mockPromise();
+    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.newKeyTextarea!.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+    await promise;
+  });
+
+  test('add invalid key', async () => {
+    const newKeyString = 'not even close to valid';
+
+    const addStub = stubRestApi('addAccountGPGKey').callsFake(() =>
+      Promise.reject(new Error('error'))
+    );
+
+    element.newKey = newKeyString;
+    await element.updateComplete;
+
+    assert.isFalse(element.addButton!.disabled);
+    assert.isFalse(element.newKeyTextarea!.disabled);
+
+    const promise = mockPromise();
+    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.newKeyTextarea!.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+    await promise;
+  });
+});
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 8f1706d..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
@@ -17,7 +17,7 @@
 
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GroupInfo, GroupId} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
@@ -33,7 +33,7 @@
   @state()
   protected _groups: GroupInfo[] = [];
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   loadData() {
     return this.restApiService.getAccountGroups().then(groups => {
@@ -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 59f6a39..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
@@ -18,7 +18,7 @@
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-overlay/gr-overlay';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
@@ -44,7 +44,7 @@
   @property({type: String})
   _passwordUrl: string | null = null;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
@@ -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 d65304c..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,104 +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 {appContext} from '../../../services/app-context';
+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 = ['OPENID', 'OAUTH'];
-
-export interface GrIdentities {
-  $: {
-    overlay: GrOverlay;
-  };
-}
+const AUTH = [AuthType.OPENID, AuthType.OAUTH];
 
 @customElement('gr-identities')
-export class GrIdentities extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrIdentities extends LitElement {
+  @query('#overlay') overlay?: GrOverlay;
+
+  @state() private identities: AccountExternalIdInfo[] = [];
+
+  // temporary var for communicating with the confirmation dialog
+  // private but used in test
+  @state() idName?: string;
+
+  @property({type: Object}) serverConfig?: ServerInfo;
+
+  @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>`;
   }
 
-  @property({type: Array})
-  _identities: AccountExternalIdInfo[] = [];
+  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>`;
+  }
 
-  @property({type: String})
-  _idName?: string;
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('serverConfig')) {
+      this.showLinkAnotherIdentity = this.computeShowLinkAnotherIdentity();
+    }
+  }
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
-
-  @property({
-    type: Boolean,
-    computed: '_computeShowLinkAnotherIdentity(serverConfig)',
-  })
-  _showLinkAnotherIdentity?: boolean;
-
-  private readonly restApiService = appContext.restApiService;
+  // 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?.git_basic_auth_policy) {
-      return AUTH.includes(config.auth.git_basic_auth_policy.toUpperCase());
+  // 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 9d8dcc5..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
@@ -18,13 +18,13 @@
 import '../../../test/common-test-setup-karma';
 import './gr-identities';
 import {GrIdentities} from './gr-identities';
+import {AuthType} from '../../../constants/constants';
 import {stubRestApi} from '../../../test/test-utils';
 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;
@@ -50,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', () => {
@@ -77,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.git_basic_auth_policy = 'OAUTH';
-    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+    config.auth.auth_type = AuthType.OAUTH;
+    element.serverConfig = config;
+    assert.isTrue(element.computeShowLinkAnotherIdentity());
 
-    config.auth.git_basic_auth_policy = 'OpenID';
-    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+    config.auth.auth_type = AuthType.OPENID;
+    element.serverConfig = config;
+    assert.isTrue(element.computeShowLinkAnotherIdentity());
 
-    config.auth.git_basic_auth_policy = 'HTTP_LDAP';
-    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+    config.auth.auth_type = AuthType.HTTP_LDAP;
+    element.serverConfig = config;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
 
-    config.auth.git_basic_auth_policy = 'LDAP';
-    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+    config.auth.auth_type = AuthType.LDAP;
+    element.serverConfig = config;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
 
-    config.auth.git_basic_auth_policy = 'HTTP';
-    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+    config.auth.auth_type = AuthType.HTTP;
+    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.git_basic_auth_policy = 'OAUTH';
-
+    config.auth.auth_type = AuthType.OAUTH;
     element.serverConfig = config;
+    await element.updateComplete;
 
-    assert.isTrue(element._showLinkAnotherIdentity);
+    assert.isTrue(element.showLinkAnotherIdentity);
 
     config = {
       ...createServerInfo(),
     };
-    config.auth.git_basic_auth_policy = 'LDAP';
+    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..845b30c 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,235 @@
 /**
  * @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;
+
+  override connectedCallback() {
+    super.connectedCallback();
+    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.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.js
deleted file mode 100644
index 1ca4852..0000000
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.js
+++ /dev/null
@@ -1,164 +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-menu-editor.js';
-import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-const basicFixture = fixtureFromElement('gr-menu-editor');
-
-suite('gr-menu-editor tests', () => {
-  let element;
-  let menu;
-
-  function assertMenuNamesEqual(element, expected) {
-    const names = element.menuItems.map(i => i.name);
-    assert.equal(names.length, expected.length);
-    for (let i = 0; i < names.length; i++) {
-      assert.equal(names[i], expected[i]);
-    }
-  }
-
-  // Click the up/down button (according to direction) for the index'th row.
-  // The index of the first row is 0, corresponding to the array.
-  function move(element, index, direction) {
-    const selector = 'tr:nth-child(' + (index + 1) + ') .move' +
-        direction + 'Button';
-    const button =
-        element.shadowRoot
-            .querySelector('tbody').querySelector(selector)
-            .shadowRoot
-            .querySelector('paper-button');
-    MockInteractions.tap(button);
-  }
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    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);
-    flush$0();
-    await flush();
-  });
-
-  test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('tbody').querySelectorAll('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));
-  });
-
-  test('_computeAddDisabled', () => {
-    assert.isTrue(element._computeAddDisabled('', ''));
-    assert.isTrue(element._computeAddDisabled('name', ''));
-    assert.isTrue(element._computeAddDisabled('', 'url'));
-    assert.isFalse(element._computeAddDisabled('name', 'url'));
-  });
-
-  test('add a new menu item', () => {
-    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();
-
-    assert.equal(element.menuItems.length, originalMenuLength + 1);
-    assert.equal(element.menuItems[element.menuItems.length - 1].name,
-        newName);
-    assert.equal(element.menuItems[element.menuItems.length - 1].url, newUrl);
-  });
-
-  test('move items down', () => {
-    assertMenuNamesEqual(element,
-        ['first name', 'second name', 'third name']);
-
-    // Move the middle item down
-    move(element, 1, 'Down');
-    assertMenuNamesEqual(element,
-        ['first name', 'third name', 'second name']);
-
-    // Moving the bottom item down is a no-op.
-    move(element, 2, 'Down');
-    assertMenuNamesEqual(element,
-        ['first name', 'third name', 'second name']);
-  });
-
-  test('move items up', () => {
-    assertMenuNamesEqual(element,
-        ['first name', 'second name', 'third name']);
-
-    // Move the last item up twice to be the first.
-    move(element, 2, 'Up');
-    move(element, 1, 'Up');
-    assertMenuNamesEqual(element,
-        ['third name', 'first name', 'second name']);
-
-    // Moving the top item up is a no-op.
-    move(element, 0, 'Up');
-    assertMenuNamesEqual(element,
-        ['third name', 'first name', 'second name']);
-  });
-
-  test('remove item', () => {
-    assertMenuNamesEqual(element,
-        ['first name', 'second name', 'third name']);
-
-    // Tap the delete button for the middle item.
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('tbody')
-        .querySelector('tr:nth-child(2) .remove-button')
-        .shadowRoot
-        .querySelector('paper-button'));
-
-    assertMenuNamesEqual(element, ['first name', 'third name']);
-
-    // Delete remaining items.
-    for (let i = 0; i < 2; i++) {
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('tbody')
-          .querySelector('tr:first-child .remove-button')
-          .shadowRoot
-          .querySelector('paper-button'));
-    }
-    assertMenuNamesEqual(element, []);
-
-    // Add item to empty menu.
-    element._newName = 'new name';
-    element._newUrl = 'new url';
-    element._handleAddButton();
-    assertMenuNamesEqual(element, ['new name']);
-  });
-});
-
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
new file mode 100644
index 0000000..c6130df
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
@@ -0,0 +1,366 @@
+/**
+ * @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, queryAndAssert, waitUntil} from '../../../test/test-utils';
+import {PaperButtonElement} from '@polymer/paper-button';
+import {TopMenuItemInfo} from '../../../types/common';
+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;
+  let menu: TopMenuItemInfo[];
+
+  function assertMenuNamesEqual(
+    element: GrMenuEditor,
+    expected: Array<string>
+  ) {
+    const names = element.menuItems.map(i => i.name);
+    assert.equal(names.length, expected.length);
+    for (let i = 0; i < names.length; i++) {
+      assert.equal(names[i], expected[i]);
+    }
+  }
+
+  // Click the up/down button (according to direction) for the index'th row.
+  // The index of the first row is 0, corresponding to the array.
+  function move(element: GrMenuEditor, index: number, direction: string) {
+    const selector = `tr:nth-child(${index + 1}) .move${direction}Button`;
+
+    const button = query<PaperButtonElement>(
+      query<HTMLElement>(query<HTMLTableElement>(element, 'tbody'), selector),
+      'paper-button'
+    );
+    MockInteractions.tap(button!);
+  }
+
+  setup(async () => {
+    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.originalPrefs = {...createDefaultPreferences(), my: menu};
+    element.menuItems = [...menu];
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    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('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', async () => {
+    const newName = 'new name';
+    const newUrl = 'new url';
+    const originalMenuLength = element.menuItems.length;
+
+    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);
+    assert.equal(element.menuItems[element.menuItems.length - 1].url, newUrl);
+  });
+
+  test('move items down', () => {
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+
+    // Move the middle item down
+    move(element, 1, 'Down');
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+
+    // Moving the bottom item down is a no-op.
+    move(element, 2, 'Down');
+    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']);
+
+    // Move the last item up twice to be the first.
+    move(element, 2, 'Up');
+    move(element, 1, 'Up');
+    assertMenuNamesEqual(element, ['third name', 'first name', 'second name']);
+
+    // Moving the top item up is a no-op.
+    move(element, 0, 'Up');
+    assertMenuNamesEqual(element, ['third name', 'first name', 'second name']);
+  });
+
+  test('remove item', () => {
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+
+    // Tap the delete button for the middle item.
+    MockInteractions.tap(
+      query<PaperButtonElement>(
+        query<HTMLElement>(
+          query<HTMLTableElement>(element, 'tbody'),
+          'tr:nth-child(2) .remove-button'
+        ),
+        'paper-button'
+      )!
+    );
+
+    assertMenuNamesEqual(element, ['first name', 'third name']);
+
+    // Delete remaining items.
+    for (let i = 0; i < 2; i++) {
+      MockInteractions.tap(
+        query<PaperButtonElement>(
+          query<HTMLElement>(
+            query<HTMLTableElement>(element, 'tbody'),
+            'tr:first-child .remove-button'
+          ),
+          'paper-button'
+        )!
+      );
+    }
+    assertMenuNamesEqual(element, []);
+
+    // Add item to empty menu.
+    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 aa8f62b..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,21 +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 {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {fireEvent} from '../../../utils/event-util';
-
-export interface GrRegistrationDialog {
-  $: {
-    name: HTMLInputElement;
-    username: 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 {
@@ -40,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.
    *
@@ -56,119 +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;
 
-  private readonly restApiService = appContext.restApiService;
+  @state() usernameMutable = false;
 
-  override ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
+  @state() hasUsernameChange?: boolean;
+
+  @state() username?: string;
+
+  @state() nameMutable?: boolean;
+
+  @state() hasNameChange?: boolean;
+
+  @state() hasDisplayNameChange?: boolean;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  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._hasUsernameChange = 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 || '');
-  }
-
-  _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
     );
   }
 
-  _save() {
-    this._saving = true;
+  private computeNameMutable() {
+    return !!this.serverConfig?.auth.editable_account_fields.includes(
+      EditableAccountField.FULL_NAME
+    );
+  }
 
-    const promises = [this.restApiService.setAccountName(this.$.name.value)];
+  // 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.hasDisplayNameChange && this.account?.display_name) {
+      promises.push(
+        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(name?: string, username?: string, saving?: boolean) {
-    return saving || (!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 6f270f5..0000000
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
+++ /dev/null
@@ -1,119 +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 class="value">
-          <iron-input bind-value="{{_account.name}}">
-            <input id="name" 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.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 3c09d5e..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,15 +33,20 @@
 
   let _listeners: {[key: string]: EventListenerOrEventListenerObject};
 
-  setup(() => {
+  setup(async () => {
     _listeners = {};
 
     account = {
       name: 'name',
+      display_name: 'display name',
       registered_on: '2018-02-08 18:49:18.000000000' as Timestamp,
     };
 
-    stubRestApi('getAccount').returns(Promise.resolve(account));
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        ...account,
+      })
+    );
     stubRestApi('setAccountName').callsFake(name => {
       account.name = name;
       return Promise.resolve();
@@ -48,19 +55,29 @@
       account.username = username;
       return Promise.resolve();
     });
+    stubRestApi('setAccountDisplayName').callsFake(displayName => {
+      account.display_name = displayName;
+      return Promise.resolve();
+    });
     stubRestApi('getConfig').returns(
       Promise.resolve({
         ...createServerInfo(),
         auth: {
           auth_type: AuthType.HTTP,
-          editable_account_fields: [EditableAccountField.USER_NAME],
+          editable_account_fields: [
+            EditableAccountField.USER_NAME,
+            EditableAccountField.FULL_NAME,
+          ],
         },
       })
     );
 
-    element = basicFixture.instantiate();
+    element = await fixture<GrRegistrationDialog>(
+      html`<gr-registration-dialog></gr-registration-dialog>`
+    );
 
-    return element.loadData();
+    await element.loadData();
+    await element.updateComplete;
   });
 
   teardown(() => {
@@ -80,7 +97,7 @@
 
   function save() {
     const promise = listen('account-detail-update');
-    MockInteractions.tap(queryAndAssert(element, '#saveButton'));
+    queryAndAssert<GrButton>(element, '#saveButton').click();
     return promise;
   }
 
@@ -89,73 +106,153 @@
     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();
-    element.$.name.value = 'new name';
+    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.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');
     assert.isNotOk(account.username);
+    assert.equal(account.display_name, 'display name');
 
     // Save and verify new values are committed.
     await save();
     assert.equal(account.name, 'new name');
     assert.equal(account.username, 'new username');
+    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.isTrue(compute('test', 'test', true));
-    assert.isFalse(compute('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 127ac69..9c448d9 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,15 +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/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';
@@ -39,27 +33,19 @@
 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';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {GerritView} from '../../../services/router/router-model';
 import {
   DateFormat,
@@ -71,23 +57,22 @@
 } 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} from 'lit';
+import {
+  customElement,
+  property,
+  query,
+  queryAsync,
+  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';
@@ -102,44 +87,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.
    *
@@ -152,78 +101,109 @@
    * @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[] = [];
+  @queryAsync('#sshEditor') sshEditorPromise!: Promise<GrSshEditor>;
 
-  @property({type: Boolean})
-  _loading = true;
+  @queryAsync('#gpgEditor') gpgEditorPromise!: Promise<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 = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
@@ -231,16 +211,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.$.diffPrefs.loadData(),
+      this.accountInfo.loadData(),
+      this.watchedProjectsEditor.loadData(),
+      this.groupList.loadData(),
+      this.identities.loadData(),
     ];
 
     // TODO(dhruvsri): move this to the service
@@ -250,10 +230,9 @@
           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
             : prefs.change_table.map(column =>
@@ -264,24 +243,24 @@
 
     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) {
+          configPromises.push(
+            this.sshEditorPromise.then(sshEditor => 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) {
+          configPromises.push(
+            this.gpgEditorPromise.then(gpgEditor => gpgEditor.loadData())
+          );
         }
 
         configPromises.push(
           getDocsBaseUrl(config, this.restApiService).then(baseUrl => {
-            this._docsBaseUrl = baseUrl;
+            this.docsBaseUrl = baseUrl;
           })
         );
 
@@ -301,21 +280,721 @@
             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">
+            <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>
+            <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>
+            <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>
+            <section
+              ?hidden=${!this.convertToString(this.localPrefs.email_format)}
+            >
+              <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>
+            <section ?hidden=${!this.localPrefs.default_base_for_merges}>
+              <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>
+            <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>
+            <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>
+            <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>
+            <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>
+            <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>
+            <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>
+            <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>
+            <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>
+            <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="EditPreferences"></gr-edit-preferences>
+          <gr-menu-editor id="Menu"></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();
@@ -334,192 +1013,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 +1100,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 +1109,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 +1129,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 81e4fc4..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ /dev/null
@@ -1,611 +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 {
-      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..6a8f575 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -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="EditPreferences"> </gr-edit-preferences>
+          <gr-menu-editor id="Menu"> </gr-menu-editor>
+          <h2 id="ChangeTableColumns">Change Table Columns</h2>
+          <fieldset id="changeTableColumns">
+            <gr-change-table-editor> </gr-change-table-editor>
+            <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 c1f347f..ae20c4e 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
@@ -15,28 +15,20 @@
  * limitations under the License.
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import '../../../styles/gr-form-styles';
 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-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 {appContext} from '../../../services/app-context';
-
-export interface GrSshEditor {
-  $: {
-    addButton: GrButton;
-    newKey: IronAutogrowTextareaElement;
-    viewKeyOverlay: GrOverlay;
-  };
-}
+import {getAppContext} from '../../../services/app-context';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {fire} from '../../../utils/event-util';
+import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -44,85 +36,239 @@
   }
 }
 @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[] = [];
 
-  private readonly restApiService = appContext.restApiService;
+  @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 [
+      formStyles,
+      sharedStyles,
+      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;
+        }
+        iron-autogrow-textarea {
+          background-color: var(--view-background-color);
+        }
+      `,
+    ];
+  }
+
+  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"
+                placeholder="New SSH Key"
+                .bindValue=${this.newKey}
+                @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) {
-    const el = (dom(e) as EventApi).localTarget as GrButton;
+  private showKey(e: Event) {
+    const el = e.target 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) {
-    const el = (dom(e) as EventApi).localTarget as GrButton;
+  private handleDeleteKey(e: Event) {
+    const el = e.target 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.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
deleted file mode 100644
index cd2c1df..0000000
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
+++ /dev/null
@@ -1,170 +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-ssh-editor.js';
-import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-ssh-editor');
-
-suite('gr-ssh-editor tests', () => {
-  let element;
-  let keys;
-
-  setup(async () => {
-    keys = [{
-      seq: 1,
-      ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
-      encoded_key: '<key 1>',
-      algorithm: 'ssh-rsa',
-      comment: 'comment-one@machine-one',
-      valid: true,
-    }, {
-      seq: 2,
-      ssh_public_key: 'ssh-rsa <key 2> comment-two@machine-two',
-      encoded_key: '<key 2>',
-      algorithm: 'ssh-rsa',
-      comment: 'comment-two@machine-two',
-      valid: true,
-    }];
-
-    stubRestApi('getAccountSSHKeys').returns(Promise.resolve(keys));
-
-    element = basicFixture.instantiate();
-
-    await element.loadData();
-    await flush();
-  });
-
-  test('renders', () => {
-    const rows = element.root.querySelectorAll('tbody tr');
-
-    assert.equal(rows.length, 2);
-
-    let cells = rows[0].querySelectorAll('td');
-    assert.equal(cells[0].textContent, keys[0].comment);
-
-    cells = rows[1].querySelectorAll('td');
-    assert.equal(cells[0].textContent, keys[1].comment);
-  });
-
-  test('remove key', async () => {
-    const lastKey = keys[1];
-
-    const saveStub = stubRestApi('deleteAccountSSHKey')
-        .callsFake(() => Promise.resolve());
-
-    assert.equal(element._keysToRemove.length, 0);
-    assert.isFalse(element.hasUnsavedChanges);
-
-    // Get the delete button for the last row.
-    const button = element.root.querySelector(
-        'tbody tr:last-of-type td:nth-child(5) gr-button');
-
-    MockInteractions.tap(button);
-
-    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.isFalse(element.hasUnsavedChanges);
-  });
-
-  test('show key', () => {
-    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
-
-    // Get the show button for the last row.
-    const button = element.root.querySelector(
-        'tbody tr:last-of-type td:nth-child(3) gr-button');
-
-    MockInteractions.tap(button);
-
-    assert.equal(element._keyToView, keys[1]);
-    assert.isTrue(openSpy.called);
-  });
-
-  test('add key', async () => {
-    const newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
-    const newKeyObject = {
-      seq: 3,
-      ssh_public_key: newKeyString,
-      encoded_key: '<key 3>',
-      algorithm: 'ssh-rsa',
-      comment: 'comment-three@machine-three',
-      valid: true,
-    };
-
-    const addStub = stubRestApi(
-        'addAccountSSHKey').callsFake(
-        () => Promise.resolve(newKeyObject));
-
-    element._newKey = newKeyString;
-
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
-
-    const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 3);
-      promise.resolve();
-    });
-
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
-
-    assert.isTrue(addStub.called);
-    assert.equal(addStub.lastCall.args[0], newKeyString);
-    await promise;
-  });
-
-  test('add invalid key', async () => {
-    const newKeyString = 'not even close to valid';
-
-    const addStub = stubRestApi(
-        'addAccountSSHKey').callsFake(
-        () => Promise.reject(new Error('error')));
-
-    element._newKey = newKeyString;
-
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
-
-    const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
-      promise.resolve();
-    });
-
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
-
-    assert.isTrue(addStub.called);
-    assert.equal(addStub.lastCall.args[0], newKeyString);
-    await promise;
-  });
-});
-
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
new file mode 100644
index 0000000..0f81e9f
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
@@ -0,0 +1,186 @@
+/**
+ * @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 './gr-ssh-editor';
+import {
+  mockPromise,
+  query,
+  queryAll,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrSshEditor} from './gr-ssh-editor';
+import {SshKeyInfo} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-ssh-editor');
+
+suite('gr-ssh-editor tests', () => {
+  let element: GrSshEditor;
+  let keys: SshKeyInfo[];
+
+  setup(async () => {
+    keys = [
+      {
+        seq: 1,
+        ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
+        encoded_key: '<key 1>',
+        algorithm: 'ssh-rsa',
+        comment: 'comment-one@machine-one',
+        valid: true,
+      },
+      {
+        seq: 2,
+        ssh_public_key: 'ssh-rsa <key 2> comment-two@machine-two',
+        encoded_key: '<key 2>',
+        algorithm: 'ssh-rsa',
+        comment: 'comment-two@machine-two',
+        valid: true,
+      },
+    ];
+
+    stubRestApi('getAccountSSHKeys').returns(Promise.resolve(keys));
+
+    element = basicFixture.instantiate();
+
+    await element.loadData();
+    await flush();
+  });
+
+  test('renders', () => {
+    const rows = queryAll<HTMLTableElement>(element, 'tbody tr');
+
+    assert.equal(rows.length, 2);
+
+    let cells = queryAll<HTMLTableElement>(rows[0], 'td');
+    assert.equal(cells[0].textContent, keys[0].comment);
+
+    cells = queryAll<HTMLTableElement>(rows[1], 'td');
+    assert.equal(cells[0].textContent, keys[1].comment);
+  });
+
+  test('remove key', async () => {
+    const lastKey = keys[1];
+
+    const saveStub = stubRestApi('deleteAccountSSHKey').callsFake(() =>
+      Promise.resolve()
+    );
+
+    assert.equal(element.keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+
+    // Get the delete button for the last row.
+    const button = query<GrButton>(
+      element,
+      'tbody tr:last-of-type td:nth-child(5) gr-button'
+    );
+
+    MockInteractions.tap(button!);
+
+    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.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('show key', () => {
+    const openSpy = sinon.spy(element.viewKeyOverlay, 'open');
+
+    // Get the show button for the last row.
+    const button = query<GrButton>(
+      element,
+      'tbody tr:last-of-type td:nth-child(3) gr-button'
+    );
+
+    MockInteractions.tap(button!);
+
+    assert.equal(element.keyToView, keys[1]);
+    assert.isTrue(openSpy.called);
+  });
+
+  test('add key', async () => {
+    const newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
+    const newKeyObject = {
+      seq: 3,
+      ssh_public_key: newKeyString,
+      encoded_key: '<key 3>',
+      algorithm: 'ssh-rsa',
+      comment: 'comment-three@machine-three',
+      valid: true,
+    };
+
+    const addStub = stubRestApi('addAccountSSHKey').resolves(newKeyObject);
+
+    element.newKey = newKeyString;
+
+    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.newKeyEditor.disabled);
+      assert.equal(element.keys.length, 3);
+      promise.resolve();
+    });
+
+    assert.isTrue(element.addButton.disabled);
+    assert.isTrue(element.newKeyEditor.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.equal(addStub.lastCall.args[0], newKeyString);
+    await promise;
+  });
+
+  test('add invalid key', async () => {
+    const newKeyString = 'not even close to valid';
+
+    const addStub = stubRestApi('addAccountSSHKey').rejects(new Error('error'));
+
+    element.newKey = newKeyString;
+
+    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.newKeyEditor.disabled);
+      assert.equal(element.keys.length, 2);
+      promise.resolve();
+    });
+
+    assert.isTrue(element.addButton.disabled);
+    assert.isTrue(element.newKeyEditor.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.equal(addStub.lastCall.args[0], newKeyString);
+    await promise;
+  });
+});
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 4381a59..bd3cc22 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 {appContext} from '../../../services/app-context';
-import {IronInputElement} from '@polymer/iron-input';
+import {assertIsDefined} from '../../../utils/common-util';
+import {ProjectWatchInfo, RepoName} from '../../../types/common';
+import {getAppContext} from '../../../services/app-context';
+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 = appContext.restApiService;
+  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..b540be8 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
@@ -18,15 +18,19 @@
 import '../../../test/common-test-setup-karma';
 import './gr-watched-projects-editor';
 import {GrWatchedProjectsEditor} from './gr-watched-projects-editor';
-import {stubRestApi} from '../../../test/test-utils';
+import {stubRestApi, waitUntil} from '../../../test/test-utils';
 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';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 
 const basicFixture = fixtureFromElement('gr-watched-projects-editor');
 
 suite('gr-watched-projects-editor tests', () => {
   let element: GrWatchedProjectsEditor;
+  let suggestionStub: sinon.SinonStub;
 
   setup(async () => {
     const projects = [
@@ -53,7 +57,7 @@
     ] as ProjectWatchInfo[];
 
     stubRestApi('getWatchedProjects').returns(Promise.resolve(projects));
-    stubRestApi('getSuggestedProjects').callsFake(input => {
+    suggestionStub = stubRestApi('getSuggestedProjects').callsFake(input => {
       if (input.startsWith('th')) {
         return Promise.resolve({
           'the project': {
@@ -70,7 +74,7 @@
     element = basicFixture.instantiate();
 
     await element.loadData();
-    await flush();
+    await element.updateComplete;
   });
 
   test('renders', () => {
@@ -101,69 +105,88 @@
     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('autocompletes repo input', async () => {
+    const repoAutocomplete = queryAndAssert<GrAutocomplete>(
+      element,
+      'gr-autocomplete'
+    );
+    const repoInput = queryAndAssert<HTMLInputElement>(
+      repoAutocomplete,
+      '#input'
+    );
+
+    repoInput.focus();
+    repoAutocomplete.text = 'the';
+    await waitUntil(() => suggestionStub.called);
+    await repoAutocomplete.updateComplete;
+
+    assert.isTrue(suggestionStub.calledWith('the'));
+  });
+
   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 +194,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 +216,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 31c62b1..689a9fb 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,14 +14,21 @@
  * 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 {AccountInfo, ChangeInfo} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {
+  AccountInfo,
+  ApprovalInfo,
+  ChangeInfo,
+  LabelInfo,
+} from '../../../types/common';
+import {getAppContext} from '../../../services/app-context';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
-import {classMap} from 'lit/directives/class-map';
+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')
 export class GrAccountChip extends LitElement {
@@ -79,7 +86,15 @@
   @property({type: Boolean})
   transparentBackground = false;
 
-  private readonly restApiService = appContext.restApiService;
+  @property({type: Object})
+  vote?: ApprovalInfo;
+
+  @property({type: Object})
+  label?: LabelInfo;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly flagsService = getAppContext().flagsService;
 
   static override get styles() {
     return [
@@ -96,6 +111,15 @@
           border: 1px solid var(--border-color);
           display: inline-flex;
           padding: 0 1px;
+          /* Any outermost circular icon would fit neatly in the border-radius
+             and won't need padding, but the exact outermost elements will
+             depend on account state and the context gr-account-chip is used.
+             So, these values are passed down to gr-account-label and any
+             outermost elements will use the value and then override it. */
+          --account-label-padding-left: 6px;
+          --account-label-padding-right: 6px;
+          --account-label-circle-padding-left: 0;
+          --account-label-circle-padding-right: 0;
         }
         :host:focus {
           border-color: transparent;
@@ -118,9 +142,24 @@
           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 {
+          border: 1px solid var(--vote-outline-disliked);
+        }
+        .container.recommended {
+          border: 1px solid var(--vote-outline-recommended);
+        }
+        .container.disliked,
+        .container.recommended {
+          --account-label-padding-right: var(--spacing-xs);
+          --account-label-circle-padding-right: var(--spacing-xs);
+        }
+        .container.closeShown {
+          --account-label-padding-right: 3px;
+          --account-label-circle-padding-right: 3px;
+        }
       `,
     ];
   }
@@ -131,9 +170,6 @@
     /* eslint-disable lit/prefer-static-styles */
     const customStyle = html`
       <style>
-        .container {
-          --account-label-padding-horizontal: 6px;
-        }
         gr-button.remove::part(paper-button),
         gr-button.remove:hover::part(paper-button),
         gr-button.remove:focus::part(paper-button) {
@@ -147,36 +183,41 @@
           line-height: 10px;
           /* This cancels most of the --account-label-padding-horizontal. */
           margin-left: -4px;
-          padding: 0 2px 0 0;
+          padding: 0 2px 0 1px;
           text-decoration: none;
         }
       </style>
     `;
     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>
@@ -209,6 +250,25 @@
         Promise.resolve(!!(cfg && cfg.plugin && cfg.plugin.has_avatars))
       );
   }
+
+  private computeVoteClasses(): ClassInfo {
+    if (
+      !this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) ||
+      !this.label ||
+      !this.account ||
+      !hasVoted(this.label, this.account)
+    ) {
+      return {};
+    }
+    const status = getLabelStatus(this.label, this.vote?.value);
+    if ([LabelStatus.APPROVED, LabelStatus.RECOMMENDED].includes(status)) {
+      return {recommended: true};
+    } else if ([LabelStatus.REJECTED, LabelStatus.DISLIKED].includes(status)) {
+      return {disliked: true};
+    } else {
+      return {};
+    }
+  }
 }
 
 declare global {
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
new file mode 100644
index 0000000..0379c8a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_test.ts
@@ -0,0 +1,61 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import './gr-account-chip';
+import {GrAccountChip} from './gr-account-chip';
+import {
+  createAccountWithIdNameAndEmail,
+  createChange,
+} from '../../../test/test-data-generators';
+
+suite('gr-account-chip tests', () => {
+  let element: GrAccountChip;
+  setup(async () => {
+    const reviewer = createAccountWithIdNameAndEmail();
+    const change = createChange();
+    element = await fixture<GrAccountChip>(html`<gr-account-chip
+      .account=${reviewer}
+      .change=${change}
+    ></gr-account-chip>`);
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="container">
+        <div>
+          <gr-account-label clickable="" deselected=""></gr-account-label>
+        </div>
+        <slot name="vote-chip"></slot>
+        <gr-button
+          aria-disabled="false"
+          aria-label="Remove"
+          class="remove"
+          hidden=""
+          id="remove"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          <iron-icon icon="gr-icons:close"></iron-icon>
+        </gr-button>
+      </div>
+    `);
+  });
+});
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..6060826 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,24 @@
  * 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';
 
-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 +56,70 @@
   @property({type: String})
   placeholder = '';
 
-  @property({type: Object, notify: true})
+  @property({type: Object})
   querySuggestions: AutocompleteQuery = () => 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 +127,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 dabf761..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
@@ -17,20 +17,22 @@
 import '@polymer/iron-icon/iron-icon';
 import '../gr-avatar/gr-avatar';
 import '../gr-hovercard-account/gr-hovercard-account';
-import {appContext} from '../../../services/app-context';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import {getAppContext} from '../../../services/app-context';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {isSelf, isServiceUser} from '../../../utils/account-util';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 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 {
@@ -78,21 +80,15 @@
   @property({type: Boolean})
   hideAvatar = false;
 
-  @property({
-    type: Boolean,
-    reflect: true,
-  })
-  cancelLeftPadding = false;
-
-  @property({type: Boolean})
-  hideStatus = false;
-
   @state()
   _config?: ServerInfo;
 
   @property({type: Boolean, reflect: true})
   selectionChipStyle = false;
 
+  @property({type: Boolean, reflect: true})
+  noStatusIcons = false;
+
   @property({
     type: Boolean,
     reflect: true,
@@ -102,9 +98,18 @@
   @property({type: Boolean, reflect: true})
   deselected = false;
 
-  reporting: ReportingService;
+  @property({type: Boolean, reflect: true})
+  clickable = false;
 
-  private readonly restApiService = appContext.restApiService;
+  @property({type: Boolean, reflect: true})
+  attentionIconShown = false;
+
+  @property({type: Boolean, reflect: true})
+  avatarShown = false;
+
+  readonly reporting = getAppContext().reportingService;
+
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
@@ -116,14 +121,23 @@
           border-radius: var(--label-border-radius);
           box-sizing: border-box;
           white-space: nowrap;
-          padding: 0 var(--account-label-padding-horizontal, 0);
+          padding-left: var(--account-label-padding-left, 0);
         }
-        /* If the first element is the avatar, then we cancel the left padding,
-        so we can fit nicely into the gr-account-chip rounding. The obvious
-        alternative of 'chip has padding' and 'avatar gets negative margin'
-        does not work, because we need 'overflow:hidden' on the label. */
-        :host([cancelLeftPadding]) {
-          padding-left: 0;
+        :host([avatarShown]:not([attentionIconShown])) {
+          padding-left: var(--account-label-circle-padding-left, 0);
+        }
+        :host([attentionIconShown]) {
+          padding-left: var(--account-label-padding-left, 0);
+        }
+        .rightSidePadding {
+          padding-right: var(--account-label-padding-right, 0);
+          /* The existence of this element will also add 2(!) flexbox gaps */
+          margin-left: -6px;
+        }
+        .container {
+          display: flex;
+          align-items: center;
+          gap: 3px;
         }
         :host::after {
           content: var(--account-label-suffix);
@@ -146,9 +160,10 @@
         gr-avatar {
           height: calc(var(--line-height-normal) - 2px);
           width: calc(var(--line-height-normal) - 2px);
-          vertical-align: top;
-          position: relative;
-          top: 1px;
+        }
+        .accountStatusDecorator,
+        .hovercardTargetWrapper {
+          display: contents;
         }
         #attentionButton {
           /* This negates the 4px horizontal padding, which we appreciate as a
@@ -160,19 +175,9 @@
           color: var(--deemphasized-text-color);
           width: 12px;
           height: 12px;
-          vertical-align: top;
-        }
-        iron-icon.status {
-          color: var(--deemphasized-text-color);
-          width: 14px;
-          height: 14px;
-          vertical-align: top;
-          position: relative;
-          top: 2px;
         }
         .name {
           display: inline-block;
-          text-decoration: inherit;
           vertical-align: top;
           overflow: hidden;
           text-overflow: ellipsis;
@@ -181,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;
+        }
       `,
     ];
   }
@@ -188,14 +203,15 @@
   override render() {
     const {account, change, highlightAttention, forceAttention, _config} = this;
     if (!account) return;
-    const hasAttention =
+    this.attentionIconShown =
       forceAttention ||
-      this._hasUnforcedAttention(highlightAttention, account, change);
+      this.hasUnforcedAttention(highlightAttention, account, change);
     this.deselected = !this.selected;
     const hasAvatars = !!_config?.plugin?.has_avatars;
-    this.cancelLeftPadding = !this.hideAvatar && !hasAttention && hasAvatars;
+    this.avatarShown = !this.hideAvatar && hasAvatars;
 
-    return html`<span>
+    return html`
+      <div class="container">
         ${!this.hideHovercard
           ? html`<gr-hovercard-account
               for="hovercardTarget"
@@ -205,30 +221,30 @@
               .voteableText=${this.voteableText}
             ></gr-hovercard-account>`
           : ''}
-        ${hasAttention
+        ${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,
@@ -242,35 +258,34 @@
               </gr-button>
             </gr-tooltip-content>`
           : ''}
-      </span>
-      <span
-        id="hovercardTarget"
-        tabindex="0"
-        @keydown="${(e: KeyboardEvent) => this.handleKeyDown(e)}"
-        class="${classMap({
-          hasAttention: !!hasAttention,
-        })}"
-      >
-        ${!this.hideAvatar
-          ? html`<gr-avatar .account="${account}" imageSize="32"></gr-avatar>`
-          : ''}
-        <span class="text" part="gr-account-label-text">
-          <span class="name"
-            >${this._computeName(account, this.firstName, this._config)}</span
+        ${this.maybeRenderLink(html`
+          <span
+            class=${classMap({
+              hovercardTargetWrapper: true,
+              hasAttention: this.attentionIconShown,
+            })}
           >
-          ${!this.hideStatus && account.status
-            ? html`<iron-icon
-                class="status"
-                icon="gr-icons:calendar"
-              ></iron-icon>`
-            : ''}
-        </span>
-      </span>`;
+            ${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>
+        `)}
+      </div>
+    `;
   }
 
   constructor() {
     super();
-    this.reporting = appContext.reportingService;
     this.restApiService.getConfig().then(config => {
       this._config = config;
     });
@@ -283,16 +298,37 @@
     });
   }
 
-  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'));
+  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>`;
   }
 
-  _isAttentionSetEnabled(
+  private renderAccountStatusPlugins() {
+    if (!this.account?._account_id || this.noStatusIcons) {
+      return;
+    }
+    return html`
+      <gr-endpoint-decorator
+        class="accountStatusDecorator"
+        name="account-status-icon"
+      >
+        <gr-endpoint-param
+          name="accountId"
+          .value=${this.account._account_id}
+        ></gr-endpoint-param>
+        <span class="rightSidePadding"></span>
+      </gr-endpoint-decorator>
+    `;
+  }
+
+  private isAttentionSetEnabled(
     highlight: boolean,
     account: AccountInfo,
     change?: ChangeInfo
@@ -300,13 +336,13 @@
     return highlight && !!change && !!account && !isServiceUser(account);
   }
 
-  _hasUnforcedAttention(
+  private hasUnforcedAttention(
     highlight: boolean,
     account: AccountInfo,
     change?: ChangeInfo
-  ) {
-    return (
-      this._isAttentionSetEnabled(highlight, account, change) &&
+  ): boolean {
+    return !!(
+      this.isAttentionSetEnabled(highlight, account, change) &&
       change &&
       change.attention_set &&
       !!account._account_id &&
@@ -314,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();
@@ -350,7 +383,7 @@
 
     this.reporting.reportInteraction(
       'attention-icon-remove',
-      this._reportingDetails()
+      this.reportingDetails()
     );
     this.restApiService
       .removeFromAttentionSet(
@@ -363,7 +396,7 @@
       });
   }
 
-  _reportingDetails() {
+  private reportingDetails() {
     if (!this.account) return;
     const targetId = this.account._account_id;
     const ownerId =
@@ -385,7 +418,7 @@
     };
   }
 
-  _computeAttentionButtonEnabled(
+  private computeAttentionButtonEnabled(
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo | undefined,
@@ -394,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,
@@ -407,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 574e450..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
@@ -18,15 +18,18 @@
 import '../../../test/common-test-setup-karma';
 import './gr-account-label';
 import {
+  query,
   queryAndAssert,
   spyRestApi,
   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,
 } from '../../../test/test-data-generators';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
@@ -36,31 +39,98 @@
 suite('gr-account-label tests', () => {
   let element: GrAccountLabel;
   const kermit: AccountDetailInfo = {
-    ...createAccountDetailWithId(31),
+    ...createAccountDetailWithIdNameAndEmail(31),
     name: 'kermit',
   };
 
-  setup(() => {
-    stubRestApi('getAccount').callsFake(() => Promise.resolve(kermit));
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    element = basicFixture.instantiate();
-    element._config = {
+  setup(async () => {
+    sinon.stub(GerritNav, 'getUrlForOwner').callsFake(() => 'test');
+    stubRestApi('getAccount').resolves(kermit);
+    stubRestApi('getLoggedIn').resolves(false);
+    stubRestApi('getConfig').resolves({
       ...createServerInfo(),
+      plugin: {
+        ...createPluginConfig(),
+        has_avatars: true,
+      },
       user: {
         anonymous_coward_name: 'Anonymous Coward',
       },
-    };
+    });
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  test('renders', async () => {
+    element.account = kermit;
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="container">
+        <gr-hovercard-account for="hovercardTarget"></gr-hovercard-account>
+        <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>
+      </div>
+    `);
+  });
+
+  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', () => {
@@ -72,7 +142,7 @@
       };
       const account = {};
       assert.deepEqual(
-        element._computeName(account, false, config),
+        element.computeName(account, false, config),
         'Anonymous'
       );
     });
@@ -85,10 +155,7 @@
         },
       };
       const account = {};
-      assert.deepEqual(
-        element._computeName(account, false, config),
-        'TestAnon'
-      );
+      assert.deepEqual(element.computeName(account, false, config), 'TestAnon');
     });
   });
 
@@ -101,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,
@@ -132,5 +199,19 @@
       assert.isTrue(apiSpy.calledOnce);
       assert.equal(apiSpy.lastCall.args[1], 42);
     });
+
+    test('no status icons attribute', async () => {
+      queryAndAssert(
+        element,
+        'gr-endpoint-decorator[name="account-status-icon"]'
+      );
+
+      element.noStatusIcons = true;
+      await element.updateComplete;
+
+      assert.notExists(
+        query(element, 'gr-endpoint-decorator[name="account-status-icon"]')
+      );
+    });
   });
 });
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 f0c9106..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
+++ /dev/null
@@ -1,123 +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;
-
-  @property({type: Boolean})
-  hideStatus = 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}
-          ?hideStatus=${this.hideStatus}
-          ?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 c754e47..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.ts
+++ /dev/null
@@ -1,60 +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 './gr-account-link';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {GrAccountLink} from './gr-account-link';
-import {createAccountWithId} from '../../../test/test-data-generators';
-import {AccountId, AccountInfo, EmailAddress} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-account-link');
-
-suite('gr-account-link tests', () => {
-  let element: GrAccountLink;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  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 5449981..1143b4e 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,69 +16,69 @@
  */
 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 {appContext} from '../../../services/app-context';
-import {customElement, property} from '@polymer/decorators';
+import {getAppContext} from '../../../services/app-context';
 import {
   ChangeInfo,
   Suggestion,
   AccountInfo,
   GroupInfo,
   EmailAddress,
+  SuggestedReviewerGroupInfo,
+  SuggestedReviewerAccountInfo,
 } from '../../../types/common';
-import {
-  ReviewerSuggestionsProvider,
-  SuggestionItem,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+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
@@ -107,7 +107,7 @@
   return !!input._group || !!input.id;
 }
 
-type AccountInput = AccountInfoInput | GroupInfoInput;
+export type AccountInput = AccountInfoInput | GroupInfoInput;
 
 export interface AccountAddition {
   account?: AccountInfoInput;
@@ -115,18 +115,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})
@@ -135,7 +132,7 @@
   @property({type: Object})
   filter?: (input: Suggestion) => boolean;
 
-  @property({type: String})
+  @property()
   placeholder = '';
 
   @property({type: Boolean})
@@ -150,8 +147,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;
@@ -159,7 +156,7 @@
   /**
    * When true, allows for non-suggested inputs to be added.
    */
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'allow-any-input'})
   allowAnyInput = false;
 
   /**
@@ -175,31 +172,100 @@
   /**
    * Returns suggestion items
    */
-  @property({type: Object})
-  _querySuggestions: (input: string) => Promise<SuggestionItem[]>;
+  @state() private querySuggestions: AutocompleteQuery;
 
-  reporting: ReportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   private pendingRemoval: Set<AccountInput> = new Set();
 
   constructor() {
     super();
-    this.reporting = appContext.reportingService;
-    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)';
+      }
+      .pending-add {
+        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.maxCount && this.maxCount <= this.accounts.length) ||
+        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[]> {
     const provider = this.suggestionsProvider;
     if (!provider) return Promise.resolve([]);
     return provider.getSuggestions(input).then(suggestions => {
@@ -213,60 +279,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');
@@ -277,8 +353,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) {
@@ -295,21 +372,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;
       }
     }
@@ -318,23 +397,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;
@@ -346,7 +426,7 @@
     }
   }
 
-  _handleChipKeydown(e: KeyboardEvent) {
+  private handleChipKeydown(e: KeyboardEvent) {
     const chip = e.target as GrAccountChip;
     const chips = this.accountChips;
     const index = chips.indexOf(chip);
@@ -364,7 +444,7 @@
         } else if (index > 0) {
           chips[index - 1].focus();
         } else {
-          this.$.entry.focus();
+          this.entry?.focus();
         }
         break;
       case 37: // Left arrow
@@ -378,7 +458,7 @@
         if (index < chips.length - 1) {
           chips[index + 1].focus();
         } else {
-          this.$.entry.focus();
+          this.entry?.focus();
         }
         break;
     }
@@ -393,13 +473,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;
   }
@@ -430,19 +510,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..981ad32 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,16 @@
   AccountId,
   AccountInfo,
   EmailAddress,
+  GroupBaseInfo,
   GroupId,
-  GroupInfo,
-  SuggestedReviewerAccountInfo,
+  GroupName,
   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 {GrAutocomplete} from '../gr-autocomplete/gr-autocomplete';
+import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
 
 const basicFixture = fixtureFromElement('gr-account-list');
 
@@ -51,7 +53,7 @@
           _account_id: 1 as AccountId,
         } as AccountInfo,
         count: 1,
-      } as SuggestedReviewerAccountInfo,
+      } as unknown as string,
     };
   }
 }
@@ -64,11 +66,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 +86,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 +101,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 +140,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 +156,7 @@
         bubbles: true,
       })
     );
-    flush();
+    await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 2);
     assert.isFalse(chips[0].classList.contains('pendingAdd'));
@@ -153,15 +177,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 +199,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,
@@ -210,12 +234,12 @@
           value: {
             account: suggestion as AccountInfo,
             count: 1,
-          },
+          } as unknown as string,
         };
       });
 
     return element
-      ._getSuggestions('')
+      .getSuggestions('')
       .then(suggestions => {
         // Default is no filtering.
         assert.equal(suggestions.length, 3);
@@ -226,7 +250,7 @@
           return (suggestion as AccountInfo)._account_id === accountId;
         };
 
-        return element._getSuggestions('');
+        return element.getSuggestions('');
       })
       .then(suggestions => {
         assert.deepEqual(suggestions, [
@@ -235,52 +259,61 @@
             value: {
               account: originalSuggestions[0] as AccountInfo,
               count: 1,
-            },
+            } as unknown as string,
           },
         ]);
       });
   });
 
-  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 +334,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 +350,7 @@
           id: newGroup.id,
           _group: true,
           _pendingAdd: true,
+          name: 'abcd' as GroupName,
         },
       },
     ]);
@@ -346,6 +380,7 @@
           _group: true,
           _pendingAdd: true,
           confirmed: true,
+          name: 'abcd' as GroupName,
         },
       },
     ]);
@@ -359,12 +394,14 @@
     assert.equal(element.accounts.length, 1);
   });
 
-  test('max-count', () => {
+  test('max-count', async () => {
     element.maxCount = 1;
     const acct = makeAccount();
-    handleAdd({account: acct});
-    flush();
-    assert.isTrue(element.$.entry.hasAttribute('hidden'));
+    handleAdd({account: acct, count: 1});
+    await element.updateComplete;
+    assert.isTrue(
+      queryAndAssert<GrAccountEntry>(element, '#entry').hasAttribute('hidden')
+    );
   });
 
   test('enter text calls suggestions provider', async () => {
@@ -387,15 +424,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 +462,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 +513,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 e7137e4..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() {
@@ -103,19 +107,19 @@
   override connectedCallback() {
     super.connectedCallback();
     this.cleanups.push(
-      addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
+      addShortcut(this, {key: Key.UP}, () => this._handleUp())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
+      addShortcut(this, {key: Key.DOWN}, () => this._handleDown())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+      addShortcut(this, {key: Key.ENTER}, () => this._handleEnter())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, _ => this._handleEscape())
+      addShortcut(this, {key: Key.ESC}, () => this._handleEscape())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
+      addShortcut(this, {key: Key.TAB}, () => this._handleTab())
     );
   }
 
@@ -132,46 +136,30 @@
 
   open() {
     this.isHidden = false;
-    this._resetCursorStops();
-    // Refit should run after we call Polymer.flush inside _resetCursorStops
-    this.refit();
+    this.onSuggestionsChanged();
   }
 
   getCurrentText() {
     return this.getCursorTarget()?.dataset['value'] || '';
   }
 
-  _handleUp(e: Event) {
-    if (!this.isHidden) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.cursorUp();
-    }
+  _handleUp() {
+    if (!this.isHidden) this.cursorUp();
   }
 
-  _handleDown(e: Event) {
-    if (!this.isHidden) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.cursorDown();
-    }
+  _handleDown() {
+    if (!this.isHidden) this.cursorDown();
   }
 
   cursorDown() {
-    if (!this.isHidden) {
-      this.cursor.next();
-    }
+    if (!this.isHidden) this.cursor.next();
   }
 
   cursorUp() {
-    if (!this.isHidden) {
-      this.cursor.previous();
-    }
+    if (!this.isHidden) this.cursor.previous();
   }
 
-  _handleTab(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
+  _handleTab() {
     this.dispatchEvent(
       new CustomEvent<ItemSelectedEvent>('item-selected', {
         detail: {
@@ -184,9 +172,7 @@
     );
   }
 
-  _handleEnter(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
+  _handleEnter() {
     this.dispatchEvent(
       new CustomEvent<ItemSelectedEvent>('item-selected', {
         detail: {
@@ -235,7 +221,7 @@
   }
 
   @observe('suggestions')
-  _resetCursorStops() {
+  onSuggestionsChanged() {
     if (this.suggestions.length > 0) {
       if (!this.isHidden) {
         flush();
@@ -247,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..04fdd0f 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.maybeOpenDropdown();
+    }
+    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.maybeOpenDropdown();
   }
 
-  _computeClass(borderless?: boolean) {
-    return borderless ? 'borderless' : '';
+  maybeOpenDropdown() {
+    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 || '';
     }
@@ -491,20 +622,23 @@
     if (this.multi) {
       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 || '');
+          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-avatar/gr-avatar.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
index 33bf6c6..34c553d 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -17,7 +17,7 @@
 import {getBaseUrl} from '../../../utils/url-util';
 import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
 import {AccountInfo} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
 
@@ -32,7 +32,7 @@
   @property({type: Boolean})
   _hasAvatars = false;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
index b3c485a..3aeef3e 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
@@ -19,7 +19,7 @@
 import './gr-avatar';
 import {GrAvatar} from './gr-avatar';
 import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
-import {appContext} from '../../../services/app-context';
+import {getAppContext, AppContext} from '../../../services/app-context';
 import {AvatarInfo} from '../../../types/common';
 import {
   createAccountWithEmail,
@@ -116,7 +116,9 @@
   });
 
   suite('config set', () => {
+    let appContext: AppContext;
     setup(() => {
+      appContext = getAppContext();
       const config = {
         ...createServerInfo(),
         plugin: {has_avatars: true, js_resource_paths: []},
@@ -141,7 +143,7 @@
       getPluginLoader().loadPlugins([]);
 
       return Promise.all([
-        appContext.restApiService.getConfig(),
+        appContext!.restApiService.getConfig(),
         getPluginLoader().awaitPluginsLoaded(),
       ]).then(() => {
         assert.isFalse(element.hasAttribute('hidden'));
@@ -154,9 +156,9 @@
   });
 
   suite('plugin has avatars', () => {
-    let element: GrAvatar;
-
+    let appContext: AppContext;
     setup(() => {
+      appContext = getAppContext();
       const config = {
         ...createServerInfo(),
         plugin: {has_avatars: true, js_resource_paths: []},
@@ -185,10 +187,11 @@
 
   suite('config not set', () => {
     let element: GrAvatar;
+    let appContext: AppContext;
 
     setup(() => {
       stub('gr-avatar', '_getConfig').returns(Promise.resolve(undefined));
-
+      appContext = getAppContext();
       element = basicFixture.instantiate();
     });
 
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 ea5b5bb..1282666 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -19,19 +19,26 @@
 import {votingStyles} from '../../../styles/gr-voting-styles';
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {customElement, property} from 'lit/decorators';
-import {getEventPath, modifierPressed} from '../../../utils/dom-util';
-import {appContext} from '../../../services/app-context';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+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 {
     'gr-button': GrButton;
   }
 }
-
+/**
+ * @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
+ */
 @customElement('gr-button')
 export class GrButton extends LitElement {
-  private readonly reporting: ReportingService = appContext.reportingService;
+  // Private but used in tests.
+  readonly reporting = getAppContext().reportingService;
 
   /**
    * Should this button be rendered as a vote chip? Then we are applying
@@ -50,6 +57,10 @@
   @property({type: Boolean, reflect: true})
   link = false;
 
+  // If flattened then the button will not be shown as raised.
+  @property({type: Boolean, reflect: true})
+  flatten = false;
+
   @property({type: Boolean, reflect: true})
   loading = false;
 
@@ -180,18 +191,34 @@
         :host([down-arrow]) paper-button:hover .downArrow {
           border-top-color: var(--deemphasized-text-color);
         }
+        .newVoteChip {
+          border: 1px solid var(--border-color);
+          box-shadow: none;
+          box-sizing: border-box;
+          min-width: 3em;
+          color: var(--vote-text-color);
+        }
       `,
     ];
   }
 
+  private readonly flagsService = getAppContext().flagsService;
+
+  private readonly isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
+    KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+  );
+
   override render() {
     return html`<paper-button
-      ?raised="${!this.link}"
-      ?disabled="${this.disabled || this.loading}"
+      ?raised=${!this.link && !this.flatten}
+      ?disabled=${this.disabled || this.loading}
       role="button"
       tabindex="-1"
       part="paper-button"
-      class="${this.voteChip ? 'voteChip' : ''}"
+      class=${classMap({
+        voteChip: this.voteChip && !this.isSubmitRequirementsUiEnabled,
+        newVoteChip: this.voteChip && this.isSubmitRequirementsUiEnabled,
+      })}
     >
       ${this.loading ? html`<span class="loadingSpin"></span>` : ''}
       <slot></slot>
@@ -203,7 +230,8 @@
     super();
     this.initialTabindex = this.getAttribute('tabindex') || '0';
     this.addEventListener('click', e => this._handleAction(e));
-    this.addEventListener('keydown', e => this._handleKeydown(e));
+    addShortcut(this, {key: Key.ENTER}, () => this.click());
+    addShortcut(this, {key: Key.SPACE}, () => this.click());
   }
 
   override updated(changedProperties: PropertyValues) {
@@ -241,14 +269,4 @@
 
     this.reporting.reportInteraction('button-click', {path: getEventPath(e)});
   }
-
-  _handleKeydown(e: KeyboardEvent) {
-    if (modifierPressed(e)) return;
-    // Handle `enter`, `space`.
-    if (e.keyCode === 13 || e.keyCode === 32) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.click();
-    }
-  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
index 0149bd5..737625c 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
@@ -19,23 +19,11 @@
 import '../../../test/common-test-setup-karma';
 import './gr-button';
 import {addListener} from '@polymer/polymer/lib/utils/gestures';
-import {appContext} from '../../../services/app-context';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {fixture, html} from '@open-wc/testing-helpers';
 import {GrButton} from './gr-button';
-import {queryAndAssert} from '../../../test/test-utils';
+import {pressKey, queryAndAssert} from '../../../test/test-utils';
 import {PaperButtonElement} from '@polymer/paper-button';
-
-const basicFixture = fixtureFromElement('gr-button');
-
-const nestedFixture = fixtureFromTemplate(html`
-  <div id="test">
-    <gr-button class="testBtn"></gr-button>
-  </div>
-`);
-
-const tabindexFixture = fixtureFromTemplate(html`
-  <gr-button tabindex="3"></gr-button>
-`);
+import {Key, Modifier} from '../../../utils/dom-util';
 
 suite('gr-button tests', () => {
   let element: GrButton;
@@ -51,10 +39,25 @@
   };
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrButton>('<gr-button></gr-button>');
     await element.updateComplete;
   });
 
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <paper-button
+        animated=""
+        aria-disabled="false"
+        elevation="1"
+        part="paper-button"
+        raised=""
+        role="button"
+        tabindex="-1"
+        ><slot></slot><i class="downArrow"></i>
+      </paper-button>
+    `);
+  });
+
   test('disabled is set by disabled', async () => {
     const paperBtn = queryAndAssert<PaperButtonElement>(
       element,
@@ -111,7 +114,9 @@
   });
 
   test('tabindex should be preserved', async () => {
-    const tabIndexElement = tabindexFixture.instantiate() as GrButton;
+    const tabIndexElement = await fixture<GrButton>(html`
+      <gr-button tabindex="3"></gr-button>
+    `);
     tabIndexElement.disabled = false;
     await element.updateComplete;
     assert.equal(tabIndexElement.getAttribute('tabindex'), '3');
@@ -143,23 +148,22 @@
     assert.isTrue(spy.calledOnce);
   });
 
-  // Keycodes: 32 for Space, 13 for Enter.
-  for (const key of [32, 13]) {
-    test(`dispatches click event on keycode ${key}`, () => {
+  for (const key of [Key.ENTER, Key.SPACE]) {
+    test(`dispatches click event on key '${key}'`, () => {
       const tapSpy = sinon.spy();
       element.addEventListener('click', tapSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, key);
+      pressKey(element, key);
       assert.isTrue(tapSpy.calledOnce);
     });
 
-    test(`dispatches no click event with modifier on keycode ${key}`, () => {
+    test(`dispatches no click event with modifier on key '${key}'`, () => {
       const tapSpy = sinon.spy();
       element.addEventListener('click', tapSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, key, 'shift');
-      MockInteractions.pressAndReleaseKeyOn(element, key, 'ctrl');
-      MockInteractions.pressAndReleaseKeyOn(element, key, 'meta');
-      MockInteractions.pressAndReleaseKeyOn(element, key, 'alt');
-      assert.isFalse(tapSpy.calledOnce);
+      pressKey(element, key, Modifier.ALT_KEY);
+      pressKey(element, key, Modifier.CTRL_KEY);
+      pressKey(element, key, Modifier.META_KEY);
+      pressKey(element, key, Modifier.SHIFT_KEY);
+      assert.isFalse(tapSpy.called);
     });
   }
 
@@ -177,12 +181,11 @@
       });
     }
 
-    // Keycodes: 32 for Space, 13 for Enter.
-    for (const key of [32, 13]) {
+    for (const key of [Key.ENTER, Key.SPACE]) {
       test(`stops click event on keycode ${key}`, () => {
         const tapSpy = sinon.spy();
         element.addEventListener('click', tapSpy);
-        MockInteractions.pressAndReleaseKeyOn(element, key);
+        pressKey(element, key);
         assert.isFalse(tapSpy.called);
       });
     }
@@ -191,7 +194,7 @@
   suite('reporting', () => {
     let reportStub: sinon.SinonStub;
     setup(() => {
-      reportStub = sinon.stub(appContext.reportingService, 'reportInteraction');
+      reportStub = sinon.stub(element.reporting, 'reportInteraction');
       reportStub.reset();
     });
 
@@ -200,19 +203,21 @@
       assert.isTrue(reportStub.calledOnce);
       assert.equal(reportStub.lastCall.args[0], 'button-click');
       assert.deepEqual(reportStub.lastCall.args[1], {
-        path: `html>body>test-fixture#${element.parentElement!.id}>gr-button`,
+        path: 'html.lightTheme>body>div>gr-button',
       });
     });
 
-    test('report event after click on nested', () => {
-      const nestedElement = nestedFixture.instantiate() as HTMLDivElement;
+    test('report event after click on nested', async () => {
+      const nestedElement = await fixture<HTMLDivElement>(html`
+        <div id="test">
+          <gr-button class="testBtn"></gr-button>
+        </div>
+      `);
       MockInteractions.click(queryAndAssert(nestedElement, 'gr-button'));
       assert.isTrue(reportStub.calledOnce);
       assert.equal(reportStub.lastCall.args[0], 'button-click');
       assert.deepEqual(reportStub.lastCall.args[1], {
-        path:
-          `html>body>test-fixture#${nestedElement.parentElement!.id}` +
-          '>div#test>gr-button.testBtn',
+        path: 'html.lightTheme>body>div>div#test>gr-button.testBtn',
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index a23621e..560c82c 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -15,17 +15,16 @@
  * limitations under the License.
  */
 import '../gr-icons/gr-icons';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-star_html';
-import {customElement, property} from '@polymer/decorators';
 import {ChangeInfo} from '../../../types/common';
 import {fireAlert} from '../../../utils/event-util';
 import {
   Shortcut,
   ShortcutSection,
 } from '../../../services/shortcuts/shortcuts-config';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -39,44 +38,78 @@
 }
 
 @customElement('gr-change-star')
-export class GrChangeStar extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrChangeStar extends LitElement {
   /**
    * Fired when star state is toggled.
    *
    * @event toggle-star
    */
 
-  @property({type: Object, notify: true})
+  @property({type: Object})
   change?: ChangeInfo;
 
-  private readonly shortcuts = appContext.shortcutsService;
+  private readonly shortcuts = getAppContext().shortcutsService;
 
-  _computeStarClass(starred?: boolean) {
-    return starred ? 'active' : '';
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        button {
+          background-color: transparent;
+          cursor: pointer;
+        }
+        iron-icon.active {
+          fill: var(--link-color);
+        }
+        iron-icon {
+          vertical-align: top;
+          --iron-icon-height: var(
+            --gr-change-star-size,
+            var(--line-height-normal, 20px)
+          );
+          --iron-icon-width: var(
+            --gr-change-star-size,
+            var(--line-height-normal, 20px)
+          );
+        }
+        :host([hidden]) {
+          visibility: hidden;
+          display: block !important;
+        }
+      `,
+    ];
   }
 
-  _computeStarIcon(starred?: boolean) {
-    // Hollow star is used to indicate inactive state.
-    return `gr-icons:star${starred ? '' : '-border'}`;
-  }
-
-  _computeAriaLabel(starred?: boolean) {
-    return starred ? 'Unstar this change' : 'Star this change';
+  override render() {
+    return html`
+      <button
+        role="checkbox"
+        title=${this.shortcuts.createTitle(
+          Shortcut.TOGGLE_CHANGE_STAR,
+          ShortcutSection.ACTIONS
+        )}
+        aria-label=${this.change?.starred
+          ? 'Unstar this change'
+          : 'Star this change'}
+        @click=${this.toggleStar}
+      >
+        <iron-icon
+          class=${this.change?.starred ? 'active' : ''}
+          .icon=${`gr-icons:star${this.change?.starred ? '' : '-border'}`}
+        ></iron-icon>
+      </button>
+    `;
   }
 
   toggleStar() {
     // Note: change should always be defined when use gr-change-star
     // but since we don't have a good way to enforce usage to always
     // set the change, we still check it here.
-    if (!this.change) {
-      return;
-    }
+    if (!this.change) return;
+
     const newVal = !this.change.starred;
-    this.set('change.starred', newVal);
+    this.change.starred = newVal;
+    this.requestUpdate('change');
     const detail: ChangeStarToggleStarDetail = {
       change: this.change,
       starred: newVal,
@@ -90,8 +123,4 @@
       })
     );
   }
-
-  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
-    return this.shortcuts.createTitle(shortcutName, section);
-  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
deleted file mode 100644
index d404795..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
+++ /dev/null
@@ -1,56 +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">
-    button {
-      background-color: transparent;
-      cursor: pointer;
-    }
-    iron-icon.active {
-      fill: var(--link-color);
-    }
-    iron-icon {
-      vertical-align: top;
-      --iron-icon-height: var(
-        --gr-change-star-size,
-        var(--line-height-normal, 20px)
-      );
-      --iron-icon-width: var(
-        --gr-change-star-size,
-        var(--line-height-normal, 20px)
-      );
-    }
-    :host([hidden]) {
-      visibility: hidden;
-      display: block !important;
-    }
-  </style>
-  <button
-    role="checkbox"
-    title="[[createTitle(Shortcut.TOGGLE_CHANGE_STAR,
-      ShortcutSection.ACTIONS)]]"
-    aria-label="[[_computeAriaLabel(change.starred)]]"
-    on-click="toggleStar"
-  >
-    <iron-icon
-      class$="[[_computeStarClass(change.starred)]]"
-      icon$="[[_computeStarIcon(change.starred)]]"
-    ></iron-icon>
-  </button>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
index 8f411ae..2c5d7a2 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
@@ -27,45 +27,47 @@
 suite('gr-change-star tests', () => {
   let element: GrChangeStar;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
     element.change = {
       ...createChange(),
       starred: true,
     };
+    await element.updateComplete;
   });
 
   test('star visibility states', async () => {
-    element.set('change.starred', true);
-    await flush();
+    element.change!.starred = true;
+    await element.updateComplete;
     let icon = queryAndAssert<IronIconElement>(element, 'iron-icon');
     assert.isTrue(icon.classList.contains('active'));
     assert.equal(icon.icon, 'gr-icons:star');
 
-    element.set('change.starred', false);
-    await flush();
+    element.change!.starred = false;
+    element.requestUpdate('change');
+    await element.updateComplete;
     icon = queryAndAssert<IronIconElement>(element, 'iron-icon');
     assert.isFalse(icon.classList.contains('active'));
     assert.equal(icon.icon, 'gr-icons:star-border');
   });
 
   test('starring', async () => {
-    element.set('change.starred', false);
-    await flush();
+    element.change!.starred = false;
+    await element.updateComplete;
     assert.equal(element.change!.starred, false);
 
-    MockInteractions.tap(queryAndAssert(element, 'button'));
-    await flush();
+    MockInteractions.tap(queryAndAssert<HTMLButtonElement>(element, 'button'));
+    await element.updateComplete;
     assert.equal(element.change!.starred, true);
   });
 
   test('unstarring', async () => {
-    element.set('change.starred', true);
-    await flush();
+    element.change!.starred = true;
+    await element.updateComplete;
     assert.equal(element.change!.starred, true);
 
-    MockInteractions.tap(queryAndAssert(element, 'button'));
-    await flush();
+    MockInteractions.tap(queryAndAssert<HTMLButtonElement>(element, 'button'));
+    await element.updateComplete;
     assert.equal(element.change!.starred, false);
   });
 });
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 0bd02d5..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',
@@ -39,9 +39,9 @@
   WIP = 'WIP',
 }
 
-const WIP_TOOLTIP =
+export const WIP_TOOLTIP =
   "This change isn't ready to be reviewed or submitted. " +
-  "It will not appear on dashboards unless you are CC'ed or assigned, " +
+  "It will not appear on dashboards unless you are CC'ed, " +
   'and email notifications will be silenced until the review is started.';
 
 export const MERGE_CONFLICT_TOOLTIP =
@@ -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 39fc7c6..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
@@ -18,16 +18,11 @@
 import '../../../test/common-test-setup-karma';
 import {createChange} from '../../../test/test-data-generators';
 import './gr-change-status';
-import {ChangeStates, GrChangeStatus} from './gr-change-status';
+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');
-
-const WIP_TOOLTIP =
-  "This change isn't ready to be reviewed or submitted. " +
-  "It will not appear on dashboards unless you are CC'ed or assigned, " +
-  'and email notifications will be silenced until the review is started.';
+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 ' +
@@ -36,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);
@@ -64,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', () => {
@@ -109,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', () => {
@@ -120,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 3f8264b..471ebd6 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
@@ -17,342 +17,686 @@
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../gr-comment/gr-comment';
-import '../../diff/gr-diff/gr-diff';
+import '../../../embed/diff/gr-diff/gr-diff';
 import '../gr-copy-clipboard/gr-copy-clipboard';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment-thread_html';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, queryAll, state} from 'lit/decorators';
 import {
   computeDiffFromContext,
-  computeId,
-  DraftInfo,
   isDraft,
   isRobot,
-  sortComments,
-  UIComment,
-  UIDraft,
-  UIRobot,
+  Comment,
+  CommentThread,
+  getLastComment,
+  UnsavedInfo,
+  isDraftOrUnsaved,
+  createUnsavedComment,
+  getFirstComment,
+  createUnsavedReply,
+  isUnsaved,
 } from '../../../utils/comment-util';
+import {ChangeMessageId} from '../../../api/rest-api';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {
-  CommentSide,
   createDefaultDiffPrefs,
-  Side,
   SpecialFilePath,
 } from '../../../constants/constants';
 import {computeDisplayPath} from '../../../utils/path-list-util';
-import {computed, customElement, observe, property} from '@polymer/decorators';
 import {
   AccountDetailInfo,
   CommentRange,
-  ConfigInfo,
   NumericChangeId,
-  PatchSetNum,
   RepoName,
   UrlEncodedCommentId,
 } from '../../../types/common';
 import {GrComment} from '../gr-comment/gr-comment';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {FILE, LineNumber} from '../../diff/gr-diff/gr-diff-line';
+import {FILE} from '../../../embed/diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffLayer, RenderPreferences} from '../../../api/diff';
-import {
-  assertIsDefined,
-  check,
-  queryAndAssert,
-} from '../../../utils/common-util';
-import {fireAlert, waitForEventOnce} from '../../../utils/event-util';
-import {GrSyntaxLayer} from '../../diff/gr-syntax-layer/gr-syntax-layer';
-import {StorageLocation} from '../../../services/storage/gr-storage';
-import {TokenHighlightLayer} from '../../diff/gr-diff-builder/token-highlight-layer';
-import {anyLineTooLong} from '../../diff/gr-diff/gr-diff-utils';
+import {assertIsDefined} from '../../../utils/common-util';
+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';
 import {generateAbsoluteUrl} from '../../../utils/url-util';
-import {addGlobalShortcut} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {repeat} from 'lit/directives/repeat';
+import {classMap} from 'lit/directives/class-map';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {ValueChangedEvent} from '../../../types/events';
+import {notDeepEqual} from '../../../utils/deep-util';
+import {resolve} from '../../../models/dependency';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {whenRendered} from '../../../utils/dom-util';
 
-const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
 
-export interface GrCommentThread {
-  $: {
-    replyBtn: GrButton;
-    quoteBtn: GrButton;
-  };
+declare global {
+  interface HTMLElementEventMap {
+    'comment-thread-editing-changed': ValueChangedEvent<boolean>;
+  }
 }
 
+/**
+ * gr-comment-thread exposes the following attributes that allow a
+ * diff widget like gr-diff to show the thread in the right location:
+ *
+ * line-num:
+ *     1-based line number or 'FILE' if it refers to the entire file.
+ *
+ * diff-side:
+ *     "left" or "right". These indicate which of the two diffed versions
+ *     the comment relates to. In the case of unified diff, the left
+ *     version is the one whose line number column is further to the left.
+ *
+ * range:
+ *     The range of text that the comment refers to (start_line,
+ *     start_character, end_line, end_character), serialized as JSON. If
+ *     set, range's end_line will have the same value as line-num. Line
+ *     numbers are 1-based, char numbers are 0-based. The start position
+ *     (start_line, start_character) is inclusive, and the end position
+ *     (end_line, end_character) is exclusive.
+ */
 @customElement('gr-comment-thread')
-export class GrCommentThread extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrCommentThread extends LitElement {
+  @query('#replyBtn')
+  replyBtn?: GrButton;
+
+  @query('#quoteBtn')
+  quoteBtn?: GrButton;
+
+  @query('.comment-box')
+  commentBox?: HTMLElement;
+
+  @queryAll('gr-comment')
+  commentElements?: NodeList;
 
   /**
-   * gr-comment-thread exposes the following attributes that allow a
-   * diff widget like gr-diff to show the thread in the right location:
+   * Required to be set by parent.
    *
-   * line-num:
-   *     1-based line number or 'FILE' if it refers to the entire file.
-   *
-   * diff-side:
-   *     "left" or "right". These indicate which of the two diffed versions
-   *     the comment relates to. In the case of unified diff, the left
-   *     version is the one whose line number column is further to the left.
-   *
-   * range:
-   *     The range of text that the comment refers to (start_line,
-   *     start_character, end_line, end_character), serialized as JSON. If
-   *     set, range's end_line will have the same value as line-num. Line
-   *     numbers are 1-based, char numbers are 0-based. The start position
-   *     (start_line, start_character) is inclusive, and the end position
-   *     (end_line, end_character) is exclusive.
+   * Lit's `hasChanged` change detection defaults to just checking strict
+   * equality (===). Here it makes sense to install a proper `deepEqual`
+   * check, because of how the comments-model and ChangeComments are setup:
+   * Each thread object is recreated on the slightest model change. So when you
+   * have 100 comment threads and there is an update to one thread, then you
+   * want to avoid re-rendering the other 99 threads.
    */
-  @property({type: Number})
-  changeNum?: NumericChangeId;
+  @property({hasChanged: notDeepEqual})
+  thread?: CommentThread;
 
-  @property({type: Array})
-  comments: UIComment[] = [];
-
-  @property({type: Object, reflectToAttribute: true})
-  range?: CommentRange;
-
-  @property({type: String, reflectToAttribute: true})
-  diffSide?: Side;
-
+  /**
+   * Id of the first comment and thus must not change. Will be derived from
+   * the `thread` property in the first willUpdate() cycle.
+   *
+   * The `rootId` property is also used in gr-diff for maintaining lists and
+   * maps of threads and their associated elements.
+   *
+   * Only stays `undefined` for new threads that only have an unsaved comment.
+   */
   @property({type: String})
-  patchNum?: PatchSetNum;
-
-  @property({type: String})
-  path: string | undefined;
-
-  @property({type: String, observer: '_projectNameChanged'})
-  projectName?: RepoName;
-
-  @property({type: Boolean, notify: true, reflectToAttribute: true})
-  hasDraft?: boolean;
-
-  @property({type: Boolean})
-  isOnParent = false;
-
-  @property({type: Number})
-  parentIndex: number | null = null;
-
-  @property({
-    type: String,
-    notify: true,
-    computed: '_computeRootId(comments.*)',
-  })
   rootId?: UrlEncodedCommentId;
 
-  @property({type: Boolean, observer: 'handleShouldScrollIntoViewChanged'})
+  // TODO: Is this attribute needed for querySelector() or css rules?
+  // We don't need this internally for the component.
+  @property({type: Boolean, reflect: true, attribute: 'has-draft'})
+  hasDraft?: boolean;
+
+  /** Will be inspected on firstUpdated() only. */
+  @property({type: Boolean, attribute: 'should-scroll-into-view'})
   shouldScrollIntoView = false;
 
-  @property({type: Boolean})
+  /**
+   * Should the file path and line number be rendered above the comment thread
+   * widget? Typically true in <gr-thread-list> and false in <gr-diff>.
+   */
+  @property({type: Boolean, attribute: 'show-file-path'})
   showFilePath = false;
 
-  @property({type: Object, reflectToAttribute: true})
-  lineNum?: LineNumber;
+  /**
+   * Only relevant when `showFilePath` is set.
+   * If false, then only the line number is rendered.
+   */
+  @property({type: Boolean, attribute: 'show-file-name'})
+  showFileName = false;
 
-  @property({type: Boolean, notify: true, reflectToAttribute: true})
-  unresolved?: boolean;
+  @property({type: Boolean, attribute: 'show-ported-comment'})
+  showPortedComment = false;
 
-  @property({type: Boolean})
-  _showActions?: boolean;
+  /** This is set to false by <gr-diff>. */
+  @property({type: Boolean, attribute: false})
+  showPatchset = true;
 
-  @property({type: Object})
-  _lastComment?: UIComment;
+  @property({type: Boolean, attribute: 'show-comment-context'})
+  showCommentContext = false;
 
-  @property({type: Array})
-  _orderedComments: UIComment[] = [];
+  /**
+   * Optional context information when a thread is being displayed for a
+   * specific change message. That influences which comments are expanded or
+   * collapsed by default.
+   */
+  @property({type: String, attribute: 'message-id'})
+  messageId?: ChangeMessageId;
 
-  @property({type: Object})
-  _projectConfig?: ConfigInfo;
+  /**
+   * We are reflecting the editing state of the draft comment here. This is not
+   * an input property, but can be inspected from the parent component.
+   *
+   * Changes to this property are fired as 'comment-thread-editing-changed'
+   * events.
+   */
+  @property({type: Boolean, attribute: 'false'})
+  editing = false;
 
-  @property({type: Object})
-  _prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+  /**
+   * This can either be an unsaved reply to the last comment or the unsaved
+   * content of a brand new comment thread (then `comments` is empty).
+   * If set, then `thread.comments` must not contain a draft. A thread can only
+   * contain *either* an unsaved comment *or* a draft, not both.
+   */
+  @state()
+  unsavedComment?: UnsavedInfo;
 
-  @property({type: Object})
-  _renderPrefs: RenderPreferences = {
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+
+  @state()
+  renderPrefs: RenderPreferences = {
     hide_left_side: true,
     disable_context_control_buttons: true,
     show_file_comment_button: false,
     hide_line_length_indicator: true,
   };
 
-  @property({type: Boolean, reflectToAttribute: true})
-  isRobotComment = false;
+  @state()
+  repoName?: RepoName;
 
-  @property({type: Boolean})
-  showFileName = true;
+  @state()
+  account?: AccountDetailInfo;
 
-  @property({type: Boolean})
-  showPortedComment = false;
-
-  @property({type: Boolean})
-  showPatchset = true;
-
-  @property({type: Boolean})
-  showCommentContext = false;
-
-  @property({type: Object})
-  _selfAccount?: AccountDetailInfo;
-
-  @property({type: Array})
+  @state()
   layers: DiffLayer[] = [];
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  /** Computed during willUpdate(). */
+  @state()
+  diff?: DiffInfo;
 
-  private readonly reporting = appContext.reportingService;
+  /** Computed during willUpdate(). */
+  @state()
+  highlightRange?: CommentRange;
 
-  private readonly commentsService = appContext.commentsService;
+  /**
+   * Reflects the *dirty* state of whether the thread is currently unresolved.
+   * We are listening on the <gr-comment> of the draft, so we even know when the
+   * checkbox is checked, even if not yet saved.
+   */
+  @state()
+  unresolved = true;
 
-  readonly storage = appContext.storageService;
+  /**
+   * Normally drafts are saved within the <gr-comment> child component and we
+   * don't care about that. But when creating 'Done.' replies we are actually
+   * saving from this component. True while the REST API call is inflight.
+   */
+  @state()
+  saving = false;
 
-  private readonly syntaxLayer = new GrSyntaxLayer();
+  // Private but used in tests.
+  readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  readonly restApiService = appContext.restApiService;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly shortcuts = appContext.shortcutsService;
+  private readonly userModel = getAppContext().userModel;
+
+  private readonly shortcuts = new ShortcutController(this);
+
+  private readonly syntaxLayer = new GrSyntaxLayerWorker();
 
   constructor() {
     super();
-    this.addEventListener('comment-update', e =>
-      this._handleCommentUpdate(e as CustomEvent)
-    );
-    appContext.restApiService.getPreferences().then(prefs => {
-      this._initLayers(!!prefs?.disable_token_highlighting);
-    });
+    this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
+    this.shortcuts.addGlobal({key: 'E'}, () => this.handleCollapseShortcut());
   }
 
-  override disconnectedCallback() {
-    super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
-  }
-
-  override connectedCallback() {
+  override connectedCallback(): void {
     super.connectedCallback();
-    this.cleanups.push(
-      addGlobalShortcut({key: 'e'}, e => this.handleExpandShortcut(e))
+    subscribe(
+      this,
+      this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
     );
-    this.cleanups.push(
-      addGlobalShortcut({key: 'E'}, e => this.handleCollapseShortcut(e))
+    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)
     );
-    this._getLoggedIn().then(loggedIn => {
-      this._showActions = loggedIn;
+    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.restApiService.getDiffPreferences().then(prefs => {
-      if (!prefs) return;
-      this._prefs = {
+    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,
       };
-      this.syntaxLayer.setEnabled(!!prefs.syntax_highlighting);
     });
-    this.restApiService.getAccount().then(account => {
-      this._selfAccount = account;
-    });
-    this._setInitialExpandedState();
   }
 
-  @computed('comments', 'path')
-  get _diff() {
-    if (this.comments === undefined || this.path === undefined) return;
-    if (!this.comments[0]?.context_lines?.length) return;
-    const diff = computeDiffFromContext(
-      this.comments[0].context_lines,
-      this.path,
-      this.comments[0].source_content_type
+  static override get styles() {
+    return [
+      a11yStyles,
+      sharedStyles,
+      css`
+        :host {
+          font-family: var(--font-family);
+          font-size: var(--font-size-normal);
+          font-weight: var(--font-weight-normal);
+          line-height: var(--line-height-normal);
+          /* Explicitly set the background color of the diff. We
+           * cannot use the diff content type ab because of the skip chunk preceding
+           * it, diff processor assumes the chunk of type skip/ab can be collapsed
+           * and hides our diff behind context control buttons.
+           *  */
+          --dark-add-highlight-color: var(--background-color-primary);
+        }
+        gr-button {
+          margin-left: var(--spacing-m);
+        }
+        gr-comment {
+          border-bottom: 1px solid var(--comment-separator-color);
+        }
+        #actions {
+          margin-left: auto;
+          padding: var(--spacing-s) var(--spacing-m);
+        }
+        .comment-box {
+          width: 80ch;
+          max-width: 100%;
+          background-color: var(--comment-background-color);
+          color: var(--comment-text-color);
+          box-shadow: var(--elevation-level-2);
+          border-radius: var(--border-radius);
+          flex-shrink: 0;
+        }
+        #container {
+          display: var(--gr-comment-thread-display, flex);
+          align-items: flex-start;
+          margin: 0 var(--spacing-s) var(--spacing-s);
+          white-space: normal;
+          /** This is required for firefox to continue the inheritance */
+          -webkit-user-select: inherit;
+          -moz-user-select: inherit;
+          -ms-user-select: inherit;
+          user-select: inherit;
+        }
+        .comment-box.unresolved {
+          background-color: var(--unresolved-comment-background-color);
+        }
+        .comment-box.robotComment {
+          background-color: var(--robot-comment-background-color);
+        }
+        #actionsContainer {
+          display: flex;
+        }
+        .comment-box.saving #actionsContainer {
+          opacity: 0.5;
+        }
+        #unresolvedLabel {
+          font-family: var(--font-family);
+          margin: auto 0;
+          padding: var(--spacing-m);
+        }
+        .pathInfo {
+          display: flex;
+          align-items: baseline;
+          justify-content: space-between;
+          padding: 0 var(--spacing-s) var(--spacing-s);
+        }
+        .fileName {
+          padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
+        }
+        @media only screen and (max-width: 1200px) {
+          .diff-container {
+            display: none;
+          }
+        }
+        .diff-container {
+          margin-left: var(--spacing-l);
+          border: 1px solid var(--border-color);
+          flex-grow: 1;
+          flex-shrink: 1;
+          max-width: 1200px;
+        }
+        .view-diff-button {
+          margin: var(--spacing-s) var(--spacing-m);
+        }
+        .view-diff-container {
+          border-top: 1px solid var(--border-color);
+          background-color: var(--background-color-primary);
+        }
+
+        /* In saved state the "reply" and "quote" buttons are 28px height.
+         * top:4px  positions the 20px icon vertically centered.
+         * Currently in draft state the "save" and "cancel" buttons are 20px
+         * height, so the link icon does not need a top:4px in gr-comment_html.
+         */
+        .link-icon {
+          position: relative;
+          top: 4px;
+          cursor: pointer;
+        }
+        .fileName gr-copy-clipboard {
+          display: inline-block;
+          visibility: hidden;
+          vertical-align: top;
+          --gr-button-padding: 0px;
+        }
+        .fileName:focus-within gr-copy-clipboard,
+        .fileName:hover gr-copy-clipboard {
+          visibility: visible;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.thread) return;
+    const dynamicBoxClasses = {
+      robotComment: this.isRobotComment(),
+      unresolved: this.unresolved,
+      saving: this.saving,
+    };
+    return html`
+      ${this.renderFilePath()}
+      <div id="container">
+        <h3 class="assistive-tech-only">${this.computeAriaHeading()}</h3>
+        <div class="comment-box ${classMap(dynamicBoxClasses)}" tabindex="0">
+          ${this.renderComments()} ${this.renderActions()}
+        </div>
+        ${this.renderContextualDiff()}
+      </div>
+    `;
+  }
+
+  renderFilePath() {
+    if (!this.showFilePath) return;
+    const href = this.getUrlForFileComment();
+    const line = this.computeDisplayLine();
+    return html`
+      ${this.renderFileName()}
+      <div class="pathInfo">
+        ${href ? html`<a href=${href}>${line}</a>` : html`<span>${line}</span>`}
+      </div>
+    `;
+  }
+
+  renderFileName() {
+    if (!this.showFileName) return;
+    if (this.isPatchsetLevel()) {
+      return html`<div class="fileName"><span>Patchset</span></div>`;
+    }
+    const href = this.getDiffUrlForPath();
+    const displayPath = this.getDisplayPath();
+    return html`
+      <div class="fileName">
+        ${href
+          ? html`<a href=${href}>${displayPath}</a>`
+          : html`<span>${displayPath}</span>`}
+        <gr-copy-clipboard hideInput .text=${displayPath}></gr-copy-clipboard>
+      </div>
+    `;
+  }
+
+  renderComments() {
+    assertIsDefined(this.thread, 'thread');
+    const robotButtonDisabled = !this.account || this.isDraftOrUnsaved();
+    const comments: Comment[] = [...this.thread.comments];
+    if (this.unsavedComment && !this.isDraft()) {
+      comments.push(this.unsavedComment);
+    }
+    return repeat(
+      comments,
+      // We want to reuse <gr-comment> when unsaved changes to draft.
+      comment => (isDraftOrUnsaved(comment) ? 'unsaved' : comment.id),
+      comment => {
+        const initiallyCollapsed =
+          !isDraftOrUnsaved(comment) &&
+          (this.messageId
+            ? comment.change_message_id !== this.messageId
+            : !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) => {
+              if (isDraftOrUnsaved(comment)) this.editing = e.detail;
+            }}
+            @comment-unresolved-changed=${(e: CustomEvent) => {
+              if (isDraftOrUnsaved(comment)) this.unresolved = e.detail;
+            }}
+          ></gr-comment>
+        `;
+      }
     );
-    if (!anyLineTooLong(diff)) {
-      this.syntaxLayer.init(diff);
-      waitForEventOnce(this, 'render').then(() => {
-        this.syntaxLayer.process();
+  }
+
+  renderActions() {
+    if (!this.account || this.isDraftOrUnsaved() || this.isRobotComment())
+      return;
+    return html`
+      <div id="actionsContainer">
+        <span id="unresolvedLabel">${
+          this.unresolved ? 'Unresolved' : 'Resolved'
+        }</span>
+        <div id="actions">
+          <iron-icon
+              class="link-icon copy"
+              @click=${this.handleCopyLink}
+              title="Copy link to this comment"
+              icon="gr-icons:link"
+              role="button"
+              tabindex="0"
+          >
+          </iron-icon>
+          <gr-button
+              id="replyBtn"
+              link
+              class="action reply"
+              ?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)}
+          >Quote</gr-button
+          >
+          ${
+            this.unresolved
+              ? html`
+                  <gr-button
+                    id="ackBtn"
+                    link
+                    class="action ack"
+                    ?disabled=${this.saving}
+                    @click=${this.handleCommentAck}
+                    >Ack</gr-button
+                  >
+                  <gr-button
+                    id="doneBtn"
+                    link
+                    class="action done"
+                    ?disabled=${this.saving}
+                    @click=${this.handleCommentDone}
+                    >Done</gr-button
+                  >
+                `
+              : ''
+          }
+        </div>
+      </div>
+      </div>
+    `;
+  }
+
+  renderContextualDiff() {
+    if (!this.changeNum || !this.showCommentContext || !this.diff) return;
+    if (!this.thread?.path) return;
+    const href = this.getUrlForFileComment();
+    return html`
+      <div class="diff-container">
+        <gr-diff
+          id="diff"
+          .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}>
+            <gr-button link class="view-diff-button">View Diff</gr-button>
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  private firstWillUpdateDone = false;
+
+  firstWillUpdate() {
+    if (!this.thread) return;
+    if (this.firstWillUpdateDone) return;
+    this.firstWillUpdateDone = true;
+
+    if (this.getFirstComment() === undefined) {
+      this.unsavedComment = createUnsavedComment(this.thread);
+    }
+    this.unresolved = this.getLastComment()?.unresolved ?? true;
+    this.diff = this.computeDiff();
+    this.highlightRange = this.computeHighlightRange();
+  }
+
+  override willUpdate(changed: PropertyValues) {
+    this.firstWillUpdate();
+    if (changed.has('thread')) {
+      if (!this.isDraftOrUnsaved()) {
+        // We can only do this for threads without draft, because otherwise we
+        // are relying on the <gr-comment> component for the draft to fire
+        // events about the *dirty* `unresolved` state.
+        this.unresolved = this.getLastComment()?.unresolved ?? true;
+      }
+      this.hasDraft = this.isDraftOrUnsaved();
+      this.rootId = this.getFirstComment()?.id;
+      if (this.isDraft()) {
+        this.unsavedComment = undefined;
+      }
+    }
+    if (changed.has('editing')) {
+      // changed.get('editing') contains the old value. We only want to trigger
+      // when changing from editing to non-editing (user has cancelled/saved).
+      // We do *not* want to trigger on first render (old value is `null`)
+      if (!this.editing && changed.get('editing') === true) {
+        this.unsavedComment = undefined;
+        if (this.thread?.comments.length === 0) {
+          this.remove();
+        }
+      }
+      fire(this, 'comment-thread-editing-changed', {value: this.editing});
+    }
+  }
+
+  override firstUpdated() {
+    if (this.shouldScrollIntoView) {
+      whenRendered(this, () => {
+        this.expandCollapseComments(false);
+        this.commentBox?.focus();
+        this.scrollIntoView({block: 'center'});
       });
     }
+  }
+
+  private isDraft() {
+    return isDraft(this.getLastComment());
+  }
+
+  private isDraftOrUnsaved(): boolean {
+    return this.isDraft() || this.isUnsaved();
+  }
+
+  private isNewThread(): boolean {
+    return this.thread?.comments.length === 0;
+  }
+
+  private isUnsaved(): boolean {
+    return !!this.unsavedComment || this.thread?.comments.length === 0;
+  }
+
+  private isPatchsetLevel() {
+    return this.thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+  }
+
+  private computeDiff() {
+    if (!this.showCommentContext) return;
+    if (!this.thread?.path) return;
+    const firstComment = this.getFirstComment();
+    if (!firstComment?.context_lines?.length) return;
+    const diff = computeDiffFromContext(
+      firstComment.context_lines,
+      this.thread?.path,
+      firstComment.source_content_type
+    );
+    // Do we really have to re-compute (and re-render) the diff?
+    if (this.diff && JSON.stringify(this.diff) === JSON.stringify(diff)) {
+      return this.diff;
+    }
+
+    if (!anyLineTooLong(diff)) {
+      this.syntaxLayer.process(diff);
+    }
     return diff;
   }
 
-  handleShouldScrollIntoViewChanged(shouldScrollIntoView?: boolean) {
-    // Wait for comment to be rendered before scrolling to it
-    if (shouldScrollIntoView) {
-      const resizeObserver = new ResizeObserver(
-        (_entries: ResizeObserverEntry[], observer: ResizeObserver) => {
-          if (this.offsetHeight > 0) {
-            queryAndAssert<HTMLDivElement>(this, '.comment-box').focus();
-            this.scrollIntoView();
-          }
-          observer.unobserve(this);
-        }
-      );
-      resizeObserver.observe(this);
+  private getDiffUrlForPath() {
+    if (!this.changeNum || !this.repoName || !this.thread?.path) {
+      return undefined;
     }
+    if (this.isNewThread()) return undefined;
+    return GerritNav.getUrlForDiffById(
+      this.changeNum,
+      this.repoName,
+      this.thread.path,
+      this.thread.patchNum
+    );
   }
 
-  _shouldShowCommentContext(
-    changeNum?: NumericChangeId,
-    showCommentContext?: boolean,
-    diff?: DiffInfo
-  ) {
-    return changeNum && showCommentContext && !!diff;
-  }
-
-  addOrEditDraft(lineNum?: LineNumber, rangeParam?: CommentRange) {
-    const lastComment = this.comments[this.comments.length - 1] || {};
-    if (isDraft(lastComment)) {
-      const commentEl = this._commentElWithDraftID(
-        lastComment.id || lastComment.__draftID
-      );
-      if (!commentEl) throw new Error('Failed to find draft.');
-      commentEl.editing = true;
-
-      // If the comment was collapsed, re-open it to make it clear which
-      // actions are available.
-      commentEl.collapsed = false;
-    } else {
-      const range = rangeParam
-        ? rangeParam
-        : lastComment
-        ? lastComment.range
-        : undefined;
-      const unresolved = lastComment ? lastComment.unresolved : undefined;
-      this.addDraft(lineNum, range, unresolved);
-    }
-  }
-
-  addDraft(lineNum?: LineNumber, range?: CommentRange, unresolved?: boolean) {
-    const draft = this._newDraft(lineNum, range);
-    draft.__editing = true;
-    draft.unresolved = unresolved === false ? unresolved : true;
-    this.commentsService.addDraft(draft);
-  }
-
-  _getDiffUrlForPath(
-    projectName?: RepoName,
-    changeNum?: NumericChangeId,
-    path?: string,
-    patchNum?: PatchSetNum
-  ) {
-    if (!changeNum || !projectName || !path) return undefined;
-    if (isDraft(this.comments[0])) {
-      return GerritNav.getUrlForDiffById(
-        changeNum,
-        projectName,
-        path,
-        patchNum
-      );
-    }
-    const id = this.comments[0].id;
-    if (!id) throw new Error('A published comment is missing the id.');
-    return GerritNav.getUrlForComment(changeNum, projectName, id);
-  }
-
-  /** The parameter is for triggering re-computation only. */
-  getHighlightRange(_: unknown) {
-    const comment = this.comments?.[0];
+  private computeHighlightRange() {
+    const comment = this.getFirstComment();
     if (!comment) return undefined;
     if (comment.range) return comment.range;
     if (comment.line) {
@@ -366,413 +710,137 @@
     return undefined;
   }
 
-  _initLayers(disableTokenHighlighting: boolean) {
-    if (!disableTokenHighlighting) {
-      this.layers.push(new TokenHighlightLayer(this));
+  // Does not work for patchset level comments
+  private getUrlForFileComment() {
+    if (!this.repoName || !this.changeNum || this.isNewThread()) {
+      return undefined;
     }
-    this.layers.push(this.syntaxLayer);
-  }
-
-  _getUrlForViewDiff(
-    comments: UIComment[],
-    changeNum?: NumericChangeId,
-    projectName?: RepoName
-  ): string {
-    if (!changeNum) return '';
-    if (!projectName) return '';
-    check(comments.length > 0, 'comment not found');
-    return GerritNav.getUrlForComment(changeNum, projectName, comments[0].id!);
-  }
-
-  _getDiffUrlForComment(
-    projectName?: RepoName,
-    changeNum?: NumericChangeId,
-    path?: string,
-    patchNum?: PatchSetNum
-  ) {
-    if (!projectName || !changeNum || !path) return undefined;
-    if (
-      (this.comments.length && this.comments[0].side === 'PARENT') ||
-      isDraft(this.comments[0])
-    ) {
-      if (this.lineNum === 'LOST') throw new Error('invalid lineNum lost');
-      return GerritNav.getUrlForDiffById(
-        changeNum,
-        projectName,
-        path,
-        patchNum,
-        undefined,
-        this.lineNum === FILE ? undefined : this.lineNum
-      );
-    }
-    const id = this.comments[0].id;
-    if (!id) throw new Error('A published comment is missing the id.');
-    return GerritNav.getUrlForComment(changeNum, projectName, id);
-  }
-
-  handleCopyLink() {
-    assertIsDefined(this.changeNum, 'changeNum');
-    assertIsDefined(this.projectName, 'projectName');
-    const url = generateAbsoluteUrl(
-      GerritNav.getUrlForCommentsTab(
-        this.changeNum,
-        this.projectName,
-        this.comments[0].id!
-      )
+    assertIsDefined(this.rootId, 'rootId of comment thread');
+    return GerritNav.getUrlForComment(
+      this.changeNum,
+      this.repoName,
+      this.rootId
     );
-    navigator.clipboard.writeText(url).then(() => {
+  }
+
+  private handleCopyLink() {
+    const comment = this.getFirstComment();
+    if (!comment) return;
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.repoName, 'repoName');
+    const url = generateAbsoluteUrl(
+      GerritNav.getUrlForCommentsTab(this.changeNum, this.repoName, comment.id)
+    );
+    assertIsDefined(url, 'url for comment');
+    navigator.clipboard.writeText(generateAbsoluteUrl(url)).then(() => {
       fireAlert(this, 'Link copied to clipboard');
     });
   }
 
-  _isPatchsetLevelComment(path?: string) {
-    return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+  private getDisplayPath() {
+    if (this.isPatchsetLevel()) return 'Patchset';
+    return computeDisplayPath(this.thread?.path);
   }
 
-  _computeShowPortedComment(comment: UIComment) {
-    if (this._orderedComments.length === 0) return false;
-    return this.showPortedComment && comment.id === this._orderedComments[0].id;
-  }
-
-  _computeDisplayPath(path?: string) {
-    const displayPath = computeDisplayPath(path);
-    if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-      return 'Patchset';
-    }
-    return displayPath;
-  }
-
-  _computeDisplayLine(lineNum?: LineNumber, range?: CommentRange) {
-    if (lineNum === FILE) {
-      if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-        return '';
-      }
-      return FILE;
-    }
-    if (lineNum) return `#${lineNum}`;
+  private computeDisplayLine() {
+    assertIsDefined(this.thread, 'thread');
+    if (this.thread.line === FILE) return this.isPatchsetLevel() ? '' : FILE;
+    if (this.thread.line) return `#${this.thread.line}`;
     // If range is set, then lineNum equals the end line of the range.
-    if (range) return `#${range.end_line}`;
+    if (this.thread.range) return `#${this.thread.range.end_line}`;
     return '';
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
+  private isRobotComment() {
+    return isRobot(this.getLastComment());
   }
 
-  _getUnresolvedLabel(unresolved?: boolean) {
-    return unresolved ? 'Unresolved' : 'Resolved';
+  private getFirstComment() {
+    assertIsDefined(this.thread);
+    return getFirstComment(this.thread);
   }
 
-  @observe('comments.*')
-  _commentsChanged() {
-    this._orderedComments = sortComments(this.comments);
-    this.updateThreadProperties();
+  private getLastComment() {
+    assertIsDefined(this.thread);
+    return getLastComment(this.thread);
   }
 
-  updateThreadProperties() {
-    if (this._orderedComments.length) {
-      this._lastComment = this._getLastComment();
-      this.unresolved = this._lastComment.unresolved;
-      this.hasDraft = isDraft(this._lastComment);
-      this.isRobotComment = isRobot(this._lastComment);
+  private handleExpandShortcut() {
+    this.expandCollapseComments(false);
+  }
+
+  private handleCollapseShortcut() {
+    this.expandCollapseComments(true);
+  }
+
+  private expandCollapseComments(actionIsCollapse: boolean) {
+    for (const comment of this.commentElements ?? []) {
+      (comment as GrComment).collapsed = actionIsCollapse;
     }
   }
 
-  _shouldDisableAction(_showActions?: boolean, _lastComment?: UIComment) {
-    return !_showActions || !_lastComment || isDraft(_lastComment);
-  }
-
-  _hideActions(_showActions?: boolean, _lastComment?: UIComment) {
-    return (
-      this._shouldDisableAction(_showActions, _lastComment) ||
-      isRobot(_lastComment)
-    );
-  }
-
-  _getLastComment() {
-    return this._orderedComments[this._orderedComments.length - 1] || {};
-  }
-
-  private handleExpandShortcut(e: KeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    this._expandCollapseComments(false);
-  }
-
-  private handleCollapseShortcut(e: KeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    this._expandCollapseComments(true);
-  }
-
-  _expandCollapseComments(actionIsCollapse: boolean) {
-    const comments = this.root?.querySelectorAll('gr-comment');
-    if (!comments) return;
-    for (const comment of comments) {
-      comment.collapsed = actionIsCollapse;
+  private async createReplyComment(
+    content: string,
+    userWantsToEdit: boolean,
+    unresolved: boolean
+  ) {
+    const replyingTo = this.getLastComment();
+    assertIsDefined(this.thread, 'thread');
+    assertIsDefined(replyingTo, 'the comment that the user wants to reply to');
+    if (isDraft(replyingTo)) {
+      throw new Error('cannot reply to draft');
     }
-  }
-
-  /**
-   * Sets the initial state of the comment thread.
-   * Expands the thread if one of the following is true:
-   * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
-   * thread is unresolved,
-   * - it's a robot comment.
-   * - it's a draft
-   */
-  _setInitialExpandedState() {
-    if (this._orderedComments) {
-      for (let i = 0; i < this._orderedComments.length; i++) {
-        const comment = this._orderedComments[i];
-        if (isDraft(comment)) {
-          comment.collapsed = false;
-          continue;
-        }
-        const isRobotComment = !!(comment as UIRobot).robot_id;
-        // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
-        const resolvedThread =
-          !this.unresolved ||
-          this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
-        if (comment.collapsed === undefined) {
-          comment.collapsed = !isRobotComment && resolvedThread;
-        }
+    if (isUnsaved(replyingTo)) {
+      throw new Error('cannot reply to unsaved comment');
+    }
+    const unsaved = createUnsavedReply(replyingTo, content, unresolved);
+    if (userWantsToEdit) {
+      this.unsavedComment = unsaved;
+    } else {
+      try {
+        this.saving = true;
+        await this.getCommentsModel().saveDraft(unsaved);
+      } finally {
+        this.saving = false;
       }
     }
   }
 
-  _createReplyComment(
-    content?: string,
-    isEditing?: boolean,
-    unresolved?: boolean
-  ) {
-    this.reporting.recordDraftInteraction();
-    const id = this._orderedComments[this._orderedComments.length - 1].id;
-    if (!id) throw new Error('Cannot reply to comment without id.');
-    const reply = this._newReply(id, content, unresolved);
-
-    if (isEditing) {
-      reply.__editing = true;
-      this.commentsService.addDraft(reply);
-    } else {
-      assertIsDefined(this.changeNum, 'changeNum');
-      assertIsDefined(this.patchNum, 'patchNum');
-      this.restApiService
-        .saveDiffDraft(this.changeNum, this.patchNum, reply)
-        .then(result => {
-          if (!result.ok) {
-            fireAlert(document, 'Unable to restore draft');
-            return;
-          }
-          this.restApiService.getResponseObject(result).then(obj => {
-            const resComment = obj as unknown as DraftInfo;
-            resComment.patch_set = reply.patch_set;
-            this.commentsService.addDraft(resComment);
-          });
-        });
-    }
-  }
-
-  _isDraft(comment: UIComment) {
-    return isDraft(comment);
-  }
-
-  _processCommentReply(quote?: boolean) {
-    const comment = this._lastComment;
+  private handleCommentReply(quote: boolean) {
+    const comment = this.getLastComment();
     if (!comment) throw new Error('Failed to find last comment.');
-    let content = undefined;
+    let content = '';
     if (quote) {
       const msg = comment.message;
       if (!msg) throw new Error('Quoting empty comment.');
       content = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
     }
-    this._createReplyComment(content, true, comment.unresolved);
+    this.createReplyComment(content, true, comment.unresolved ?? true);
   }
 
-  _handleCommentReply() {
-    this._processCommentReply();
+  private handleCommentAck() {
+    this.createReplyComment('Ack', false, false);
   }
 
-  _handleCommentQuote() {
-    this._processCommentReply(true);
+  private handleCommentDone() {
+    this.createReplyComment('Done', false, false);
   }
 
-  _handleCommentAck() {
-    this._createReplyComment('Ack', false, false);
-  }
-
-  _handleCommentDone() {
-    this._createReplyComment('Done', false, false);
-  }
-
-  _handleCommentFix(e: CustomEvent) {
+  private handleCommentFix(e: CustomEvent) {
     const comment = e.detail.comment;
     const msg = comment.message;
     const quoted = msg.replace(NEWLINE_PATTERN, '\n> ') as string;
     const quoteStr = '> ' + quoted + '\n\n';
     const response = quoteStr + 'Please fix.';
-    this._createReplyComment(response, false, true);
+    this.createReplyComment(response, false, true);
   }
 
-  _commentElWithDraftID(id?: string): GrComment | null {
-    if (!id) return null;
-    const els = this.root?.querySelectorAll('gr-comment');
-    if (!els) return null;
-    for (const el of els) {
-      const c = el.comment;
-      if (isRobot(c)) continue;
-      if (c?.id === id || (isDraft(c) && c?.__draftID === id)) return el;
-    }
-    return null;
-  }
-
-  _newReply(
-    inReplyTo: UrlEncodedCommentId,
-    message?: string,
-    unresolved?: boolean
-  ) {
-    const d = this._newDraft();
-    d.in_reply_to = inReplyTo;
-    if (message !== undefined) {
-      d.message = message;
-    }
-    if (unresolved !== undefined) {
-      d.unresolved = unresolved;
-    }
-    return d;
-  }
-
-  _newDraft(lineNum?: LineNumber, range?: CommentRange) {
-    const d: UIDraft = {
-      __draft: true,
-      __draftID: 'draft__' + Math.random().toString(36),
-      __date: new Date(),
-    };
-    if (lineNum === 'LOST') throw new Error('invalid lineNum lost');
-    // For replies, always use same meta info as root.
-    if (this.comments && this.comments.length >= 1) {
-      const rootComment = this.comments[0];
-      if (rootComment.path !== undefined) d.path = rootComment.path;
-      if (rootComment.patch_set !== undefined)
-        d.patch_set = rootComment.patch_set;
-      if (rootComment.side !== undefined) d.side = rootComment.side;
-      if (rootComment.line !== undefined) d.line = rootComment.line;
-      if (rootComment.range !== undefined) d.range = rootComment.range;
-      if (rootComment.parent !== undefined) d.parent = rootComment.parent;
-    } else {
-      // Set meta info for root comment.
-      d.path = this.path;
-      d.patch_set = this.patchNum;
-      d.side = this._getSide(this.isOnParent);
-
-      if (lineNum && lineNum !== FILE) {
-        d.line = lineNum;
-      }
-      if (range) {
-        d.range = range;
-      }
-      if (this.parentIndex) {
-        d.parent = this.parentIndex;
-      }
-    }
-    return d;
-  }
-
-  _getSide(isOnParent: boolean): CommentSide {
-    return isOnParent ? CommentSide.PARENT : CommentSide.REVISION;
-  }
-
-  _computeRootId(comments: PolymerDeepPropertyChange<UIComment[], unknown>) {
-    // Keep the root ID even if the comment was removed, so that notification
-    // to sync will know which thread to remove.
-    if (!comments.base.length) {
-      return this.rootId;
-    }
-    return computeId(comments.base[0]);
-  }
-
-  _handleCommentDiscard() {
-    assertIsDefined(this.changeNum, 'changeNum');
-    assertIsDefined(this.patchNum, 'patchNum');
-    // Check to see if there are any other open comments getting edited and
-    // set the local storage value to its message value.
-    for (const changeComment of this.comments) {
-      if (isDraft(changeComment) && changeComment.__editing) {
-        const commentLocation: StorageLocation = {
-          changeNum: this.changeNum,
-          patchNum: this.patchNum,
-          path: changeComment.path,
-          line: changeComment.line,
-        };
-        this.storage.setDraftComment(
-          commentLocation,
-          changeComment.message ?? ''
-        );
-      }
-    }
-  }
-
-  _handleCommentUpdate(e: CustomEvent) {
-    const comment = e.detail.comment;
-    const index = this._indexOf(comment, this.comments);
-    if (index === -1) {
-      // This should never happen: comment belongs to another thread.
-      this.reporting.error(
-        new Error(`Comment update for another comment thread: ${comment}`)
-      );
-      return;
-    }
-    this.set(['comments', index], comment);
-    // Because of the way we pass these comment objects around by-ref, in
-    // combination with the fact that Polymer does dirty checking in
-    // observers, the this.set() call above will not cause a thread update in
-    // some situations.
-    this.updateThreadProperties();
-  }
-
-  _indexOf(comment: UIComment | undefined, arr: UIComment[]) {
-    if (!comment) return -1;
-    for (let i = 0; i < arr.length; i++) {
-      const c = arr[i];
-      if (
-        (isDraft(c) && isDraft(comment) && c.__draftID === comment.__draftID) ||
-        (c.id && c.id === comment.id)
-      ) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
-  /** 2nd parameter is for triggering re-computation only. */
-  _computeHostClass(unresolved?: boolean, _?: unknown) {
-    if (this.isRobotComment) {
-      return 'robotComment';
-    }
-    return unresolved ? 'unresolved' : '';
-  }
-
-  /**
-   * Load the project config when a project name has been provided.
-   *
-   * @param name The project name.
-   */
-  _projectNameChanged(name?: RepoName) {
-    if (!name) {
-      return;
-    }
-    this.restApiService.getProjectConfig(name).then(config => {
-      this._projectConfig = config;
-    });
-  }
-
-  _computeAriaHeading(_orderedComments: UIComment[]) {
-    const firstComment = _orderedComments[0];
-    const author = firstComment?.author ?? this._selfAccount;
-    const lastComment = _orderedComments[_orderedComments.length - 1] || {};
-    const status = [
-      lastComment.unresolved ? 'Unresolved' : '',
-      isDraft(lastComment) ? 'Draft' : '',
-    ].join(' ');
-    return `${status} Comment thread by ${getUserName(undefined, author)}`;
+  private computeAriaHeading() {
+    const author = this.getFirstComment()?.author ?? this.account;
+    const user = getUserName(undefined, author);
+    const unresolvedStatus = this.unresolved ? 'Unresolved ' : '';
+    const draftStatus = this.isDraftOrUnsaved() ? 'Draft ' : '';
+    return `${unresolvedStatus}${draftStatus}Comment thread by ${user}`;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
deleted file mode 100644
index c3faaa5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_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="gr-a11y-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      font-family: var(--font-family);
-      font-size: var(--font-size-normal);
-      font-weight: var(--font-weight-normal);
-      line-height: var(--line-height-normal);
-      /* Explicitly set the background color of the diff. We
-       * cannot use the diff content type ab because of the skip chunk preceding
-       * it, diff processor assumes the chunk of type skip/ab can be collapsed
-       * and hides our diff behind context control buttons.
-       *  */
-      --dark-add-highlight-color: var(--background-color-primary);
-    }
-    gr-button {
-      margin-left: var(--spacing-m);
-    }
-    gr-comment {
-      border-bottom: 1px solid var(--comment-separator-color);
-    }
-    #actions {
-      margin-left: auto;
-      padding: var(--spacing-s) var(--spacing-m);
-    }
-    .comment-box {
-      width: 80ch;
-      max-width: 100%;
-      background-color: var(--comment-background-color);
-      color: var(--comment-text-color);
-      box-shadow: var(--elevation-level-2);
-      border-radius: var(--border-radius);
-      flex-shrink: 0;
-    }
-    #container {
-      display: var(--gr-comment-thread-display, flex);
-      align-items: flex-start;
-      margin: 0 var(--spacing-s) var(--spacing-s);
-      white-space: normal;
-      /** This is required for firefox to continue the inheritance */
-      -webkit-user-select: inherit;
-      -moz-user-select: inherit;
-      -ms-user-select: inherit;
-      user-select: inherit;
-    }
-    .comment-box.unresolved {
-      background-color: var(--unresolved-comment-background-color);
-    }
-    .comment-box.robotComment {
-      background-color: var(--robot-comment-background-color);
-    }
-    #commentInfoContainer {
-      display: flex;
-    }
-    #unresolvedLabel {
-      font-family: var(--font-family);
-      margin: auto 0;
-      padding: var(--spacing-m);
-    }
-    .pathInfo {
-      display: flex;
-      align-items: baseline;
-      justify-content: space-between;
-      padding: 0 var(--spacing-s) var(--spacing-s);
-    }
-    .fileName {
-      padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
-    }
-    @media only screen and (max-width: 1200px) {
-      .diff-container {
-        display: none;
-      }
-    }
-    .diff-container {
-      margin-left: var(--spacing-l);
-      border: 1px solid var(--border-color);
-      flex-grow: 1;
-      flex-shrink: 1;
-      max-width: 1200px;
-    }
-    .view-diff-button {
-      margin: var(--spacing-s) var(--spacing-m);
-    }
-    .view-diff-container {
-      border-top: 1px solid var(--border-color);
-      background-color: var(--background-color-primary);
-    }
-
-    /* In saved state the "reply" and "quote" buttons are 28px height.
-     * top:4px  positions the 20px icon vertically centered.
-     * Currently in draft state the "save" and "cancel" buttons are 20px
-     * height, so the link icon does not need a top:4px in gr-comment_html.
-     */
-    .link-icon {
-      position: relative;
-      top: 4px;
-      cursor: pointer;
-    }
-    .fileName gr-copy-clipboard {
-      display: inline-block;
-      visibility: hidden;
-      vertical-align: top;
-      --gr-button-padding: 0px;
-    }
-    .fileName:focus-within gr-copy-clipboard,
-    .fileName:hover gr-copy-clipboard {
-      visibility: visible;
-    }
-  </style>
-
-  <template is="dom-if" if="[[showFilePath]]">
-    <template is="dom-if" if="[[showFileName]]">
-      <div class="fileName">
-        <template is="dom-if" if="[[_isPatchsetLevelComment(path)]]">
-          <span> [[_computeDisplayPath(path)]] </span>
-        </template>
-        <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
-          <a
-            href$="[[_getDiffUrlForPath(projectName, changeNum, path, patchNum)]]"
-          >
-            [[_computeDisplayPath(path)]]
-          </a>
-          <gr-copy-clipboard
-            hideInput=""
-            text="[[_computeDisplayPath(path)]]"
-          ></gr-copy-clipboard>
-        </template>
-      </div>
-    </template>
-    <div class="pathInfo">
-      <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
-        <a
-          href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
-          >[[_computeDisplayLine(lineNum, range)]]</a
-        >
-      </template>
-    </div>
-  </template>
-  <div id="container">
-    <h3 class="assistive-tech-only">
-      [[_computeAriaHeading(_orderedComments)]]
-    </h3>
-    <div
-      class$="[[_computeHostClass(unresolved, isRobotComment)]] comment-box"
-      tabindex="0"
-    >
-      <template
-        id="commentList"
-        is="dom-repeat"
-        items="[[_orderedComments]]"
-        as="comment"
-      >
-        <gr-comment
-          comment="{{comment}}"
-          comments="{{comments}}"
-          robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
-          change-num="[[changeNum]]"
-          project-name="[[projectName]]"
-          patch-num="[[patchNum]]"
-          draft="[[_isDraft(comment)]]"
-          show-actions="[[_showActions]]"
-          show-patchset="[[showPatchset]]"
-          show-ported-comment="[[_computeShowPortedComment(comment)]]"
-          side="[[comment.side]]"
-          project-config="[[_projectConfig]]"
-          on-create-fix-comment="_handleCommentFix"
-          on-comment-discard="_handleCommentDiscard"
-          on-copy-comment-link="handleCopyLink"
-        ></gr-comment>
-      </template>
-      <div
-        id="commentInfoContainer"
-        hidden$="[[_hideActions(_showActions, _lastComment)]]"
-      >
-        <span id="unresolvedLabel">[[_getUnresolvedLabel(unresolved)]]</span>
-        <div id="actions">
-          <iron-icon
-            class="link-icon"
-            on-click="handleCopyLink"
-            class="copy"
-            title="Copy link to this comment"
-            icon="gr-icons:link"
-            role="button"
-            tabindex="0"
-          >
-          </iron-icon>
-          <gr-button
-            id="replyBtn"
-            link=""
-            class="action reply"
-            on-click="_handleCommentReply"
-            >Reply</gr-button
-          >
-          <gr-button
-            id="quoteBtn"
-            link=""
-            class="action quote"
-            on-click="_handleCommentQuote"
-            >Quote</gr-button
-          >
-          <template is="dom-if" if="[[unresolved]]">
-            <gr-button
-              id="ackBtn"
-              link=""
-              class="action ack"
-              on-click="_handleCommentAck"
-              >Ack</gr-button
-            >
-            <gr-button
-              id="doneBtn"
-              link=""
-              class="action done"
-              on-click="_handleCommentDone"
-              >Done</gr-button
-            >
-          </template>
-        </div>
-      </div>
-    </div>
-    <template
-      is="dom-if"
-      if="[[_shouldShowCommentContext(changeNum, showCommentContext, _diff)]]"
-    >
-      <div class="diff-container">
-        <gr-diff
-          id="diff"
-          change-num="[[changeNum]]"
-          diff="[[_diff]]"
-          layers="[[layers]]"
-          path="[[path]]"
-          prefs="[[_prefs]]"
-          render-prefs="[[_renderPrefs]]"
-          highlight-range="[[getHighlightRange(comments)]]"
-        >
-        </gr-diff>
-        <div class="view-diff-container">
-          <a href="[[_getUrlForViewDiff(comments, changeNum, projectName)]]">
-            <gr-button link class="view-diff-button">View Diff</gr-button>
-          </a>
-        </div>
-      </div>
-    </template>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 595de34..3573197 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -14,936 +14,443 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-comment-thread';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {SpecialFilePath, Side} from '../../../constants/constants';
-import {
-  sortComments,
-  UIComment,
-  UIRobot,
-  UIDraft,
-} from '../../../utils/comment-util';
+import {DraftInfo, sortComments} from '../../../utils/comment-util';
 import {GrCommentThread} from './gr-comment-thread';
 import {
-  PatchSetNum,
   NumericChangeId,
   UrlEncodedCommentId,
   Timestamp,
-  RobotId,
-  RobotRunId,
+  CommentInfo,
   RepoName,
-  ConfigInfo,
-  EmailAddress,
 } from '../../../types/common';
-import {GrComment} from '../gr-comment/gr-comment';
-import {LineNumber} from '../../diff/gr-diff/gr-diff-line';
-import {
-  tap,
-  pressAndReleaseKeyOn,
-} from '@polymer/iron-test-helpers/mock-interactions';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {
   mockPromise,
-  stubComments,
-  stubReporting,
+  queryAndAssert,
   stubRestApi,
+  waitUntilCalled,
+  MockPromise,
 } from '../../../test/test-utils';
-import {_testOnly_resetState} from '../../../services/comments/comments-model';
+import {
+  createAccountDetailWithId,
+  createThread,
+} from '../../../test/test-data-generators';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {SinonStub} from 'sinon';
+import {waitUntil} from '@open-wc/testing-helpers';
 
 const basicFixture = fixtureFromElement('gr-comment-thread');
 
-const withCommentFixture = fixtureFromElement('gr-comment-thread');
+const c1 = {
+  author: {name: 'Kermit'},
+  id: 'the-root' as UrlEncodedCommentId,
+  message: 'start the conversation',
+  updated: '2021-11-01 10:11:12.000000000' as Timestamp,
+};
+
+const c2 = {
+  author: {name: 'Ms Piggy'},
+  id: 'the-reply' as UrlEncodedCommentId,
+  message: 'keep it going',
+  updated: '2021-11-02 10:11:12.000000000' as Timestamp,
+  in_reply_to: 'the-root' as UrlEncodedCommentId,
+};
+
+const c3 = {
+  author: {name: 'Kermit'},
+  id: 'the-draft' as UrlEncodedCommentId,
+  message: 'stop it',
+  updated: '2021-11-03 10:11:12.000000000' as Timestamp,
+  in_reply_to: 'the-reply' as UrlEncodedCommentId,
+  __draft: true,
+};
+
+const commentWithContext = {
+  author: {name: 'Kermit'},
+  id: 'the-draft' as UrlEncodedCommentId,
+  message: 'just for context',
+  updated: '2021-11-03 10:11:12.000000000' as Timestamp,
+  line: 5,
+  context_lines: [
+    {line_number: 4, context_line: 'content of line 4'},
+    {line_number: 5, context_line: 'content of line 5'},
+    {line_number: 6, context_line: 'content of line 6'},
+  ],
+};
 
 suite('gr-comment-thread tests', () => {
-  suite('basic test', () => {
-    let element: GrCommentThread;
-
-    setup(() => {
-      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      _testOnly_resetState();
-      element = basicFixture.instantiate();
-      element.patchNum = 3 as PatchSetNum;
-      element.changeNum = 1 as NumericChangeId;
-      flush();
-    });
-
-    test('renders without patchNum and changeNum', async () => {
-      const fixture = fixtureFromTemplate(
-        html`<gr-comment-thread show-file-path="" path="path/to/file"></gr-change-metadata>`
-      );
-      fixture.instantiate();
-      await flush();
-    });
-
-    test('comments are sorted correctly', () => {
-      const comments: UIComment[] = [
-        {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          __date: new Date('2015-12-25'),
-        },
-        {
-          id: 'sallys_confession' as UrlEncodedCommentId,
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_defiance' as UrlEncodedCommentId,
-          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'dr_finklesteins_response' as UrlEncodedCommentId,
-          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_mission' as UrlEncodedCommentId,
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-        },
-      ];
-      const results = sortComments(comments);
-      assert.deepEqual(results, [
-        {
-          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'dr_finklesteins_response' as UrlEncodedCommentId,
-          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_defiance' as UrlEncodedCommentId,
-          in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_confession' as UrlEncodedCommentId,
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-        },
-        {
-          id: 'sallys_mission' as UrlEncodedCommentId,
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-        },
-        {
-          message: 'i like you, too' as UrlEncodedCommentId,
-          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          __date: new Date('2015-12-25'),
-        },
-      ]);
-    });
-
-    test('addOrEditDraft w/ edit draft', () => {
-      element.comments = [
-        {
-          id: 'jacks_reply' as UrlEncodedCommentId,
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          updated: '2015-12-25 15:00:20.396000000' as Timestamp,
-          __draft: true,
-        },
-      ];
-      const commentElStub = sinon
-        .stub(element, '_commentElWithDraftID')
-        .callsFake(() => new GrComment());
-      const addDraftStub = sinon.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isTrue(commentElStub.called);
-      assert.isFalse(addDraftStub.called);
-    });
-
-    test('addOrEditDraft w/o edit draft', () => {
-      element.comments = [];
-      const commentElStub = sinon
-        .stub(element, '_commentElWithDraftID')
-        .callsFake(() => new GrComment());
-      const addDraftStub = sinon.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isFalse(commentElStub.called);
-      assert.isTrue(addDraftStub.called);
-    });
-
-    test('_shouldDisableAction', () => {
-      let showActions = true;
-      const lastComment: UIComment = {};
-      assert.equal(
-        element._shouldDisableAction(showActions, lastComment),
-        false
-      );
-      showActions = false;
-      assert.equal(
-        element._shouldDisableAction(showActions, lastComment),
-        true
-      );
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(
-        element._shouldDisableAction(showActions, lastComment),
-        true
-      );
-      const robotComment: UIRobot = {
-        id: '1234' as UrlEncodedCommentId,
-        updated: '1234' as Timestamp,
-        robot_id: 'robot_id' as RobotId,
-        robot_run_id: 'robot_run_id' as RobotRunId,
-        properties: {},
-        fix_suggestions: [],
-      };
-      assert.equal(
-        element._shouldDisableAction(showActions, robotComment),
-        false
-      );
-    });
-
-    test('_hideActions', () => {
-      let showActions = true;
-      const lastComment: UIComment = {};
-      assert.equal(element._hideActions(showActions, lastComment), false);
-      showActions = false;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      const robotComment: UIRobot = {
-        id: '1234' as UrlEncodedCommentId,
-        updated: '1234' as Timestamp,
-        robot_id: 'robot_id' as RobotId,
-        robot_run_id: 'robot_run_id' as RobotRunId,
-        properties: {},
-        fix_suggestions: [],
-      };
-      assert.equal(element._hideActions(showActions, robotComment), true);
-    });
-
-    test('setting project name loads the project config', async () => {
-      const projectName = 'foo/bar/baz' as RepoName;
-      const getProjectStub = stubRestApi('getProjectConfig').returns(
-        Promise.resolve({} as ConfigInfo)
-      );
-      element.projectName = projectName;
-      await flush();
-      assert.isTrue(getProjectStub.calledWithExactly(projectName as never));
-    });
-
-    test('optionally show file path', () => {
-      // Path info doesn't exist when showFilePath is false. Because it's in a
-      // dom-if it is not yet in the dom.
-      assert.isNotOk(element.shadowRoot?.querySelector('.pathInfo'));
-
-      const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
-      element.changeNum = 123 as NumericChangeId;
-      element.projectName = 'test project' as RepoName;
-      element.path = 'path/to/file';
-      element.patchNum = 3 as PatchSetNum;
-      element.lineNum = 5;
-      element.comments = [{id: 'comment_id' as UrlEncodedCommentId}];
-      element.showFilePath = true;
-      flush();
-      assert.isOk(element.shadowRoot?.querySelector('.pathInfo'));
-      assert.notEqual(
-        getComputedStyle(element.shadowRoot!.querySelector('.pathInfo')!)
-          .display,
-        'none'
-      );
-      assert.isTrue(
-        commentStub.calledWithExactly(
-          element.changeNum,
-          element.projectName,
-          'comment_id' as UrlEncodedCommentId
-        )
-      );
-    });
-
-    test('_computeDisplayPath', () => {
-      let path = 'path/to/file';
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.patchNum = 3 as PatchSetNum;
-      path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(element._computeDisplayPath(path), 'Patchset');
-    });
-
-    test('_computeDisplayLine', () => {
-      element.lineNum = 5;
-      assert.equal(
-        element._computeDisplayLine(element.lineNum, element.range),
-        '#5'
-      );
-
-      element.path = SpecialFilePath.COMMIT_MESSAGE;
-      element.lineNum = 5;
-      assert.equal(
-        element._computeDisplayLine(element.lineNum, element.range),
-        '#5'
-      );
-
-      element.lineNum = undefined;
-      element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(
-        element._computeDisplayLine(element.lineNum, element.range),
-        ''
-      );
-    });
-  });
-});
-
-suite('comment action tests with unresolved thread', () => {
-  let element: GrCommentThread;
-  let addDraftServiceStub: SinonStub;
-  let saveDiffDraftStub: SinonStub;
-  let comment = {
-    id: '7afa4931_de3d65bd',
-    path: '/path/to/file.txt',
-    line: 5,
-    in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-    updated: '2015-12-21 02:01:10.850000000',
-    message: 'Done',
-  };
-  const peanutButterComment = {
-    author: {
-      name: 'Mr. Peanutbutter',
-      email: 'tenn1sballchaser@aol.com' as EmailAddress as EmailAddress,
-    },
-    id: 'baf0414d_60047215' as UrlEncodedCommentId,
-    line: 5,
-    in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-    message: 'is this a crossover episode!?',
-    updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-    path: '/path/to/file.txt',
-    unresolved: true,
-    patch_set: 3 as PatchSetNum,
-  };
-  const mockResponse: Response = {
-    ...new Response(),
-    headers: {} as Headers,
-    redirected: false,
-    status: 200,
-    statusText: '',
-    type: '' as ResponseType,
-    url: '',
-    ok: true,
-    text() {
-      return Promise.resolve(")]}'\n" + JSON.stringify(comment));
-    },
-  };
-  let saveDiffDraftPromiseResolver: (value?: Response) => void;
-  setup(() => {
-    addDraftServiceStub = stubComments('addDraft');
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    saveDiffDraftStub = stubRestApi('saveDiffDraft').returns(
-      new Promise<Response>(
-        resolve =>
-          (saveDiffDraftPromiseResolver = resolve as (value?: Response) => void)
-      )
-    );
-    stubRestApi('deleteDiffDraft').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    element = withCommentFixture.instantiate();
-    element.patchNum = 1 as PatchSetNum;
-    element.changeNum = 1 as NumericChangeId;
-    element.comments = [peanutButterComment];
-    flush();
-  });
-
-  test('reply', () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const reportStub = stubReporting('recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const replyBtn = element.$.replyBtn;
-    tap(replyBtn);
-    flush();
-    const draft = addDraftServiceStub.firstCall.args[0];
-    assert.isOk(draft);
-    assert.notOk(draft.message, 'message should be empty');
-    assert.equal(
-      draft.in_reply_to,
-      'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
-    );
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('quote reply', () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const reportStub = stubReporting('recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    tap(quoteBtn);
-    flush();
-
-    const draft = addDraftServiceStub.firstCall.args[0];
-    // the quote reply is not autmatically saved so verify that id is not set
-    assert.isNotOk(draft.id);
-    // verify that the draft returned was not saved
-    assert.isNotOk(saveDiffDraftStub.called);
-    assert.equal(draft.message, '> is this a crossover episode!?\n\n');
-    assert.equal(
-      draft.in_reply_to,
-      'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
-    );
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('quote reply multiline', () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    const reportStub = stubReporting('recordDraftInteraction');
-    element.comments = [
-      {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress as EmailAddress,
-        },
-        id: 'baf0414d_60047215' as UrlEncodedCommentId,
-        path: 'test',
-        line: 5,
-        message: 'is this a crossover episode!?\nIt might be!',
-        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-      },
-    ];
-    flush();
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    tap(quoteBtn);
-    flush();
-
-    const draft = addDraftServiceStub.firstCall.args[0];
-    assert.equal(
-      draft.message,
-      '> is this a crossover episode!?\n> It might be!\n\n'
-    );
-    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('ack', async () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    comment = {
-      id: '7afa4931_de3d65bd',
-      path: '/path/to/file.txt',
-      line: 5,
-      in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-      updated: '2015-12-21 02:01:10.850000000',
-      message: 'Ack',
-    };
-    const reportStub = stubReporting('recordDraftInteraction');
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
-    assert.isOk(ackBtn);
-    tap(ackBtn!);
-    await flush();
-    const draft = addDraftServiceStub.firstCall.args[0];
-    assert.equal(draft.message, 'Ack');
-    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
-    assert.isNotOk(draft.unresolved);
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('done', async () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    comment = {
-      id: '7afa4931_de3d65bd',
-      path: '/path/to/file.txt',
-      line: 5,
-      in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-      updated: '2015-12-21 02:01:10.850000000',
-      message: 'Done',
-    };
-    const reportStub = stubReporting('recordDraftInteraction');
-    assert.isFalse(saveDiffDraftStub.called);
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
-    assert.isOk(doneBtn);
-    tap(doneBtn!);
-    await flush();
-    const draft = addDraftServiceStub.firstCall.args[0];
-    // Since the reply is automatically saved, verify that draft.id is set in
-    // the model
-    assert.equal(draft.id, '7afa4931_de3d65bd');
-    assert.equal(draft.message, 'Done');
-    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
-    assert.isNotOk(draft.unresolved);
-    assert.isTrue(reportStub.calledOnce);
-    assert.isTrue(saveDiffDraftStub.called);
-  });
-
-  test('save', async () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    element.path = '/path/to/file.txt';
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    element.shadowRoot?.querySelector('gr-comment')?._fireSave();
-
-    await flush();
-    assert.equal(element.rootId, 'baf0414d_60047215' as UrlEncodedCommentId);
-  });
-
-  test('please fix', async () => {
-    comment = peanutButterComment;
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-    const promise = mockPromise();
-    commentEl!.addEventListener('create-fix-comment', async () => {
-      assert.isTrue(saveDiffDraftStub.called);
-      assert.isFalse(addDraftServiceStub.called);
-      saveDiffDraftPromiseResolver(mockResponse);
-      // flushing so the saveDiffDraftStub resolves and the draft is returned
-      await flush();
-      assert.isTrue(saveDiffDraftStub.called);
-      assert.isTrue(addDraftServiceStub.called);
-      const draft = saveDiffDraftStub.firstCall.args[2];
-      assert.equal(
-        draft.message,
-        '> is this a crossover episode!?\n\nPlease fix.'
-      );
-      assert.equal(
-        draft.in_reply_to,
-        'baf0414d_60047215' as UrlEncodedCommentId
-      );
-      assert.isTrue(draft.unresolved);
-      promise.resolve();
-    });
-    assert.isFalse(saveDiffDraftStub.called);
-    assert.isFalse(addDraftServiceStub.called);
-    commentEl!.dispatchEvent(
-      new CustomEvent('create-fix-comment', {
-        detail: {comment: commentEl!.comment},
-        composed: true,
-        bubbles: false,
-      })
-    );
-    await promise;
-  });
-
-  test('discard', async () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    element.path = '/path/to/file.txt';
-    assert.isOk(element.comments[0]);
-    const deleteDraftStub = stubComments('deleteDraft');
-    element.push(
-      'comments',
-      element._newReply(
-        element.comments[0]!.id as UrlEncodedCommentId,
-        'it’s pronouced jiff, not giff'
-      )
-    );
-    await flush();
-
-    const draftEl = element.root?.querySelectorAll('gr-comment')[1];
-    assert.ok(draftEl);
-    draftEl?._fireSave(); // tell the model about the draft
-    const promise = mockPromise();
-    draftEl!.addEventListener('comment-discard', () => {
-      assert.isTrue(deleteDraftStub.called);
-      promise.resolve();
-    });
-    draftEl!._fireDiscard();
-    await promise;
-  });
-
-  test('discard with a single comment still fires event with previous rootId', async () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    element.path = '/path/to/file.txt';
-    element.comments = [];
-    element.addOrEditDraft(1 as LineNumber);
-    const draft = addDraftServiceStub.firstCall.args[0];
-    element.comments = [draft];
-    flush();
-    const rootId = element.rootId;
-    assert.isOk(rootId);
-    flush();
-    const draftEl = element.root?.querySelectorAll('gr-comment')[0];
-    assert.ok(draftEl);
-    const deleteDraftStub = stubComments('deleteDraft');
-    const promise = mockPromise();
-    draftEl!.addEventListener('comment-discard', () => {
-      assert.isTrue(deleteDraftStub.called);
-      promise.resolve();
-    });
-    draftEl!._fireDiscard();
-    await promise;
-    assert.isTrue(deleteDraftStub.called);
-  });
-
-  test('comment-update', () => {
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const updatedComment = {
-      id: element.comments[0].id,
-      foo: 'bar',
-    };
-    assert.isOk(commentEl);
-    commentEl!.dispatchEvent(
-      new CustomEvent('comment-update', {
-        detail: {comment: updatedComment},
-        composed: true,
-        bubbles: true,
-      })
-    );
-    assert.strictEqual(element.comments[0], updatedComment);
-  });
-
-  suite('jack and sally comment data test consolidation', () => {
-    setup(() => {
-      element.comments = [
-        {
-          id: 'jacks_reply' as UrlEncodedCommentId,
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          updated: '2015-12-25 15:00:20.396000000' as Timestamp,
-          path: 'abcd',
-          unresolved: false,
-        },
-        {
-          id: 'sallys_confession' as UrlEncodedCommentId,
-          in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000' as Timestamp,
-          path: 'abcd',
-        },
-        {
-          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
-          path: 'abcd',
-        },
-        {
-          id: 'sallys_defiance' as UrlEncodedCommentId,
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
-          path: 'abcd',
-        },
-      ];
-    });
-
-    test('orphan replies', () => {
-      assert.equal(4, element._orderedComments.length);
-    });
-
-    test('keyboard shortcuts', () => {
-      const expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
-      pressAndReleaseKeyOn(element, 69, null, 'e');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
-
-      pressAndReleaseKeyOn(element, 69, 'shift', 'E');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
-    });
-
-    test('comment in_reply_to is either null or most recent comment', () => {
-      element._createReplyComment('dummy', true);
-      const draft = addDraftServiceStub.firstCall.args[0];
-      element.comments = [...element.comments, draft];
-      flush();
-      assert.equal(element._orderedComments.length, 5);
-      assert.equal(
-        element._orderedComments[4].in_reply_to,
-        'jacks_reply' as UrlEncodedCommentId
-      );
-    });
-
-    test('resolvable comments', () => {
-      assert.isFalse(element.unresolved);
-      element._createReplyComment('dummy', true, true);
-      const draft = addDraftServiceStub.firstCall.args[0];
-      element.comments = [...element.comments, draft];
-      flush();
-      assert.isTrue(element.unresolved);
-    });
-
-    test('_setInitialExpandedState with unresolved', () => {
-      element.unresolved = true;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState without unresolved', () => {
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with robot_ids', () => {
-      for (let i = 0; i < element.comments.length; i++) {
-        (element.comments[i] as UIRobot).robot_id = '123' as RobotId;
-      }
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with collapsed state', () => {
-      element.comments[0].collapsed = false;
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      assert.isFalse(element.comments[0].collapsed);
-      for (let i = 1; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-  });
-
-  test('_computeHostClass', () => {
-    assert.equal(element._computeHostClass(true), 'unresolved');
-    assert.equal(element._computeHostClass(false), '');
-  });
-
-  test('addDraft sets unresolved state correctly', () => {
-    let unresolved = true;
-    let draft;
-    element.comments = [];
-    element.path = 'abcd';
-    element.addDraft(undefined, undefined, unresolved);
-    draft = addDraftServiceStub.lastCall.args[0];
-    assert.equal(draft.unresolved, true);
-
-    unresolved = false; // comment should get added as actually resolved.
-    element.comments = [];
-    element.addDraft(undefined, undefined, unresolved);
-    draft = addDraftServiceStub.lastCall.args[0];
-    assert.equal(draft.unresolved, false);
-
-    element.comments = [];
-    element.addDraft();
-    draft = addDraftServiceStub.lastCall.args[0];
-    assert.equal(draft.unresolved, true);
-  });
-
-  test('_newDraft with root', () => {
-    const draft = element._newDraft();
-    assert.equal(draft.patch_set, 3 as PatchSetNum);
-  });
-
-  test('_newDraft with no root', () => {
-    element.comments = [];
-    element.diffSide = Side.RIGHT;
-    element.patchNum = 2 as PatchSetNum;
-    const draft = element._newDraft();
-    assert.equal(draft.patch_set, 2 as PatchSetNum);
-  });
-
-  test('new comment gets created', () => {
-    element.comments = [];
-    element.path = 'abcd';
-    element.addOrEditDraft(1);
-    const draft = addDraftServiceStub.firstCall.args[0];
-    element.comments = [draft];
-    flush();
-    assert.equal(element.comments.length, 1);
-    // Mock a submitted comment.
-    element.comments[0].id = (element.comments[0] as UIDraft)
-      .__draftID as UrlEncodedCommentId;
-    delete (element.comments[0] as UIDraft).__draft;
-    element.addOrEditDraft(1);
-    assert.equal(addDraftServiceStub.callCount, 2);
-  });
-
-  test('unresolved label', () => {
-    element.unresolved = false;
-    const label = element.shadowRoot?.querySelector('#unresolvedLabel');
-    assert.isOk(label);
-    assert.isFalse(label!.hasAttribute('hidden'));
-    element.unresolved = true;
-    assert.isFalse(label!.hasAttribute('hidden'));
-  });
-
-  test('draft comments are at the end of orderedComments', () => {
-    element.comments = [
-      {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: '2' as UrlEncodedCommentId,
-        line: 5,
-        message: 'Earlier draft',
-        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-        __draft: true,
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter2',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: '1' as UrlEncodedCommentId,
-        line: 5,
-        message: 'This comment was left last but is not a draft',
-        updated: '2015-12-10 19:48:33.843000000' as Timestamp,
-      },
-      {
-        author: {
-          name: 'Mr. Peanutbutter2',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: '3' as UrlEncodedCommentId,
-        line: 5,
-        message: 'Later draft',
-        updated: '2015-12-09 19:48:33.843000000' as Timestamp,
-        __draft: true,
-      },
-    ];
-    assert.equal(element._orderedComments[0].id, '1' as UrlEncodedCommentId);
-    assert.equal(element._orderedComments[1].id, '2' as UrlEncodedCommentId);
-    assert.equal(element._orderedComments[2].id, '3' as UrlEncodedCommentId);
-  });
-
-  test('reflects lineNum and commentSide to attributes', () => {
-    element.lineNum = 7;
-    element.diffSide = Side.LEFT;
-
-    assert.equal(element.getAttribute('line-num'), '7');
-    assert.equal(element.getAttribute('diff-side'), Side.LEFT);
-  });
-
-  test('reflects range to JSON serialized attribute if set', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-
-    assert.isOk(element.getAttribute('range'));
-    assert.deepEqual(JSON.parse(element.getAttribute('range')!), {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    });
-  });
-
-  test('removes range attribute if range is unset', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-    element.range = undefined;
-
-    assert.notOk(element.hasAttribute('range'));
-  });
-});
-
-suite('comment action tests on resolved comments', () => {
   let element: GrCommentThread;
 
-  setup(() => {
+  setup(async () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('saveDiffDraft').returns(
-      Promise.resolve({
-        ...new Response(),
-        ok: true,
-        text() {
-          return Promise.resolve(
-            ")]}'\n" +
-              JSON.stringify({
-                id: '7afa4931_de3d65bd',
-                path: '/path/to/file.txt',
-                line: 5,
-                in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-                updated: '2015-12-21 02:01:10.850000000',
-                message: 'Done',
-              })
-          );
-        },
-      })
-    );
-    stubRestApi('deleteDiffDraft').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    element = withCommentFixture.instantiate();
-    element.patchNum = 1 as PatchSetNum;
+    element = basicFixture.instantiate();
     element.changeNum = 1 as NumericChangeId;
-    element.comments = [
+    element.showFileName = true;
+    element.showFilePath = true;
+    element.repoName = 'test-repo-name' as RepoName;
+    await element.updateComplete;
+    element.account = {...createAccountDetailWithId(13), name: 'Yoda'};
+  });
+
+  test('renders with draft', async () => {
+    element.thread = createThread(c1, c2, c3);
+    await element.updateComplete;
+  });
+
+  test('renders with draft', async () => {
+    element.thread = createThread(c1, c2, c3);
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="fileName">
+        <span>test-path-comment-thread</span>
+        <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+      </div>
+      <div class="pathInfo">
+        <span>#314</span>
+      </div>
+      <div id="container">
+        <h3 class="assistive-tech-only">Draft Comment thread by Kermit</h3>
+        <div class="comment-box" tabindex="0">
+          <gr-comment
+            collapsed=""
+            initially-collapsed=""
+            robot-button-disabled=""
+            show-patchset=""
+          ></gr-comment>
+          <gr-comment
+            collapsed=""
+            initially-collapsed=""
+            robot-button-disabled=""
+            show-patchset=""
+          ></gr-comment>
+          <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+        </div>
+      </div>
+    `);
+  });
+
+  test('renders unsaved', async () => {
+    element.thread = createThread();
+    await element.updateComplete;
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="fileName">
+        <span>test-path-comment-thread</span>
+        <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+      </div>
+      <div class="pathInfo">
+        <span>#314</span>
+      </div>
+      <div id="container">
+        <h3 class="assistive-tech-only">
+          Unresolved Draft Comment thread by Yoda
+        </h3>
+        <div class="comment-box unresolved" tabindex="0">
+          <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+        </div>
+      </div>
+    `);
+  });
+
+  test('renders with actions resolved', async () => {
+    element.thread = createThread(c1, c2);
+    await element.updateComplete;
+    expect(queryAndAssert(element, '#container')).dom.to.equal(/* HTML */ `
+      <div id="container">
+        <h3 class="assistive-tech-only">Comment thread by Kermit</h3>
+        <div class="comment-box" tabindex="0">
+          <gr-comment
+            collapsed=""
+            initially-collapsed=""
+            show-patchset=""
+          ></gr-comment>
+          <gr-comment
+            collapsed=""
+            initially-collapsed=""
+            show-patchset=""
+          ></gr-comment>
+          <div id="actionsContainer">
+            <span id="unresolvedLabel"> Resolved </span>
+            <div id="actions">
+              <iron-icon
+                class="copy link-icon"
+                icon="gr-icons:link"
+                role="button"
+                tabindex="0"
+                title="Copy link to this comment"
+              >
+              </iron-icon>
+              <gr-button
+                aria-disabled="false"
+                class="action reply"
+                id="replyBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Reply
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                class="action quote"
+                id="quoteBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Quote
+              </gr-button>
+            </div>
+          </div>
+        </div>
+      </div>
+    `);
+  });
+
+  test('renders with actions unresolved', async () => {
+    element.thread = createThread(c1, {...c2, unresolved: true});
+    await element.updateComplete;
+    expect(queryAndAssert(element, '#container')).dom.to.equal(/* HTML */ `
+      <div id="container">
+        <h3 class="assistive-tech-only">Unresolved Comment thread by Kermit</h3>
+        <div class="comment-box unresolved" tabindex="0">
+          <gr-comment show-patchset=""></gr-comment>
+          <gr-comment show-patchset=""></gr-comment>
+          <div id="actionsContainer">
+            <span id="unresolvedLabel"> Unresolved </span>
+            <div id="actions">
+              <iron-icon
+                class="copy link-icon"
+                icon="gr-icons:link"
+                role="button"
+                tabindex="0"
+                title="Copy link to this comment"
+              >
+              </iron-icon>
+              <gr-button
+                aria-disabled="false"
+                class="action reply"
+                id="replyBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Reply
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                class="action quote"
+                id="quoteBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Quote
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                class="action ack"
+                id="ackBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Ack
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                class="action done"
+                id="doneBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Done
+              </gr-button>
+            </div>
+          </div>
+        </div>
+      </div>
+    `);
+  });
+
+  test('renders with diff', async () => {
+    element.showCommentContext = true;
+    element.thread = createThread(commentWithContext);
+    await element.updateComplete;
+
+    const gr_diff = queryAndAssert(element, '.diff-container gr-diff');
+    expect(gr_diff.id).to.equal('diff');
+    const gr_diff_classes = gr_diff.classList.value;
+    expect(gr_diff_classes).to.include('disable-context-control-buttons');
+    expect(gr_diff_classes).to.include('hide-line-length-indicator');
+    expect(gr_diff_classes).to.include('no-left');
+    // @ts-ignore
+    expect(gr_diff.getAttribute('style').replace(/\s+/g, '')).to.equal(
+      '--line-limit-marker:100ch;--content-width:none;--diff-max-width:none;--font-size:12px;'
+    );
+
+    expect(queryAndAssert(element, '.diff-container .view-diff-container')).dom
+      .to.equal(/* HTML */ `
+      <div class="view-diff-container">
+        <a href="">
+          <gr-button
+            aria-disabled="false"
+            class="view-diff-button"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            View Diff
+          </gr-button>
+        </a>
+      </div>
+    `);
+  });
+
+  suite('action button clicks', () => {
+    let savePromise: MockPromise<DraftInfo>;
+    let stub: SinonStub;
+
+    setup(async () => {
+      savePromise = mockPromise<DraftInfo>();
+      stub = sinon
+        .stub(element.getCommentsModel(), 'saveDraft')
+        .returns(savePromise);
+
+      element.thread = createThread(c1, {...c2, unresolved: true});
+      await element.updateComplete;
+    });
+
+    test('handle Ack', async () => {
+      tap(queryAndAssert(element, '#ackBtn'));
+      waitUntilCalled(stub, 'saveDraft()');
+      assert.equal(stub.lastCall.firstArg.message, 'Ack');
+      assert.equal(stub.lastCall.firstArg.unresolved, false);
+      assert.isTrue(element.saving);
+
+      savePromise.resolve();
+      await element.updateComplete;
+      assert.isFalse(element.saving);
+    });
+
+    test('handle Done', async () => {
+      tap(queryAndAssert(element, '#doneBtn'));
+      waitUntilCalled(stub, 'saveDraft()');
+      assert.equal(stub.lastCall.firstArg.message, 'Done');
+      assert.equal(stub.lastCall.firstArg.unresolved, false);
+    });
+
+    test('handle Reply', async () => {
+      assert.isUndefined(element.unsavedComment);
+      tap(queryAndAssert(element, '#replyBtn'));
+      assert.equal(element.unsavedComment?.message, '');
+    });
+
+    test('handle Quote', async () => {
+      assert.isUndefined(element.unsavedComment);
+      tap(queryAndAssert(element, '#quoteBtn'));
+      assert.equal(element.unsavedComment?.message?.trim(), `> ${c2.message}`);
+    });
+  });
+
+  suite('self removal when empty thread changed to editing:false', () => {
+    let threadEl: GrCommentThread;
+
+    setup(async () => {
+      threadEl = basicFixture.instantiate();
+      threadEl.thread = createThread();
+    });
+
+    test('new thread el normally has a parent and an unsaved comment', async () => {
+      await waitUntil(() => threadEl.editing);
+      assert.isOk(threadEl.unsavedComment);
+      assert.isOk(threadEl.parentElement);
+    });
+
+    test('thread el removed after clicking CANCEL', async () => {
+      await waitUntil(() => threadEl.editing);
+
+      const commentEl = queryAndAssert(threadEl, 'gr-comment');
+      const buttonEl = queryAndAssert(commentEl, 'gr-button.cancel');
+      tap(buttonEl);
+
+      await waitUntil(() => !threadEl.editing);
+      assert.isNotOk(threadEl.parentElement);
+    });
+  });
+
+  test('comments are sorted correctly', () => {
+    const comments: CommentInfo[] = [
       {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: 'baf0414d_60047215' as UrlEncodedCommentId,
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-        path: '/path/to/file.txt',
-        unresolved: false,
+        id: 'jacks_confession' as UrlEncodedCommentId,
+        in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, too',
+        updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, jack',
+        updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'i’m running away',
+        updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_defiance' as UrlEncodedCommentId,
+        in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'i will poison you so i can get away',
+        updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+        in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'no i will pull a thread and your arm will fall off',
+        updated: '2015-10-31 11:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_mission' as UrlEncodedCommentId,
+        message: 'i have to find santa',
+        updated: '2015-12-24 15:00:20.396000000' as Timestamp,
       },
     ];
-    flush();
-  });
-
-  test('ack and done should be hidden', () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
-    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
-    assert.equal(ackBtn, null);
-    assert.equal(doneBtn, null);
-  });
-
-  test('reply and quote button should be visible', () => {
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const replyBtn = element.shadowRoot?.querySelector('#replyBtn');
-    const quoteBtn = element.shadowRoot?.querySelector('#quoteBtn');
-    assert.ok(replyBtn);
-    assert.ok(quoteBtn);
+    const results = sortComments(comments);
+    assert.deepEqual(results, [
+      {
+        id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'i’m running away',
+        updated: '2015-10-31 09:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'dr_finklesteins_response' as UrlEncodedCommentId,
+        in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'no i will pull a thread and your arm will fall off',
+        updated: '2015-10-31 11:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_defiance' as UrlEncodedCommentId,
+        in_reply_to: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
+        message: 'i will poison you so i can get away',
+        updated: '2015-10-31 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, jack',
+        updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'sallys_mission' as UrlEncodedCommentId,
+        message: 'i have to find santa',
+        updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+      },
+      {
+        id: 'jacks_confession' as UrlEncodedCommentId,
+        in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, too',
+        updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+      },
+    ]);
   });
 });
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 154a045..c460ad7 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -27,85 +27,72 @@
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import '../gr-account-label/gr-account-label';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment_html';
-import {getRootElement} from '../../../scripts/rootElement';
-import {appContext} from '../../../services/app-context';
-import {customElement, observe, property} from '@polymer/decorators';
+import {getAppContext} from '../../../services/app-context';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {resolve} from '../../../models/dependency';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GrTextarea} from '../gr-textarea/gr-textarea';
 import {GrOverlay} from '../gr-overlay/gr-overlay';
 import {
   AccountDetailInfo,
+  CommentLinks,
   NumericChangeId,
-  ConfigInfo,
-  PatchSetNum,
   RepoName,
-  BasePatchSetNum,
+  RobotCommentInfo,
 } from '../../../types/common';
-import {GrButton} from '../gr-button/gr-button';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {
-  isDraft,
+  Comment,
+  DraftInfo,
+  isDraftOrUnsaved,
   isRobot,
-  UIComment,
-  UIDraft,
-  UIRobot,
+  isUnsaved,
 } from '../../../utils/comment-util';
-import {OpenFixPreviewEventDetail} from '../../../types/events';
-import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
-import {pluralize} from '../../../utils/string-util';
+import {
+  OpenFixPreviewEventDetail,
+  ValueChangedEvent,
+} from '../../../types/events';
+import {fire, fireEvent} from '../../../utils/event-util';
 import {assertIsDefined} from '../../../utils/common-util';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {StorageLocation} from '../../../services/storage/gr-storage';
-import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
+import {Key, Modifier} from '../../../utils/dom-util';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {classMap} from 'lit/directives/class-map';
+import {LineNumber} from '../../../api/diff';
+import {CommentSide} from '../../../constants/constants';
+import {getRandomInt} from '../../../utils/math-util';
+import {Subject} from 'rxjs';
+import {debounceTime} from 'rxjs/operators';
+import {configModelToken} from '../../../models/config/config-model';
+import {changeModelToken} from '../../../models/change/change-model';
 
-const STORAGE_DEBOUNCE_INTERVAL = 400;
-const TOAST_DEBOUNCE_INTERVAL = 200;
-
-const SAVED_MESSAGE = 'All changes saved';
 const UNSAVED_MESSAGE = 'Unable to save draft';
 
-const REPORT_CREATE_DRAFT = 'CreateDraftComment';
-const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
-const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
-
 const FILE = 'FILE';
 
+// visible for testing
+export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
+
 export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
 
-/**
- * All candidates tips to show, will pick randomly.
- */
-const RESPECTFUL_REVIEW_TIPS = [
-  'Assume competence.',
-  'Provide rationale or context.',
-  'Consider how comments may be interpreted.',
-  'Avoid harsh language.',
-  'Make your comments specific and actionable.',
-  'When disagreeing, explain the advantage of your approach.',
-];
-
-interface CommentOverlays {
-  confirmDelete?: GrOverlay | null;
-  confirmDiscard?: GrOverlay | null;
+declare global {
+  interface HTMLElementEventMap {
+    'comment-editing-changed': CustomEvent<boolean>;
+    'comment-unresolved-changed': CustomEvent<boolean>;
+    'comment-anchor-tap': CustomEvent<CommentAnchorTapEventDetail>;
+  }
 }
 
-export interface GrComment {
-  $: {
-    container: HTMLDivElement;
-    resolvedCheckbox: HTMLInputElement;
-    header: HTMLDivElement;
-  };
+export interface CommentAnchorTapEventDetail {
+  number: LineNumber;
+  side?: CommentSide;
 }
 
 @customElement('gr-comment')
-export class GrComment extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrComment extends LitElement {
   /**
    * Fired when the create fix comment action is triggered.
    *
@@ -119,30 +106,6 @@
    */
 
   /**
-   * Fired when this comment is discarded.
-   *
-   * @event comment-discard
-   */
-
-  /**
-   * Fired when this comment is edited.
-   *
-   * @event comment-edit
-   */
-
-  /**
-   * Fired when this comment is saved.
-   *
-   * @event comment-save
-   */
-
-  /**
-   * Fired when this comment is updated.
-   *
-   * @event comment-update
-   */
-
-  /**
    * Fired when editing status changed.
    *
    * @event comment-editing-changed
@@ -154,192 +117,743 @@
    * @event comment-anchor-tap
    */
 
-  @property({type: Number})
-  changeNum?: NumericChangeId;
+  @query('#editTextarea')
+  textarea?: GrTextarea;
 
-  @property({type: String})
-  projectName?: RepoName;
+  @query('#container')
+  container?: HTMLElement;
 
-  @property({type: Object, notify: true, observer: '_commentChanged'})
-  comment?: UIComment;
+  @query('#resolvedCheckbox')
+  resolvedCheckbox?: HTMLInputElement;
 
+  @query('#confirmDeleteOverlay')
+  confirmDeleteOverlay?: GrOverlay;
+
+  @property({type: Object})
+  comment?: Comment;
+
+  // TODO: Move this out of gr-comment. gr-comment should not have a comments
+  // property. This is only used for hasHumanReply at the moment.
   @property({type: Array})
-  comments?: UIComment[];
-
-  @property({type: Boolean, reflectToAttribute: true})
-  isRobotComment = false;
-
-  @property({type: Boolean, reflectToAttribute: true})
-  disabled = false;
-
-  @property({type: Boolean, observer: '_draftChanged'})
-  draft = false;
-
-  @property({type: Boolean, observer: '_editingChanged'})
-  editing = false;
-
-  // Assigns a css property to the comment hiding the comment while it's being
-  // discarded
-  @property({
-    type: Boolean,
-    reflectToAttribute: true,
-  })
-  discarding = false;
-
-  @property({type: Boolean})
-  hasChildren?: boolean;
-
-  @property({type: String})
-  patchNum?: PatchSetNum;
-
-  @property({type: Boolean})
-  showActions?: boolean;
-
-  @property({type: Boolean})
-  _showHumanActions?: boolean;
-
-  @property({type: Boolean})
-  _showRobotActions?: boolean;
-
-  @property({
-    type: Boolean,
-    reflectToAttribute: true,
-    observer: '_toggleCollapseClass',
-  })
-  collapsed = true;
-
-  @property({type: Object})
-  projectConfig?: ConfigInfo;
-
-  @property({type: Boolean})
-  robotButtonDisabled = false;
-
-  @property({type: Boolean})
-  _hasHumanReply?: boolean;
-
-  @property({type: Boolean})
-  _isAdmin = false;
-
-  @property({type: Object})
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  _xhrPromise?: Promise<any>; // Used for testing.
-
-  @property({type: String, observer: '_messageTextChanged'})
-  _messageText = '';
-
-  @property({type: String})
-  side?: string;
-
-  @property({type: Boolean})
-  resolved = false;
-
-  // Intentional to share the object across instances.
-  @property({type: Object})
-  _numPendingDraftRequests: {number: number} = {number: 0};
-
-  @property({type: Boolean})
-  _enableOverlay = false;
+  comments?: Comment[];
 
   /**
-   * Property for storing references to overlay elements. When the overlays
-   * are moved to getRootElement() to be shown they are no-longer
-   * children, so they can't be queried along the tree, so they are stored
-   * here.
+   * Initial collapsed state of the comment.
    */
-  @property({type: Object})
-  _overlays: CommentOverlays = {};
+  @property({type: Boolean, attribute: 'initially-collapsed'})
+  initiallyCollapsed?: boolean;
+
+  /**
+   * This is the *current* (internal) collapsed state of the comment. Do not set
+   * from the outside. Use `initiallyCollapsed` instead. This is just a
+   * reflected property such that css rules can be based on it.
+   */
+  @property({type: Boolean, reflect: true})
+  collapsed?: boolean;
+
+  @property({type: Boolean, attribute: 'robot-button-disabled'})
+  robotButtonDisabled = false;
+
+  /* private, but used in css rules */
+  @property({type: Boolean, reflect: true})
+  saving = false;
+
+  /**
+   * `saving` and `autoSaving` are separate and cannot be set at the same time.
+   * `saving` affects the UI state (disabled buttons, etc.) and eventually
+   * leaves editing mode, but `autoSaving` just happens in the background
+   * without the user noticing.
+   */
+  @state()
+  autoSaving?: Promise<DraftInfo>;
+
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  editing = false;
+
+  @state()
+  commentLinks: CommentLinks = {};
+
+  @state()
+  repoName?: RepoName;
+
+  /* The 'dirty' state of the comment.message, which will be saved on demand. */
+  @state()
+  messageText = '';
+
+  /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
+  @state()
+  unresolved = true;
 
   @property({type: Boolean})
-  _showRespectfulTip = false;
+  showConfirmDeleteOverlay = false;
 
   @property({type: Boolean})
-  showPatchset = true;
+  unableToSave = false;
 
-  @property({type: String})
-  _respectfulReviewTip?: string;
+  @property({type: Boolean, attribute: 'show-patchset'})
+  showPatchset = false;
 
-  @property({type: Boolean})
-  _respectfulTipDismissed = false;
-
-  @property({type: Boolean})
-  _unableToSave = false;
-
-  @property({type: Object})
-  _selfAccount?: AccountDetailInfo;
-
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-ported-comment'})
   showPortedComment = false;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  @state()
+  account?: AccountDetailInfo;
 
-  private readonly restApiService = appContext.restApiService;
+  @state()
+  isAdmin = false;
 
-  private readonly storage = appContext.storageService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
-  private readonly commentsService = appContext.commentsService;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private fireUpdateTask?: DelayedTask;
+  // Private but used in tests.
+  readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  private storeTask?: DelayedTask;
+  private readonly userModel = getAppContext().userModel;
 
-  private draftToastTask?: DelayedTask;
+  private readonly configModel = resolve(this, configModelToken);
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getAccount().then(account => {
-      this._selfAccount = account;
-    });
-    if (this.editing) {
-      this.collapsed = false;
-    } else if (this.comment) {
-      this.collapsed = !!this.comment.collapsed;
-    }
-    this._getIsAdmin().then(isAdmin => {
-      this._isAdmin = !!isAdmin;
-    });
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, e => this._handleEsc(e))
-    );
+  private readonly shortcuts = new ShortcutController(this);
+
+  /**
+   * This is triggered when the user types into the editing textarea. We then
+   * debounce it and call autoSave().
+   */
+  private autoSaveTrigger$ = new Subject();
+
+  /**
+   * Set to the content of DraftInfo when entering editing mode.
+   * Only used for "Cancel".
+   */
+  private originalMessage = '';
+
+  /**
+   * Set to the content of DraftInfo when entering editing mode.
+   * Only used for "Cancel".
+   */
+  private originalUnresolved = false;
+
+  constructor() {
+    super();
+    this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc());
     for (const key of ['s', Key.ENTER]) {
       for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
-        addShortcut(this, {key, modifiers: [modifier]}, e =>
-          this._handleSaveKey(e)
-        );
+        this.shortcuts.addLocal({key, modifiers: [modifier]}, () => {
+          this.save();
+        });
       }
     }
   }
 
+  override connectedCallback() {
+    super.connectedCallback();
+    subscribe(
+      this,
+      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$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
+      () => {
+        this.autoSave();
+      }
+    );
+  }
+
   override disconnectedCallback() {
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
-    this.fireUpdateTask?.cancel();
-    this.storeTask?.cancel();
-    this.draftToastTask?.cancel();
-    if (this.textarea) {
-      this.textarea.closeDropdown();
-    }
+    // Clean up emoji dropdown.
+    if (this.textarea) this.textarea.closeDropdown();
     super.disconnectedCallback();
   }
 
-  /** 2nd argument is for *triggering* the computation only. */
-  _getAuthor(comment?: UIComment, _?: unknown) {
-    return comment?.author || this._selfAccount;
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          font-family: var(--font-family);
+          padding: var(--spacing-m);
+        }
+        :host([collapsed]) {
+          padding: var(--spacing-s) var(--spacing-m);
+        }
+        :host([saving]) {
+          pointer-events: none;
+        }
+        :host([saving]) .actions,
+        :host([saving]) .robotActions,
+        :host([saving]) .date {
+          opacity: 0.5;
+        }
+        .body {
+          padding-top: var(--spacing-m);
+        }
+        .header {
+          align-items: center;
+          cursor: pointer;
+          display: flex;
+        }
+        .headerLeft > span {
+          font-weight: var(--font-weight-bold);
+        }
+        .headerMiddle {
+          color: var(--deemphasized-text-color);
+          flex: 1;
+          overflow: hidden;
+        }
+        .draftLabel,
+        .draftTooltip {
+          color: var(--deemphasized-text-color);
+          display: inline;
+        }
+        .date {
+          justify-content: flex-end;
+          text-align: right;
+          white-space: nowrap;
+        }
+        span.date {
+          color: var(--deemphasized-text-color);
+        }
+        span.date:hover {
+          text-decoration: underline;
+        }
+        .actions,
+        .robotActions {
+          display: flex;
+          justify-content: flex-end;
+          padding-top: 0;
+        }
+        .robotActions {
+          /* Better than the negative margin would be to remove the gr-button
+       * padding, but then we would also need to fix the buttons that are
+       * inserted by plugins. :-/ */
+          margin: 4px 0 -4px;
+        }
+        .action {
+          margin-left: var(--spacing-l);
+        }
+        .rightActions {
+          display: flex;
+          justify-content: flex-end;
+        }
+        .rightActions gr-button {
+          --gr-button-padding: 0 var(--spacing-s);
+        }
+        .editMessage {
+          display: block;
+          margin: var(--spacing-m) 0;
+          width: 100%;
+        }
+        .show-hide {
+          margin-left: var(--spacing-s);
+        }
+        .robotId {
+          color: var(--deemphasized-text-color);
+          margin-bottom: var(--spacing-m);
+        }
+        .robotRun {
+          margin-left: var(--spacing-m);
+        }
+        .robotRunLink {
+          margin-left: var(--spacing-m);
+        }
+        /* just for a11y */
+        input.show-hide {
+          display: none;
+        }
+        label.show-hide {
+          cursor: pointer;
+          display: block;
+        }
+        label.show-hide iron-icon {
+          vertical-align: top;
+        }
+        :host([collapsed]) #container .body {
+          padding-top: 0;
+        }
+        #container .collapsedContent {
+          display: block;
+          overflow: hidden;
+          padding-left: var(--spacing-m);
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .resolve,
+        .unresolved {
+          align-items: center;
+          display: flex;
+          flex: 1;
+          margin: 0;
+        }
+        .resolve label {
+          color: var(--comment-text-color);
+        }
+        gr-dialog .main {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+        }
+        #deleteBtn {
+          --gr-button-text-color: var(--deemphasized-text-color);
+          --gr-button-padding: 0;
+        }
+
+        /** Disable select for the caret and actions */
+        .actions,
+        .show-hide {
+          -webkit-user-select: none;
+          -moz-user-select: none;
+          -ms-user-select: none;
+          user-select: none;
+        }
+
+        .pointer {
+          cursor: pointer;
+        }
+        .patchset-text {
+          color: var(--deemphasized-text-color);
+          margin-left: var(--spacing-s);
+        }
+        .headerLeft gr-account-label {
+          --account-max-length: 130px;
+          width: 150px;
+        }
+        .headerLeft gr-account-label::part(gr-account-label-text) {
+          font-weight: var(--font-weight-bold);
+        }
+        .draft gr-account-label {
+          width: unset;
+        }
+        .portedMessage {
+          margin: 0 var(--spacing-m);
+        }
+        .link-icon {
+          cursor: pointer;
+        }
+      `,
+    ];
   }
 
-  _getUrlForComment(comment?: UIComment) {
-    if (!comment || !this.changeNum || !this.projectName) return '';
+  override render() {
+    if (isUnsaved(this.comment) && !this.editing) return;
+    const classes = {container: true, draft: isDraftOrUnsaved(this.comment)};
+    return html`
+      <div id="container" class=${classMap(classes)}>
+        <div
+          class="header"
+          id="header"
+          @click=${() => (this.collapsed = !this.collapsed)}
+        >
+          <div class="headerLeft">
+            ${this.renderAuthor()} ${this.renderPortedCommentMessage()}
+            ${this.renderDraftLabel()}
+          </div>
+          <div class="headerMiddle">${this.renderCollapsedContent()}</div>
+          ${this.renderRunDetails()} ${this.renderDeleteButton()}
+          ${this.renderPatchset()} ${this.renderDate()} ${this.renderToggle()}
+        </div>
+        <div class="body">
+          ${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
+          ${this.renderCommentMessage()} ${this.renderHumanActions()}
+          ${this.renderRobotActions()}
+        </div>
+      </div>
+      ${this.renderConfirmDialog()}
+    `;
+  }
+
+  private renderAuthor() {
+    if (isRobot(this.comment)) {
+      const id = this.comment.robot_id;
+      return html`<span class="robotName">${id}</span>`;
+    }
+    const classes = {draft: isDraftOrUnsaved(this.comment)};
+    return html`
+      <gr-account-label
+        .account=${this.comment?.author ?? this.account}
+        class=${classMap(classes)}
+      >
+      </gr-account-label>
+    `;
+  }
+
+  private renderPortedCommentMessage() {
+    if (!this.showPortedComment) return;
+    if (!this.comment?.patch_set) return;
+    return html`
+      <a href=${this.getUrlForComment()}>
+        <span class="portedMessage" @click=${this.handlePortedMessageClick}>
+          From patchset ${this.comment?.patch_set}
+        </span>
+      </a>
+    `;
+  }
+
+  private renderDraftLabel() {
+    if (!isDraftOrUnsaved(this.comment)) return;
+    let label = 'DRAFT';
+    let tooltip =
+      'This draft is only visible to you. ' +
+      "To publish drafts, click the 'Reply' or 'Start review' button " +
+      "at the top of the change or press the 'a' key.";
+    if (this.unableToSave) {
+      label += ' (Failed to save)';
+      tooltip = 'Unable to save draft. Please try to save again.';
+    }
+    return html`
+      <gr-tooltip-content
+        class="draftTooltip"
+        has-tooltip
+        title=${tooltip}
+        max-width="20em"
+        show-icon
+      >
+        <span class="draftLabel">${label}</span>
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderCollapsedContent() {
+    if (!this.collapsed) return;
+    return html`
+      <span class="collapsedContent">${this.comment?.message}</span>
+    `;
+  }
+
+  private renderRunDetails() {
+    if (!isRobot(this.comment)) return;
+    if (!this.comment?.url || this.collapsed) return;
+    return html`
+      <div class="runIdMessage message">
+        <div class="runIdInformation">
+          <a class="robotRunLink" href=${this.comment.url}>
+            <span class="robotRun link">Run Details</span>
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  /**
+   * Deleting a comment is an admin feature. It means more than just discarding
+   * a draft. It is an action applied to published comments.
+   */
+  private renderDeleteButton() {
+    if (
+      !this.isAdmin ||
+      isDraftOrUnsaved(this.comment) ||
+      isRobot(this.comment)
+    )
+      return;
+    if (this.collapsed) return;
+    return html`
+      <gr-button
+        id="deleteBtn"
+        title="Delete Comment"
+        link
+        class="action delete"
+        @click=${this.openDeleteCommentOverlay}
+      >
+        <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
+      </gr-button>
+    `;
+  }
+
+  private renderPatchset() {
+    if (!this.showPatchset) return;
+    assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
+    return html`
+      <span class="patchset-text"> Patchset ${this.comment.patch_set}</span>
+    `;
+  }
+
+  private renderDate() {
+    if (!this.comment?.updated || this.collapsed) return;
+    return html`
+      <span class="separator"></span>
+      <span class="date" tabindex="0" @click=${this.handleAnchorClick}>
+        <gr-date-formatter
+          withTooltip
+          .dateStr=${this.comment.updated}
+        ></gr-date-formatter>
+      </span>
+    `;
+  }
+
+  private renderToggle() {
+    const icon = this.collapsed
+      ? 'gr-icons:expand-more'
+      : 'gr-icons:expand-less';
+    const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
+    return html`
+      <div class="show-hide" tabindex="0">
+        <label class="show-hide" aria-label=${ariaLabel}>
+          <input
+            type="checkbox"
+            class="show-hide"
+            ?checked=${this.collapsed}
+            @change=${() => (this.collapsed = !this.collapsed)}
+          />
+          <iron-icon id="icon" icon=${icon}></iron-icon>
+        </label>
+      </div>
+    `;
+  }
+
+  private renderRobotAuthor() {
+    if (!isRobot(this.comment) || this.collapsed) return;
+    return html`<div class="robotId">${this.comment.author?.name}</div>`;
+  }
+
+  private renderEditingTextarea() {
+    if (!this.editing || this.collapsed) return;
+    return html`
+      <gr-textarea
+        id="editTextarea"
+        class="editMessage"
+        autocomplete="on"
+        code=""
+        ?disabled=${this.saving}
+        rows="4"
+        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>
+    `;
+  }
+
+  private renderCommentMessage() {
+    if (this.collapsed || this.editing) return;
+    return html`
+      <!--The "message" class is needed to ensure selectability from
+          gr-diff-selection.-->
+      <gr-formatted-text
+        class="message"
+        .content=${this.comment?.message}
+        .config=${this.commentLinks}
+        ?noTrailingMargin=${!isDraftOrUnsaved(this.comment)}
+      ></gr-formatted-text>
+    `;
+  }
+
+  private renderCopyLinkIcon() {
+    // Only show the icon when the thread contains a published comment.
+    if (!this.comment?.in_reply_to && isDraftOrUnsaved(this.comment)) return;
+    return html`
+      <iron-icon
+        class="copy link-icon"
+        @click=${this.handleCopyLink}
+        title="Copy link to this comment"
+        icon="gr-icons:link"
+        role="button"
+        tabindex="0"
+      >
+      </iron-icon>
+    `;
+  }
+
+  private renderHumanActions() {
+    if (!this.account || isRobot(this.comment)) return;
+    if (this.collapsed || !isDraftOrUnsaved(this.comment)) return;
+    return html`
+      <div class="actions">
+        <div class="action resolve">
+          <label>
+            <input
+              type="checkbox"
+              id="resolvedCheckbox"
+              ?checked=${!this.unresolved}
+              @change=${this.handleToggleResolved}
+            />
+            Resolved
+          </label>
+        </div>
+        ${this.renderDraftActions()}
+      </div>
+    `;
+  }
+
+  private renderDraftActions() {
+    if (!isDraftOrUnsaved(this.comment)) return;
+    return html`
+      <div class="rightActions">
+        ${this.autoSaving ? html`.&nbsp;&nbsp;` : ''}
+        ${this.renderCopyLinkIcon()} ${this.renderDiscardButton()}
+        ${this.renderEditButton()} ${this.renderCancelButton()}
+        ${this.renderSaveButton()}
+      </div>
+    `;
+  }
+
+  private renderDiscardButton() {
+    if (this.editing) return;
+    return html`<gr-button
+      link
+      ?disabled=${this.saving}
+      class="action discard"
+      @click=${this.discard}
+      >Discard</gr-button
+    >`;
+  }
+
+  private renderEditButton() {
+    if (this.editing) return;
+    return html`<gr-button
+      link
+      ?disabled=${this.saving}
+      class="action edit"
+      @click=${this.edit}
+      >Edit</gr-button
+    >`;
+  }
+
+  private renderCancelButton() {
+    if (!this.editing) return;
+    return html`
+      <gr-button
+        link
+        ?disabled=${this.saving}
+        class="action cancel"
+        @click=${this.cancel}
+        >Cancel</gr-button
+      >
+    `;
+  }
+
+  private renderSaveButton() {
+    if (!this.editing && !this.unableToSave) return;
+    return html`
+      <gr-button
+        link
+        ?disabled=${this.isSaveDisabled()}
+        class="action save"
+        @click=${this.save}
+        >Save</gr-button
+      >
+    `;
+  }
+
+  private renderRobotActions() {
+    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>
+      </gr-endpoint-decorator>
+    `;
+    return html`
+      <div class="robotActions">
+        ${this.renderCopyLinkIcon()} ${endpoint} ${this.renderShowFixButton()}
+        ${this.renderPleaseFixButton()}
+      </div>
+    `;
+  }
+
+  private renderShowFixButton() {
+    if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
+    return html`
+      <gr-button
+        link
+        secondary
+        class="action show-fix"
+        ?disabled=${this.saving}
+        @click=${this.handleShowFix}
+      >
+        Show Fix
+      </gr-button>
+    `;
+  }
+
+  private renderPleaseFixButton() {
+    if (this.hasHumanReply()) return;
+    return html`
+      <gr-button
+        link
+        ?disabled=${this.robotButtonDisabled}
+        class="action fix"
+        @click=${this.handleFix}
+      >
+        Please Fix
+      </gr-button>
+    `;
+  }
+
+  private renderConfirmDialog() {
+    if (!this.showConfirmDeleteOverlay) return;
+    return html`
+      <gr-overlay id="confirmDeleteOverlay" with-backdrop>
+        <gr-confirm-delete-comment-dialog
+          id="confirmDeleteComment"
+          @confirm=${this.handleConfirmDeleteComment}
+          @cancel=${this.closeDeleteCommentOverlay}
+        >
+        </gr-confirm-delete-comment-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private getUrlForComment() {
+    const comment = this.comment;
+    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.projectName,
+      this.changeNum,
+      this.repoName,
       comment.id
     );
   }
 
-  _handlePortedMessageClick() {
+  private firstWillUpdateDone = false;
+
+  firstWillUpdate() {
+    if (this.firstWillUpdateDone) return;
+    this.firstWillUpdateDone = true;
+
+    assertIsDefined(this.comment, 'comment');
+    this.unresolved = this.comment.unresolved ?? true;
+    if (isUnsaved(this.comment)) this.editing = true;
+    if (isDraftOrUnsaved(this.comment)) {
+      this.collapsed = false;
+    } else {
+      this.collapsed = !!this.initiallyCollapsed;
+    }
+  }
+
+  override willUpdate(changed: PropertyValues) {
+    this.firstWillUpdate();
+    if (changed.has('editing')) {
+      this.onEditingChanged();
+    }
+    if (changed.has('unresolved')) {
+      // The <gr-comment-thread> component wants to change its color based on
+      // the (dirty) unresolved state, so let's notify it about changes.
+      fire(this, 'comment-unresolved-changed', this.unresolved);
+    }
+  }
+
+  private handlePortedMessageClick() {
     assertIsDefined(this.comment, 'comment');
     this.reporting.reportInteraction('navigate-to-original-comment', {
       line: this.comment.line,
@@ -347,740 +861,221 @@
     });
   }
 
-  @observe('editing')
-  _onEditingChange(editing?: boolean) {
-    this.dispatchEvent(
-      new CustomEvent('comment-editing-changed', {
-        detail: !!editing,
-        bubbles: true,
-        composed: true,
-      })
-    );
-    if (!editing) return;
-    // visibility based on cache this will make sure we only and always show
-    // a tip once every Math.max(a day, period between creating comments)
-    const cachedVisibilityOfRespectfulTip =
-      this.storage.getRespectfulTipVisibility();
-    if (!cachedVisibilityOfRespectfulTip) {
-      // we still want to show the tip with a probability of 30%
-      if (this.getRandomNum(0, 3) >= 1) return;
-      this._showRespectfulTip = true;
-      const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
-      this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
-      this.reporting.reportInteraction('respectful-tip-appeared', {
-        tip: this._respectfulReviewTip,
-      });
-      // update cache
-      this.storage.setRespectfulTipVisibility();
-    }
+  // private, but visible for testing
+  getRandomInt(from: number, to: number) {
+    return getRandomInt(from, to);
   }
 
-  /** Set as a separate method so easy to stub. */
-  getRandomNum(min: number, max: number) {
-    return Math.floor(Math.random() * (max - min) + min);
-  }
-
-  _computeVisibilityOfTip(showTip: boolean, tipDismissed: boolean) {
-    return showTip && !tipDismissed;
-  }
-
-  _dismissRespectfulTip() {
-    this._respectfulTipDismissed = true;
-    this.reporting.reportInteraction('respectful-tip-dismissed', {
-      tip: this._respectfulReviewTip,
-    });
-    // add a 14-day delay to the tip cache
-    this.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
-  }
-
-  _onRespectfulReadMoreClick() {
-    this.reporting.reportInteraction('respectful-read-more-clicked');
-  }
-
-  get textarea(): GrTextarea | null {
-    return this.shadowRoot?.querySelector('#editTextarea') as GrTextarea | null;
-  }
-
-  get confirmDeleteOverlay() {
-    if (!this._overlays.confirmDelete) {
-      this._enableOverlay = true;
-      flush();
-      this._overlays.confirmDelete = this.shadowRoot?.querySelector(
-        '#confirmDeleteOverlay'
-      ) as GrOverlay | null;
-    }
-    return this._overlays.confirmDelete;
-  }
-
-  get confirmDiscardOverlay() {
-    if (!this._overlays.confirmDiscard) {
-      this._enableOverlay = true;
-      flush();
-      this._overlays.confirmDiscard = this.shadowRoot?.querySelector(
-        '#confirmDiscardOverlay'
-      ) as GrOverlay | null;
-    }
-    return this._overlays.confirmDiscard;
-  }
-
-  _computeShowHideIcon(collapsed: boolean) {
-    return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
-  }
-
-  _computeShowHideAriaLabel(collapsed: boolean) {
-    return collapsed ? 'Expand' : 'Collapse';
-  }
-
-  @observe('showActions', 'isRobotComment')
-  _calculateActionstoShow(showActions?: boolean, isRobotComment?: boolean) {
-    // Polymer 2: check for undefined
-    if ([showActions, isRobotComment].includes(undefined)) {
-      return;
-    }
-
-    this._showHumanActions = showActions && !isRobotComment;
-    this._showRobotActions = showActions && isRobotComment;
-  }
-
-  hasPublishedComment(comments?: UIComment[]) {
-    if (!comments?.length) return false;
-    return comments.length > 1 || !isDraft(comments[0]);
-  }
-
-  @observe('comment')
-  _isRobotComment(comment: UIRobot) {
-    this.isRobotComment = !!comment.robot_id;
-  }
-
-  isOnParent() {
-    return this.side === 'PARENT';
-  }
-
-  _getIsAdmin() {
-    return this.restApiService.getIsAdmin();
-  }
-
-  _computeDraftTooltip(unableToSave: boolean) {
-    return unableToSave
-      ? 'Unable to save draft. Please try to save again.'
-      : "This draft is only visible to you. To publish drafts, click the 'Reply'" +
-          "or 'Start review' button at the top of the change or press the 'A' key.";
-  }
-
-  _computeDraftText(unableToSave: boolean) {
-    return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
-  }
-
-  handleCopyLink() {
+  private handleCopyLink() {
     fireEvent(this, 'copy-comment-link');
   }
 
-  save(opt_comment?: UIComment) {
-    let comment = opt_comment;
-    if (!comment) {
-      comment = this.comment;
+  /** Enter editing mode. */
+  private edit() {
+    if (!isDraftOrUnsaved(this.comment)) {
+      throw new Error('Cannot edit published comment.');
     }
-
-    this.set('comment.message', this._messageText);
-    this.editing = false;
-    this.disabled = true;
-
-    if (!this._messageText) {
-      return this._discardDraft();
-    }
-
-    this._xhrPromise = this._saveDraft(comment)
-      .then(response => {
-        this.disabled = false;
-        if (!response.ok) {
-          return;
-        }
-
-        this._eraseDraftCommentFromStorage();
-        return this.restApiService.getResponseObject(response).then(obj => {
-          const resComment = obj as unknown as UIDraft;
-          if (!isDraft(this.comment)) throw new Error('Can only save drafts.');
-          resComment.__draft = true;
-          // Maintain the ephemeral draft ID for identification by other
-          // elements.
-          if (this.comment?.__draftID) {
-            resComment.__draftID = this.comment.__draftID;
-          }
-          if (!resComment.patch_set) resComment.patch_set = this.patchNum;
-          this.comment = resComment;
-          this._fireSave();
-          return obj;
-        });
-      })
-      .catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-
-    return this._xhrPromise;
-  }
-
-  _eraseDraftCommentFromStorage() {
-    // Prevents a race condition in which removing the draft comment occurs
-    // prior to it being saved.
-    this.storeTask?.cancel();
-
-    assertIsDefined(this.comment?.path, 'comment.path');
-    assertIsDefined(this.changeNum, 'changeNum');
-    this.storage.eraseDraftComment({
-      changeNum: this.changeNum,
-      patchNum: this._getPatchNum(),
-      path: this.comment.path,
-      line: this.comment.line,
-      range: this.comment.range,
-    });
-  }
-
-  _commentChanged(comment: UIComment) {
-    this.editing = isDraft(comment) && !!comment.__editing;
-    this.resolved = !comment.unresolved;
-    this.discarding = false;
-    if (this.editing) {
-      // It's a new draft/reply, notify.
-      this._fireUpdate();
-    }
-  }
-
-  @observe('comment', 'comments.*')
-  _computeHasHumanReply() {
-    const comment = this.comment;
-    if (!comment || !this.comments) return;
-    // hide please fix button for robot comment that has human reply
-    this._hasHumanReply = this.comments.some(
-      c =>
-        c.in_reply_to &&
-        c.in_reply_to === comment.id &&
-        !(c as UIRobot).robot_id
-    );
-  }
-
-  _getEventPayload(): OpenFixPreviewEventDetail {
-    return {comment: this.comment, patchNum: this.patchNum};
-  }
-
-  _fireEdit() {
-    if (this.comment) this.commentsService.editDraft(this.comment);
-    this.dispatchEvent(
-      new CustomEvent('comment-edit', {
-        detail: this._getEventPayload(),
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
-  _fireSave() {
-    if (this.comment) this.commentsService.addDraft(this.comment);
-    this.dispatchEvent(
-      new CustomEvent('comment-save', {
-        detail: this._getEventPayload(),
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
-  _fireUpdate() {
-    this.fireUpdateTask = debounce(this.fireUpdateTask, () => {
-      this.dispatchEvent(
-        new CustomEvent('comment-update', {
-          detail: this._getEventPayload(),
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-  }
-
-  _computeAccountLabelClass(draft: boolean) {
-    return draft ? 'draft' : '';
-  }
-
-  _draftChanged(draft: boolean) {
-    this.$.container.classList.toggle('draft', draft);
-  }
-
-  _editingChanged(editing?: boolean, previousValue?: boolean) {
-    // Polymer 2: observer fires when at least one property is defined.
-    // Do nothing to prevent comment.__editing being overwritten
-    // if previousValue is undefined
-    if (previousValue === undefined) return;
-
-    this.$.container.classList.toggle('editing', editing);
-    if (this.comment && this.comment.id) {
-      const cancelButton = this.shadowRoot?.querySelector(
-        '.cancel'
-      ) as GrButton | null;
-      if (cancelButton) {
-        cancelButton.hidden = !editing;
-      }
-    }
-    if (isDraft(this.comment)) {
-      this.comment.__editing = this.editing;
-    }
-    if (!!editing !== !!previousValue) {
-      // To prevent event firing on comment creation.
-      this._fireUpdate();
-    }
-    if (editing) {
-      setTimeout(() => {
-        flush();
-        this.textarea && this.textarea.putCursorAtEnd();
-      }, 1);
-    }
-  }
-
-  _computeDeleteButtonClass(isAdmin: boolean, draft: boolean) {
-    return isAdmin && !draft ? 'showDeleteButtons' : '';
-  }
-
-  _computeSaveDisabled(
-    draft: string,
-    comment: UIComment | undefined,
-    resolved?: boolean
-  ) {
-    // If resolved state has changed and a msg exists, save should be enabled.
-    if (!comment || (comment.unresolved === resolved && draft)) {
-      return false;
-    }
-    return !draft || draft.trim() === '';
-  }
-
-  _handleSaveKey(e: Event) {
-    if (
-      !this._computeSaveDisabled(this._messageText, this.comment, this.resolved)
-    ) {
-      e.preventDefault();
-      this._handleSave(e);
-    }
-  }
-
-  _handleEsc(e: Event) {
-    if (!this._messageText.length) {
-      e.preventDefault();
-      this._handleCancel(e);
-    }
-  }
-
-  _handleToggleCollapsed() {
-    this.collapsed = !this.collapsed;
-  }
-
-  _toggleCollapseClass(collapsed: boolean) {
-    if (collapsed) {
-      this.$.container.classList.add('collapsed');
-    } else {
-      this.$.container.classList.remove('collapsed');
-    }
-  }
-
-  @observe('comment.message')
-  _commentMessageChanged(message: string) {
-    /*
-     * Only overwrite the message text user has typed if there is no existing
-     * text typed by the user. This prevents the bug where creating another
-     * comment triggered a recomputation of comments and the text written by
-     * the user was lost.
-     */
-    if (!this._messageText || !this.editing) this._messageText = message || '';
-  }
-
-  _messageTextChanged(_: string, oldValue: string) {
-    // Only store comments that are being edited in local storage.
-    if (
-      !this.comment ||
-      (this.comment.id && (!isDraft(this.comment) || !this.comment.__editing))
-    ) {
-      return;
-    }
-
-    const patchNum = this.comment.patch_set
-      ? this.comment.patch_set
-      : this._getPatchNum();
-    const {path, line, range} = this.comment;
-    if (!path) return;
-    this.storeTask = debounce(
-      this.storeTask,
-      () => {
-        const message = this._messageText;
-        if (this.changeNum === undefined) {
-          throw new Error('undefined changeNum');
-        }
-        const commentLocation: StorageLocation = {
-          changeNum: this.changeNum,
-          patchNum,
-          path,
-          line,
-          range,
-        };
-
-        if ((!message || !message.length) && oldValue) {
-          // If the draft has been modified to be empty, then erase the storage
-          // entry.
-          this.storage.eraseDraftComment(commentLocation);
-        } else {
-          this.storage.setDraftComment(commentLocation, message);
-        }
-      },
-      STORAGE_DEBOUNCE_INTERVAL
-    );
-  }
-
-  _handleAnchorClick(e: Event) {
-    e.preventDefault();
-    if (!this.comment) return;
-    this.dispatchEvent(
-      new CustomEvent('comment-anchor-tap', {
-        bubbles: true,
-        composed: true,
-        detail: {
-          number: this.comment.line || FILE,
-          side: this.side,
-        },
-      })
-    );
-  }
-
-  _handleEdit(e: Event) {
-    e.preventDefault();
-    if (this.comment?.message) this._messageText = this.comment.message;
+    if (this.editing) return;
     this.editing = true;
-    this._fireEdit();
-    this.reporting.recordDraftInteraction();
   }
 
-  _handleSave(e: Event) {
-    e.preventDefault();
+  // TODO: Move this out of gr-comment. gr-comment should not have a comments
+  // property.
+  private hasHumanReply() {
+    if (!this.comment || !this.comments) return false;
+    return this.comments.some(
+      c => c.in_reply_to && c.in_reply_to === this.comment?.id && !isRobot(c)
+    );
+  }
 
-    // Ignore saves started while already saving.
-    if (this.disabled) return;
-    const timingLabel = this.comment?.id
-      ? REPORT_UPDATE_DRAFT
-      : REPORT_CREATE_DRAFT;
-    const timer = this.reporting.getTimer(timingLabel);
-    this.set('comment.__editing', false);
-    return this.save().then(() => {
-      timer.end();
+  // private, but visible for testing
+  getEventPayload(): OpenFixPreviewEventDetail {
+    assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
+    return {comment: this.comment, patchNum: this.comment.patch_set};
+  }
+
+  private onEditingChanged() {
+    if (this.editing) {
+      this.collapsed = false;
+      this.messageText = this.comment?.message ?? '';
+      this.unresolved = this.comment?.unresolved ?? true;
+      this.originalMessage = this.messageText;
+      this.originalUnresolved = this.unresolved;
+      setTimeout(() => this.textarea?.putCursorAtEnd(), 1);
+    }
+
+    // Parent components such as the reply dialog might be interested in whether
+    // come of their child components are in editing mode.
+    fire(this, 'comment-editing-changed', this.editing);
+  }
+
+  // private, but visible for testing
+  isSaveDisabled() {
+    assertIsDefined(this.comment, 'comment');
+    if (this.saving) return true;
+    if (this.comment.unresolved !== this.unresolved) return false;
+    return !this.messageText?.trimEnd();
+  }
+
+  private handleEsc() {
+    // vim users don't like ESC to cancel/discard, so only do this when the
+    // comment text is empty.
+    if (!this.messageText?.trimEnd()) this.cancel();
+  }
+
+  private handleAnchorClick() {
+    assertIsDefined(this.comment, 'comment');
+    fire(this, 'comment-anchor-tap', {
+      number: this.comment.line || FILE,
+      side: this.comment?.side,
     });
   }
 
-  _handleCancel(e: Event) {
-    e.preventDefault();
-    if (!this.comment) return;
-    if (!this.comment.id) {
-      // Ensures we update the discarded draft message before deleting the draft
-      this.set('comment.message', this._messageText);
-      this._fireDiscard();
-    } else {
-      this.set('comment.__editing', false);
-      this.commentsService.cancelDraft(this.comment);
+  private handleFix() {
+    // Handled by <gr-comment-thread>.
+    fire(this, 'create-fix-comment', this.getEventPayload());
+  }
+
+  private handleShowFix() {
+    // Handled top-level in the diff and change view components.
+    fire(this, 'open-fix-preview', this.getEventPayload());
+  }
+
+  // private, but visible for testing
+  cancel() {
+    assertIsDefined(this.comment, 'comment');
+    if (!isDraftOrUnsaved(this.comment)) {
+      throw new Error('only unsaved and draft comments are editable');
+    }
+    this.messageText = this.originalMessage;
+    this.unresolved = this.originalUnresolved;
+    this.save();
+  }
+
+  async autoSave() {
+    if (this.saving || this.autoSaving) return;
+    if (!this.editing || !this.comment) return;
+    if (!isDraftOrUnsaved(this.comment)) return;
+    const messageToSave = this.messageText.trimEnd();
+    if (messageToSave === '') return;
+    if (messageToSave === this.comment.message) return;
+
+    try {
+      this.autoSaving = this.rawSave(messageToSave, {showToast: false});
+      await this.autoSaving;
+    } finally {
+      this.autoSaving = undefined;
+    }
+  }
+
+  async discard() {
+    this.messageText = '';
+    await this.save();
+  }
+
+  async save() {
+    if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
+
+    try {
+      this.saving = true;
+      this.unableToSave = false;
+      if (this.autoSaving) {
+        this.comment = await this.autoSaving;
+      }
+      // Depending on whether `messageToSave` is empty we treat this either as
+      // a discard or a save action.
+      const messageToSave = this.messageText.trimEnd();
+      if (messageToSave === '') {
+        // Don't try to discard UnsavedInfo. Nothing to do then.
+        if (this.comment.id) {
+          await this.getCommentsModel().discardDraft(this.comment.id);
+        }
+      } else {
+        // No need to make a backend call when nothing has changed.
+        if (
+          messageToSave !== this.comment?.message ||
+          this.unresolved !== this.comment.unresolved
+        ) {
+          await this.rawSave(messageToSave, {showToast: true});
+        }
+      }
       this.editing = false;
+    } catch (e) {
+      this.unableToSave = true;
+      throw e;
+    } finally {
+      this.saving = false;
     }
   }
 
-  _fireDiscard() {
-    if (this.comment) this.commentsService.deleteDraft(this.comment);
-    this.fireUpdateTask?.cancel();
-    this.dispatchEvent(
-      new CustomEvent('comment-discard', {
-        detail: this._getEventPayload(),
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
-  _handleFix() {
-    this.dispatchEvent(
-      new CustomEvent('create-fix-comment', {
-        bubbles: true,
-        composed: true,
-        detail: this._getEventPayload(),
-      })
-    );
-  }
-
-  _handleShowFix() {
-    this.dispatchEvent(
-      new CustomEvent('open-fix-preview', {
-        bubbles: true,
-        composed: true,
-        detail: this._getEventPayload(),
-      })
-    );
-  }
-
-  _hasNoFix(comment?: UIComment) {
-    return !comment || !(comment as UIRobot).fix_suggestions;
-  }
-
-  _handleDiscard(e: Event) {
-    e.preventDefault();
-    this.reporting.recordDraftInteraction();
-
-    this._discardDraft();
-  }
-
-  _discardDraft() {
-    if (!this.comment) return Promise.reject(new Error('undefined comment'));
-    if (!isDraft(this.comment)) {
-      return Promise.reject(new Error('Cannot discard a non-draft comment.'));
-    }
-    this.discarding = true;
-    const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
-    this.editing = false;
-    this.disabled = true;
-    this._eraseDraftCommentFromStorage();
-
-    if (!this.comment.id) {
-      this.disabled = false;
-      this._fireDiscard();
-      return Promise.resolve();
-    }
-
-    this._xhrPromise = this._deleteDraft(this.comment)
-      .then(response => {
-        this.disabled = false;
-        if (!response.ok) {
-          this.discarding = false;
-        }
-        timer.end();
-        this._fireDiscard();
-        return response;
-      })
-      .catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-
-    return this._xhrPromise;
-  }
-
-  _getSavingMessage(numPending: number, requestFailed?: boolean) {
-    if (requestFailed) {
-      return UNSAVED_MESSAGE;
-    }
-    if (numPending === 0) {
-      return SAVED_MESSAGE;
-    }
-    return `Saving ${pluralize(numPending, 'draft')}...`;
-  }
-
-  _showStartRequest() {
-    const numPending = ++this._numPendingDraftRequests.number;
-    this._updateRequestToast(numPending);
-  }
-
-  _showEndRequest() {
-    const numPending = --this._numPendingDraftRequests.number;
-    this._updateRequestToast(numPending);
-  }
-
-  _handleFailedDraftRequest() {
-    this._numPendingDraftRequests.number--;
-
-    // Cancel the debouncer so that error toasts from the error-manager will
-    // not be overridden.
-    this.draftToastTask?.cancel();
-    this._updateRequestToast(
-      this._numPendingDraftRequests.number,
-      /* requestFailed=*/ true
-    );
-  }
-
-  _updateRequestToast(numPending: number, requestFailed?: boolean) {
-    const message = this._getSavingMessage(numPending, requestFailed);
-    this.draftToastTask = debounce(
-      this.draftToastTask,
-      () => {
-        // Note: the event is fired on the body rather than this element because
-        // this element may not be attached by the time this executes, in which
-        // case the event would not bubble.
-        fireAlert(document.body, message);
+  /** For sharing between save() and autoSave(). */
+  private rawSave(message: string, options: {showToast: boolean}) {
+    if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
+    return this.getCommentsModel().saveDraft(
+      {
+        ...this.comment,
+        message,
+        unresolved: this.unresolved,
       },
-      TOAST_DEBOUNCE_INTERVAL
+      options.showToast
     );
   }
 
-  _handleDraftFailure() {
-    this.$.container.classList.add('unableToSave');
-    this._unableToSave = true;
-    this._handleFailedDraftRequest();
-  }
-
-  _saveDraft(draft?: UIComment) {
-    if (!draft || this.changeNum === undefined || this.patchNum === undefined) {
-      throw new Error('undefined draft or changeNum or patchNum');
-    }
-    this._showStartRequest();
-    return this.restApiService
-      .saveDiffDraft(this.changeNum, this.patchNum, draft)
-      .then(result => {
-        if (result.ok) {
-          // remove
-          this._unableToSave = false;
-          this.$.container.classList.remove('unableToSave');
-          this._showEndRequest();
-        } else {
-          this._handleDraftFailure();
-        }
-        return result;
-      })
-      .catch(err => {
-        this._handleDraftFailure();
-        throw err;
-      });
-  }
-
-  _deleteDraft(draft: UIComment) {
-    const changeNum = this.changeNum;
-    const patchNum = this.patchNum;
-    if (changeNum === undefined || patchNum === undefined) {
-      throw new Error('undefined changeNum or patchNum');
-    }
-    fireAlert(this, 'Discarding draft...');
-    const draftID = draft.id;
-    if (!draftID) throw new Error('Missing id in comment draft.');
-    return this.restApiService
-      .deleteDiffDraft(changeNum, patchNum, {id: draftID})
-      .then(result => {
-        if (result.ok) {
-          fire(this, 'show-alert', {
-            message: 'Draft Discarded',
-            action: 'Undo',
-            callback: () =>
-              this.commentsService.restoreDraft(changeNum, patchNum, draftID),
-          });
-        }
-        return result;
-      });
-  }
-
-  _getPatchNum(): PatchSetNum {
-    const patchNum = this.isOnParent()
-      ? ('PARENT' as BasePatchSetNum)
-      : this.patchNum;
-    if (patchNum === undefined) throw new Error('patchNum undefined');
-    return patchNum;
-  }
-
-  @observe('changeNum', 'patchNum', 'comment')
-  _loadLocalDraft(
-    changeNum: number,
-    patchNum?: PatchSetNum,
-    comment?: UIComment
-  ) {
-    // Polymer 2: check for undefined
-    if ([changeNum, patchNum, comment].includes(undefined)) {
-      return;
-    }
-
-    // Only apply local drafts to comments that are drafts and are currently
-    // being edited.
-    if (
-      !comment ||
-      !comment.path ||
-      comment.message ||
-      !isDraft(comment) ||
-      !comment.__editing
-    ) {
-      return;
-    }
-
-    const draft = this.storage.getDraftComment({
-      changeNum,
-      patchNum: this._getPatchNum(),
-      path: comment.path,
-      line: comment.line,
-      range: comment.range,
-    });
-
-    if (draft) {
-      this._messageText = draft.message || '';
-    }
-  }
-
-  _handleToggleResolved() {
-    this.reporting.recordDraftInteraction();
-    this.resolved = !this.resolved;
-    // Modify payload instead of this.comment, as this.comment is passed from
-    // the parent by ref.
-    const payload = this._getEventPayload();
-    if (!payload.comment) {
-      throw new Error('comment not defined in payload');
-    }
-    payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
-    this.dispatchEvent(
-      new CustomEvent('comment-update', {
-        detail: payload,
-        composed: true,
-        bubbles: true,
-      })
-    );
+  private handleToggleResolved() {
+    this.unresolved = !this.unresolved;
     if (!this.editing) {
-      // Save the resolved state immediately.
-      this.save(payload.comment);
+      // messageText is only assigned a value if the comment reaches editing
+      // state, however it is possible that the user toggles the resolved state
+      // without editing the comment in which case we assign the correct value
+      // to messageText here
+      this.messageText = this.comment?.message ?? '';
+      this.save();
     }
   }
 
-  _handleCommentDelete() {
-    this._openOverlay(this.confirmDeleteOverlay);
+  private async openDeleteCommentOverlay() {
+    this.showConfirmDeleteOverlay = true;
+    await this.updateComplete;
+    await this.confirmDeleteOverlay?.open();
   }
 
-  _handleCancelDeleteComment() {
-    this._closeOverlay(this.confirmDeleteOverlay);
+  private closeDeleteCommentOverlay() {
+    this.showConfirmDeleteOverlay = false;
+    this.confirmDeleteOverlay?.remove();
+    this.confirmDeleteOverlay?.close();
   }
 
-  _openOverlay(overlay?: GrOverlay | null) {
-    if (!overlay) {
-      return Promise.reject(new Error('undefined overlay'));
-    }
-    getRootElement().appendChild(overlay);
-    return overlay.open();
-  }
-
-  _computeHideRunDetails(comment: UIComment | undefined, collapsed: boolean) {
-    if (!comment) return true;
-    if (!isRobot(comment)) return true;
-    return !comment.url || collapsed;
-  }
-
-  _closeOverlay(overlay?: GrOverlay | null) {
-    if (overlay) {
-      getRootElement().removeChild(overlay);
-      overlay.close();
-    }
-  }
-
-  _handleConfirmDeleteComment() {
+  /**
+   * Deleting a *published* comment is an admin feature. It means more than just
+   * discarding a draft.
+   *
+   * TODO: Also move this into the comments-service.
+   * TODO: Figure out a good reloading strategy when deleting was successful.
+   *       `this.comment = newComment` does not seem sufficient.
+   */
+  // private, but visible for testing
+  handleConfirmDeleteComment() {
     const dialog = this.confirmDeleteOverlay?.querySelector(
       '#confirmDeleteComment'
     ) as GrConfirmDeleteCommentDialog | null;
     if (!dialog || !dialog.message) {
       throw new Error('missing confirm delete dialog');
     }
-    if (
-      !this.comment ||
-      !this.comment.id ||
-      this.changeNum === undefined ||
-      this.patchNum === undefined
-    ) {
-      throw new Error('undefined comment or id or changeNum or patchNum');
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.comment, 'comment');
+    assertIsDefined(this.comment.patch_set, 'comment.patch_set');
+    if (isDraftOrUnsaved(this.comment)) {
+      throw new Error('Admin deletion is only for published comments.');
     }
     this.restApiService
       .deleteComment(
         this.changeNum,
-        this.patchNum,
+        this.comment.patch_set,
         this.comment.id,
         dialog.message
       )
       .then(newComment => {
-        this._handleCancelDeleteComment();
+        this.closeDeleteCommentOverlay();
         this.comment = newComment;
       });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
deleted file mode 100644
index b77c4b2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ /dev/null
@@ -1,497 +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;
-      font-family: var(--font-family);
-      padding: var(--spacing-m);
-    }
-    :host([collapsed]) {
-      padding: var(--spacing-s) var(--spacing-m);
-    }
-    :host([disabled]) {
-      pointer-events: none;
-    }
-    :host([disabled]) .actions,
-    :host([disabled]) .robotActions,
-    :host([disabled]) .date {
-      opacity: 0.5;
-    }
-    :host([discarding]) {
-      display: none;
-    }
-    .body {
-      padding-top: var(--spacing-m);
-    }
-    .header {
-      align-items: center;
-      cursor: pointer;
-      display: flex;
-    }
-    .headerLeft > span {
-      font-weight: var(--font-weight-bold);
-    }
-    .headerMiddle {
-      color: var(--deemphasized-text-color);
-      flex: 1;
-      overflow: hidden;
-    }
-    .draftLabel,
-    .draftTooltip {
-      color: var(--deemphasized-text-color);
-      display: none;
-    }
-    .date {
-      justify-content: flex-end;
-      text-align: right;
-      white-space: nowrap;
-    }
-    span.date {
-      color: var(--deemphasized-text-color);
-    }
-    span.date:hover {
-      text-decoration: underline;
-    }
-    .actions,
-    .robotActions {
-      display: flex;
-      justify-content: flex-end;
-      padding-top: 0;
-    }
-    .robotActions {
-      /* Better than the negative margin would be to remove the gr-button
-       * padding, but then we would also need to fix the buttons that are
-       * inserted by plugins. :-/ */
-      margin: 4px 0 -4px;
-    }
-    .action {
-      margin-left: var(--spacing-l);
-    }
-    .rightActions {
-      display: flex;
-      justify-content: flex-end;
-    }
-    .rightActions gr-button {
-      --gr-button-padding: 0 var(--spacing-s);
-    }
-    .editMessage {
-      display: none;
-      margin: var(--spacing-m) 0;
-      width: 100%;
-    }
-    .container:not(.draft) .actions .hideOnPublished {
-      display: none;
-    }
-    .draft .reply,
-    .draft .quote,
-    .draft .ack,
-    .draft .done {
-      display: none;
-    }
-    .draft .draftLabel,
-    .draft .draftTooltip {
-      display: inline;
-    }
-    .draft:not(.editing):not(.unableToSave) .save,
-    .draft:not(.editing) .cancel {
-      display: none;
-    }
-    .editing .message,
-    .editing .reply,
-    .editing .quote,
-    .editing .ack,
-    .editing .done,
-    .editing .edit,
-    .editing .discard,
-    .editing .unresolved {
-      display: none;
-    }
-    .editing .editMessage {
-      display: block;
-    }
-    .show-hide {
-      margin-left: var(--spacing-s);
-    }
-    .robotId {
-      color: var(--deemphasized-text-color);
-      margin-bottom: var(--spacing-m);
-    }
-    .robotRun {
-      margin-left: var(--spacing-m);
-    }
-    .robotRunLink {
-      margin-left: var(--spacing-m);
-    }
-    input.show-hide {
-      display: none;
-    }
-    label.show-hide {
-      cursor: pointer;
-      display: block;
-    }
-    label.show-hide iron-icon {
-      vertical-align: top;
-    }
-    #container .collapsedContent {
-      display: none;
-    }
-    #container.collapsed .body {
-      padding-top: 0;
-    }
-    #container.collapsed .collapsedContent {
-      display: block;
-      overflow: hidden;
-      padding-left: var(--spacing-m);
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    #container.collapsed #deleteBtn,
-    #container.collapsed .date,
-    #container.collapsed .actions,
-    #container.collapsed gr-formatted-text,
-    #container.collapsed gr-textarea,
-    #container.collapsed .respectfulReviewTip {
-      display: none;
-    }
-    .resolve,
-    .unresolved {
-      align-items: center;
-      display: flex;
-      flex: 1;
-      margin: 0;
-    }
-    .resolve label {
-      color: var(--comment-text-color);
-    }
-    gr-dialog .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    #deleteBtn {
-      display: none;
-      --gr-button-text-color: var(--deemphasized-text-color);
-      --gr-button-padding: 0;
-    }
-    #deleteBtn.showDeleteButtons {
-      display: block;
-    }
-
-    /** Disable select for the caret and actions */
-    .actions,
-    .show-hide {
-      -webkit-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-      user-select: none;
-    }
-
-    .respectfulReviewTip {
-      justify-content: space-between;
-      display: flex;
-      padding: var(--spacing-m);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      margin-bottom: var(--spacing-m);
-    }
-    .respectfulReviewTip div {
-      display: flex;
-    }
-    .respectfulReviewTip div iron-icon {
-      margin-right: var(--spacing-s);
-    }
-    .respectfulReviewTip a {
-      white-space: nowrap;
-      margin-right: var(--spacing-s);
-      padding-left: var(--spacing-m);
-      text-decoration: none;
-    }
-    .pointer {
-      cursor: pointer;
-    }
-    .patchset-text {
-      color: var(--deemphasized-text-color);
-      margin-left: var(--spacing-s);
-    }
-    .headerLeft gr-account-label {
-      --account-max-length: 130px;
-      width: 150px;
-    }
-    .headerLeft gr-account-label::part(gr-account-label-text) {
-      font-weight: var(--font-weight-bold);
-    }
-    .draft gr-account-label {
-      width: unset;
-    }
-    .portedMessage {
-      margin: 0 var(--spacing-m);
-    }
-    .link-icon {
-      cursor: pointer;
-    }
-  </style>
-  <div id="container" class="container">
-    <div class="header" id="header" on-click="_handleToggleCollapsed">
-      <div class="headerLeft">
-        <template is="dom-if" if="[[comment.robot_id]]">
-          <span class="robotName"> [[comment.robot_id]] </span>
-        </template>
-        <template is="dom-if" if="[[!comment.robot_id]]">
-          <gr-account-label
-            account="[[_getAuthor(comment, _selfAccount)]]"
-            class$="[[_computeAccountLabelClass(draft)]]"
-            hideStatus
-          >
-          </gr-account-label>
-        </template>
-        <template is="dom-if" if="[[showPortedComment]]">
-          <a href="[[_getUrlForComment(comment)]]"
-            ><span class="portedMessage" on-click="_handlePortedMessageClick"
-              >From patchset [[comment.patch_set]]</span
-            ></a
-          >
-        </template>
-        <gr-tooltip-content
-          class="draftTooltip"
-          has-tooltip
-          title="[[_computeDraftTooltip(_unableToSave)]]"
-          max-width="20em"
-          show-icon
-        >
-          <span class="draftLabel">[[_computeDraftText(_unableToSave)]]</span>
-        </gr-tooltip-content>
-      </div>
-      <div class="headerMiddle">
-        <span class="collapsedContent">[[comment.message]]</span>
-      </div>
-      <div
-        hidden$="[[_computeHideRunDetails(comment, collapsed)]]"
-        class="runIdMessage message"
-      >
-        <div class="runIdInformation">
-          <a class="robotRunLink" href$="[[comment.url]]">
-            <span class="robotRun link">Run Details</span>
-          </a>
-        </div>
-      </div>
-      <gr-button
-        id="deleteBtn"
-        title="Delete Comment"
-        link=""
-        class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
-        hidden$="[[isRobotComment]]"
-        on-click="_handleCommentDelete"
-      >
-        <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
-      </gr-button>
-      <template is="dom-if" if="[[showPatchset]]">
-        <span class="patchset-text"> Patchset [[patchNum]]</span>
-      </template>
-      <span class="separator"></span>
-      <template is="dom-if" if="[[comment.updated]]">
-        <span class="date" tabindex="0" on-click="_handleAnchorClick">
-          <gr-date-formatter
-            withTooltip
-            date-str="[[comment.updated]]"
-          ></gr-date-formatter>
-        </span>
-      </template>
-      <div class="show-hide" tabindex="0">
-        <label
-          class="show-hide"
-          aria-label$="[[_computeShowHideAriaLabel(collapsed)]]"
-        >
-          <input
-            type="checkbox"
-            class="show-hide"
-            checked$="[[collapsed]]"
-            on-change="_handleToggleCollapsed"
-          />
-          <iron-icon id="icon" icon="[[_computeShowHideIcon(collapsed)]]">
-          </iron-icon>
-        </label>
-      </div>
-    </div>
-    <div class="body">
-      <template is="dom-if" if="[[isRobotComment]]">
-        <div class="robotId" hidden$="[[collapsed]]">
-          [[comment.author.name]]
-        </div>
-      </template>
-      <template is="dom-if" if="[[editing]]">
-        <gr-textarea
-          id="editTextarea"
-          class="editMessage"
-          autocomplete="on"
-          code=""
-          disabled="{{disabled}}"
-          rows="4"
-          text="{{_messageText}}"
-        ></gr-textarea>
-        <template
-          is="dom-if"
-          if="[[_computeVisibilityOfTip(_showRespectfulTip, _respectfulTipDismissed)]]"
-        >
-          <div class="respectfulReviewTip">
-            <div>
-              <gr-tooltip-content
-                has-tooltip
-                title="Tips for respectful code reviews."
-              >
-                <iron-icon
-                  class="pointer"
-                  icon="gr-icons:lightbulb-outline"
-                ></iron-icon>
-              </gr-tooltip-content>
-              [[_respectfulReviewTip]]
-            </div>
-            <div>
-              <a
-                tabindex="-1"
-                on-click="_onRespectfulReadMoreClick"
-                href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
-                target="_blank"
-              >
-                Read more
-              </a>
-              <a
-                tabindex="-1"
-                class="close pointer"
-                on-click="_dismissRespectfulTip"
-                >Not helpful</a
-              >
-            </div>
-          </div>
-        </template>
-      </template>
-      <!--The message class is needed to ensure selectability from
-        gr-diff-selection.-->
-      <gr-formatted-text
-        class="message"
-        content="[[comment.message]]"
-        no-trailing-margin="[[!comment.__draft]]"
-        config="[[projectConfig.commentlinks]]"
-      ></gr-formatted-text>
-      <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
-        <div class="action resolve hideOnPublished">
-          <label>
-            <input
-              type="checkbox"
-              id="resolvedCheckbox"
-              checked="[[resolved]]"
-              on-change="_handleToggleResolved"
-            />
-            Resolved
-          </label>
-        </div>
-        <template is="dom-if" if="[[draft]]">
-          <div class="rightActions">
-            <template is="dom-if" if="[[hasPublishedComment(comments)]]">
-              <iron-icon
-                class="link-icon"
-                on-click="handleCopyLink"
-                class="copy"
-                title="Copy link to this comment"
-                icon="gr-icons:link"
-                role="button"
-                tabindex="0"
-              >
-              </iron-icon>
-            </template>
-            <gr-button
-              link=""
-              class="action cancel hideOnPublished"
-              on-click="_handleCancel"
-              >Cancel</gr-button
-            >
-            <gr-button
-              link=""
-              class="action discard hideOnPublished"
-              on-click="_handleDiscard"
-              >Discard</gr-button
-            >
-            <gr-button
-              link=""
-              class="action edit hideOnPublished"
-              on-click="_handleEdit"
-              >Edit</gr-button
-            >
-            <gr-button
-              link=""
-              disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
-              class="action save hideOnPublished"
-              on-click="_handleSave"
-              >Save</gr-button
-            >
-          </div>
-        </template>
-      </div>
-      <div class="robotActions" hidden$="[[!_showRobotActions]]">
-        <template is="dom-if" if="[[hasPublishedComment(comments)]]">
-          <iron-icon
-            class="link-icon"
-            on-click="handleCopyLink"
-            class="copy"
-            title="Copy link to this comment"
-            icon="gr-icons:link"
-            role="button"
-            tabindex="0"
-          >
-          </iron-icon>
-        </template>
-        <template is="dom-if" if="[[isRobotComment]]">
-          <gr-endpoint-decorator name="robot-comment-controls">
-            <gr-endpoint-param name="comment" value="[[comment]]">
-            </gr-endpoint-param>
-          </gr-endpoint-decorator>
-          <gr-button
-            link=""
-            secondary=""
-            class="action show-fix"
-            hidden$="[[_hasNoFix(comment)]]"
-            on-click="_handleShowFix"
-          >
-            Show Fix
-          </gr-button>
-          <template is="dom-if" if="[[!_hasHumanReply]]">
-            <gr-button
-              link=""
-              class="action fix"
-              on-click="_handleFix"
-              disabled="[[robotButtonDisabled]]"
-            >
-              Please Fix
-            </gr-button>
-          </template>
-        </template>
-      </div>
-    </div>
-  </div>
-  <template is="dom-if" if="[[_enableOverlay]]">
-    <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
-      <gr-confirm-delete-comment-dialog
-        id="confirmDeleteComment"
-        on-confirm="_handleConfirmDeleteComment"
-        on-cancel="_handleCancelDeleteComment"
-      >
-      </gr-confirm-delete-comment-dialog>
-    </gr-overlay>
-  </template>
-`;
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 00edc07..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
@@ -14,1502 +14,682 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-comment';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {GrComment, __testOnly_UNSAVED_MESSAGE} from './gr-comment';
-import {SpecialFilePath, CommentSide} from '../../../constants/constants';
+import {AUTO_SAVE_DEBOUNCE_DELAY_MS, GrComment} from './gr-comment';
 import {
   queryAndAssert,
   stubRestApi,
-  stubStorage,
-  spyStorage,
   query,
-  isVisible,
-  stubReporting,
+  pressKey,
+  listenOnce,
   mockPromise,
+  waitUntilCalled,
+  dispatch,
+  MockPromise,
 } from '../../../test/test-utils';
 import {
   AccountId,
   EmailAddress,
-  FixId,
   NumericChangeId,
-  ParsedJSON,
   PatchSetNum,
-  RobotId,
-  RobotRunId,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {
-  pressAndReleaseKeyOn,
-  tap,
-} from '@polymer/iron-test-helpers/mock-interactions';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {
   createComment,
   createDraft,
   createFixSuggestionInfo,
+  createRobotComment,
+  createUnsaved,
 } from '../../../test/test-data-generators';
-import {Timer} from '../../../services/gr-reporting/gr-reporting';
-import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
-import {CreateFixCommentEvent} from '../../../types/events';
-import {DraftInfo, UIRobot} from '../../../utils/comment-util';
-import {MockTimer} from '../../../services/gr-reporting/gr-reporting_mock';
+import {
+  CreateFixCommentEvent,
+  OpenFixPreviewEventDetail,
+} from '../../../types/events';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
-
-const basicFixture = fixtureFromElement('gr-comment');
-
-const draftFixture = fixtureFromTemplate(html`
-  <gr-comment draft="true"></gr-comment>
-`);
+import {DraftInfo} from '../../../utils/comment-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {Modifier} from '../../../utils/dom-util';
+import {SinonStub} from 'sinon';
 
 suite('gr-comment tests', () => {
-  suite('basic tests', () => {
-    let element: GrComment;
+  let element: GrComment;
 
-    let openOverlaySpy: sinon.SinonSpy;
+  setup(() => {
+    element = fixtureFromElement('gr-comment').instantiate();
+    element.account = {
+      email: 'dhruvsri@google.com' as EmailAddress,
+      name: 'Dhruv Srivastava',
+      _account_id: 1083225 as AccountId,
+      avatars: [{url: 'abc', height: 32, width: 32}],
+      registered_on: '123' as Timestamp,
+    };
+    element.showPatchset = true;
+    element.getRandomInt = () => 1;
+    element.comment = {
+      ...createComment(),
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com' as EmailAddress,
+      },
+      id: 'baf0414d_60047215' as UrlEncodedCommentId,
+      line: 5,
+      message: 'This is the test comment message.',
+      updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+    };
+  });
 
-    setup(() => {
-      stubRestApi('getAccount').returns(
-        Promise.resolve({
-          email: 'dhruvsri@google.com' as EmailAddress,
-          name: 'Dhruv Srivastava',
-          _account_id: 1083225 as AccountId,
-          avatars: [{url: 'abc', height: 32, width: 32}],
-          registered_on: '123' as Timestamp,
-        })
-      );
-      element = basicFixture.instantiate();
-      element.comment = {
-        ...createComment(),
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: 'baf0414d_60047215' as UrlEncodedCommentId,
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-      };
-
-      openOverlaySpy = sinon.spy(element, '_openOverlay');
+  suite('DOM rendering', () => {
+    test('renders collapsed', async () => {
+      element.initiallyCollapsed = true;
+      await element.updateComplete;
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <div class="container" id="container">
+          <div class="header" id="header">
+            <div class="headerLeft">
+              <gr-account-label deselected=""></gr-account-label>
+            </div>
+            <div class="headerMiddle">
+              <span class="collapsedContent">
+                This is the test comment message.
+              </span>
+            </div>
+            <span class="patchset-text">Patchset 1</span>
+            <div class="show-hide" tabindex="0">
+              <label aria-label="Expand" class="show-hide">
+                <input checked="" class="show-hide" type="checkbox" />
+                <iron-icon id="icon" icon="gr-icons:expand-more"></iron-icon>
+              </label>
+            </div>
+          </div>
+          <div class="body"></div>
+        </div>
+      `);
     });
 
-    teardown(() => {
-      openOverlaySpy.getCalls().forEach(call => {
-        call.args[0].remove();
-      });
+    test('renders expanded', async () => {
+      element.initiallyCollapsed = false;
+      await element.updateComplete;
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <div class="container" id="container">
+          <div class="header" id="header">
+            <div class="headerLeft">
+              <gr-account-label deselected=""></gr-account-label>
+            </div>
+            <div class="headerMiddle"></div>
+            <span class="patchset-text">Patchset 1</span>
+            <span class="separator"></span>
+            <span class="date" tabindex="0">
+              <gr-date-formatter withtooltip=""></gr-date-formatter>
+            </span>
+            <div class="show-hide" tabindex="0">
+              <label aria-label="Collapse" class="show-hide">
+                <input class="show-hide" type="checkbox" />
+                <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
+              </label>
+            </div>
+          </div>
+          <div class="body">
+            <gr-formatted-text
+              class="message"
+              notrailingmargin=""
+            ></gr-formatted-text>
+          </div>
+        </div>
+      `);
     });
 
-    test('collapsible comments', () => {
-      // When a comment (not draft) is loaded, it should be collapsed
-      assert.isTrue(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-
-      // The header middle content is only visible when comments are collapsed.
-      // It shows the message in a condensed way, and limits to a single line.
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
-
-      // When the header row is clicked, the comment should expand
-      tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is not visible'
-      );
+    test('renders expanded robot', async () => {
+      element.initiallyCollapsed = false;
+      element.comment = createRobotComment();
+      await element.updateComplete;
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <div class="container" id="container">
+          <div class="header" id="header">
+            <div class="headerLeft">
+              <span class="robotName">robot-id-123</span>
+            </div>
+            <div class="headerMiddle"></div>
+            <span class="patchset-text">Patchset 1</span>
+            <span class="separator"></span>
+            <span class="date" tabindex="0">
+              <gr-date-formatter withtooltip=""></gr-date-formatter>
+            </span>
+            <div class="show-hide" tabindex="0">
+              <label aria-label="Collapse" class="show-hide">
+                <input class="show-hide" type="checkbox" />
+                <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
+              </label>
+            </div>
+          </div>
+          <div class="body">
+            <div class="robotId"></div>
+            <gr-formatted-text
+              class="message"
+              notrailingmargin=""
+            ></gr-formatted-text>
+            <div class="robotActions">
+              <iron-icon
+                class="copy link-icon"
+                icon="gr-icons:link"
+                role="button"
+                tabindex="0"
+                title="Copy link to this comment"
+              >
+              </iron-icon>
+              <gr-endpoint-decorator name="robot-comment-controls">
+                <gr-endpoint-param name="comment"></gr-endpoint-param>
+              </gr-endpoint-decorator>
+              <gr-button
+                aria-disabled="false"
+                class="action show-fix"
+                link=""
+                role="button"
+                secondary=""
+                tabindex="0"
+              >
+                Show Fix
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                class="action fix"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Please Fix
+              </gr-button>
+            </div>
+          </div>
+        </div>
+      `);
     });
 
-    test('clicking on date link fires event', () => {
-      element.side = 'PARENT';
-      const stub = sinon.stub();
-      element.addEventListener('comment-anchor-tap', stub);
-      flush();
-      const dateEl = queryAndAssert(element, '.date');
-      assert.ok(dateEl);
-      tap(dateEl);
-
-      assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail, {
-        side: element.side,
-        number: element.comment!.line,
-      });
+    test('renders expanded admin', async () => {
+      element.initiallyCollapsed = false;
+      element.isAdmin = true;
+      await element.updateComplete;
+      expect(queryAndAssert(element, 'gr-button.delete')).dom.to
+        .equal(/* HTML */ `
+        <gr-button
+          aria-disabled="false"
+          class="action delete"
+          id="deleteBtn"
+          link=""
+          role="button"
+          tabindex="0"
+          title="Delete Comment"
+        >
+          <iron-icon icon="gr-icons:delete" id="icon"></iron-icon>
+        </gr-button>
+      `);
     });
 
-    test('message is not retrieved from storage when missing path', async () => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-      };
-      await flush();
-      assert.isTrue(loadSpy.called);
-      assert.isFalse(storageStub.called);
+    test('renders draft', async () => {
+      element.initiallyCollapsed = false;
+      (element.comment as DraftInfo).__draft = true;
+      await element.updateComplete;
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <div class="container draft" id="container">
+          <div class="header" id="header">
+            <div class="headerLeft">
+              <gr-account-label class="draft" deselected=""></gr-account-label>
+              <gr-tooltip-content
+                class="draftTooltip"
+                has-tooltip=""
+                max-width="20em"
+                show-icon=""
+                title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
+              >
+                <span class="draftLabel">DRAFT</span>
+              </gr-tooltip-content>
+            </div>
+            <div class="headerMiddle"></div>
+            <span class="patchset-text">Patchset 1</span>
+            <span class="separator"></span>
+            <span class="date" tabindex="0">
+              <gr-date-formatter withtooltip=""></gr-date-formatter>
+            </span>
+            <div class="show-hide" tabindex="0">
+              <label aria-label="Collapse" class="show-hide">
+                <input class="show-hide" type="checkbox" />
+                <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
+              </label>
+            </div>
+          </div>
+          <div class="body">
+            <gr-formatted-text class="message"></gr-formatted-text>
+            <div class="actions">
+              <div class="action resolve">
+                <label>
+                  <input checked="" id="resolvedCheckbox" type="checkbox" />
+                  Resolved
+                </label>
+              </div>
+              <div class="rightActions">
+                <gr-button
+                  aria-disabled="false"
+                  class="action discard"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Discard
+                </gr-button>
+                <gr-button
+                  aria-disabled="false"
+                  class="action edit"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Edit
+                </gr-button>
+              </div>
+            </div>
+          </div>
+        </div>
+      `);
     });
 
-    test('message is not retrieved from storage when message present', async () => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        message: 'This is a message',
-        line: 5,
-        path: 'test',
-        __editing: true,
-        __draft: true,
-      };
-      await flush();
-      assert.isTrue(loadSpy.called);
-      assert.isFalse(storageStub.called);
+    test('renders draft in editing mode', async () => {
+      element.initiallyCollapsed = false;
+      (element.comment as DraftInfo).__draft = true;
+      element.editing = true;
+      await element.updateComplete;
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <div class="container draft" id="container">
+          <div class="header" id="header">
+            <div class="headerLeft">
+              <gr-account-label class="draft" deselected=""></gr-account-label>
+              <gr-tooltip-content
+                class="draftTooltip"
+                has-tooltip=""
+                max-width="20em"
+                show-icon=""
+                title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
+              >
+                <span class="draftLabel">DRAFT</span>
+              </gr-tooltip-content>
+            </div>
+            <div class="headerMiddle"></div>
+            <span class="patchset-text">Patchset 1</span>
+            <span class="separator"></span>
+            <span class="date" tabindex="0">
+              <gr-date-formatter withtooltip=""></gr-date-formatter>
+            </span>
+            <div class="show-hide" tabindex="0">
+              <label aria-label="Collapse" class="show-hide">
+                <input class="show-hide" type="checkbox" />
+                <iron-icon id="icon" icon="gr-icons:expand-less"></iron-icon>
+              </label>
+            </div>
+          </div>
+          <div class="body">
+            <gr-textarea
+              autocomplete="on"
+              class="code editMessage"
+              code=""
+              id="editTextarea"
+              rows="4"
+              text="This is the test comment message."
+            >
+            </gr-textarea>
+            <div class="actions">
+              <div class="action resolve">
+                <label>
+                  <input checked="" id="resolvedCheckbox" type="checkbox" />
+                  Resolved
+                </label>
+              </div>
+              <div class="rightActions">
+                <gr-button
+                  aria-disabled="false"
+                  class="action cancel"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Cancel
+                </gr-button>
+                <gr-button
+                  aria-disabled="false"
+                  class="action save"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Save
+                </gr-button>
+              </div>
+            </div>
+          </div>
+        </div>
+      `);
     });
+  });
 
-    test('message is retrieved from storage for drafts in edit', async () => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
+  test('clicking on date link fires event', async () => {
+    const stub = sinon.stub();
+    element.addEventListener('comment-anchor-tap', stub);
+    await element.updateComplete;
 
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        __editing: true,
-        __draft: true,
-      };
-      await flush();
-      assert.isTrue(loadSpy.called);
-      assert.isTrue(storageStub.called);
+    const dateEl = queryAndAssert(element, '.date');
+    tap(dateEl);
+
+    assert.isTrue(stub.called);
+    assert.deepEqual(stub.lastCall.args[0].detail, {
+      side: 'REVISION',
+      number: element.comment!.line,
     });
+  });
 
-    test('comment message sets messageText only when empty', () => {
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element._messageText = '';
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        __editing: true,
-        __draft: true,
-        message: 'hello world',
-      };
-      // messageText was empty so overwrite the message now
-      assert.equal(element._messageText, 'hello world');
+  test('comment message sets messageText only when empty', async () => {
+    element.changeNum = 1 as NumericChangeId;
+    element.messageText = '';
+    element.comment = {
+      ...createComment(),
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com' as EmailAddress,
+      },
+      line: 5,
+      path: 'test',
+      __draft: true,
+      message: 'hello world',
+    };
+    element.editing = true;
+    await element.updateComplete;
+    // messageText was empty so overwrite the message now
+    assert.equal(element.messageText, 'hello world');
 
-      element.comment!.message = 'new message';
-      // messageText was already set so do not overwrite it
-      assert.equal(element._messageText, 'hello world');
-    });
+    element.comment.message = 'new message';
+    await element.updateComplete;
+    // messageText was already set so do not overwrite it
+    assert.equal(element.messageText, 'hello world');
+  });
 
-    test('comment message sets messageText when not edited', () => {
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element._messageText = 'Some text';
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        __editing: false,
-        __draft: true,
-        message: 'hello world',
-      };
-      // messageText was empty so overwrite the message now
-      assert.equal(element._messageText, 'hello world');
+  test('comment message sets messageText when not edited', async () => {
+    element.changeNum = 1 as NumericChangeId;
+    element.messageText = 'Some text';
+    element.comment = {
+      ...createComment(),
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com' as EmailAddress,
+      },
+      line: 5,
+      path: 'test',
+      __draft: true,
+      message: 'hello world',
+    };
+    element.editing = true;
+    await element.updateComplete;
+    // messageText was empty so overwrite the message now
+    assert.equal(element.messageText, 'hello world');
 
-      element.comment!.message = 'new message';
-      // messageText was already set so do not overwrite it
-      assert.equal(element._messageText, 'hello world');
-    });
+    element.comment.message = 'new message';
+    await element.updateComplete;
+    // messageText was already set so do not overwrite it
+    assert.equal(element.messageText, 'hello world');
+  });
 
-    test('_getPatchNum', () => {
-      element.side = 'PARENT';
-      element.patchNum = 1 as PatchSetNum;
-      assert.equal(element._getPatchNum(), 'PARENT' as PatchSetNum);
-      element.side = 'REVISION';
-      assert.equal(element._getPatchNum(), 1 as PatchSetNum);
-    });
+  test('delete comment', async () => {
+    element.changeNum = 42 as NumericChangeId;
+    element.isAdmin = true;
+    await element.updateComplete;
 
-    test('comment expand and collapse', () => {
-      element.collapsed = true;
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
+    const deleteButton = queryAndAssert(element, '.action.delete');
+    tap(deleteButton);
+    await element.updateComplete;
 
-      element.collapsed = false;
-      assert.isFalse(element.collapsed);
-      assert.isTrue(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is is not visible'
-      );
-    });
+    assertIsDefined(element.confirmDeleteOverlay, 'confirmDeleteOverlay');
+    const dialog = queryAndAssert<GrConfirmDeleteCommentDialog>(
+      element.confirmDeleteOverlay,
+      '#confirmDeleteComment'
+    );
+    dialog.message = 'removal reason';
+    await element.updateComplete;
 
-    suite('while editing', () => {
-      let handleCancelStub: sinon.SinonStub;
-      let handleSaveStub: sinon.SinonStub;
-      setup(() => {
-        element.editing = true;
-        element._messageText = 'test';
-        handleCancelStub = sinon.stub(element, '_handleCancel');
-        handleSaveStub = sinon.stub(element, '_handleSave');
-        flush();
-      });
-
-      suite('when text is empty', () => {
-        setup(() => {
-          element._messageText = '';
-          element.comment = {};
-        });
-
-        test('esc closes comment when text is empty', () => {
-          pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
-          assert.isTrue(handleCancelStub.called);
-        });
-
-        test('ctrl+enter does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
-          assert.isFalse(handleSaveStub.called);
-        });
-
-        test('meta+enter does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
-          assert.isFalse(handleSaveStub.called);
-        });
-
-        test('ctrl+s does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
-          assert.isFalse(handleSaveStub.called);
-        });
-      });
-
-      test('esc does not close comment that has content', () => {
-        pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
-        assert.isFalse(handleCancelStub.called);
-      });
-
-      test('ctrl+enter saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
-        assert.isTrue(handleSaveStub.called);
-      });
-
-      test('meta+enter saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
-        assert.isTrue(handleSaveStub.called);
-      });
-
-      test('ctrl+s saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
-        assert.isTrue(handleSaveStub.called);
-      });
-    });
-
-    test('delete comment button for non-admins is hidden', () => {
-      element._isAdmin = false;
-      assert.isFalse(
-        queryAndAssert(element, '.action.delete').classList.contains(
-          'showDeleteButtons'
-        )
-      );
-    });
-
-    test('delete comment button for admins with draft is hidden', () => {
-      element._isAdmin = false;
-      element.draft = true;
-      assert.isFalse(
-        queryAndAssert(element, '.action.delete').classList.contains(
-          'showDeleteButtons'
-        )
-      );
-    });
-
-    test('delete comment', async () => {
-      const stub = stubRestApi('deleteComment').returns(
-        Promise.resolve({
-          id: '1' as UrlEncodedCommentId,
-          updated: '1' as Timestamp,
-          ...createComment(),
-        })
-      );
-      const openSpy = sinon.spy(element.confirmDeleteOverlay!, 'open');
-      element.changeNum = 42 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element._isAdmin = true;
-      assert.isTrue(
-        queryAndAssert(element, '.action.delete').classList.contains(
-          'showDeleteButtons'
-        )
-      );
-      tap(queryAndAssert(element, '.action.delete'));
-      await flush();
-      await openSpy.lastCall.returnValue;
-      const dialog = element.confirmDeleteOverlay?.querySelector(
-        '#confirmDeleteComment'
-      ) as GrConfirmDeleteCommentDialog;
-      dialog.message = 'removal reason';
-      element._handleConfirmDeleteComment();
-      assert.isTrue(
-        stub.calledWith(
-          42 as NumericChangeId,
-          1 as PatchSetNum,
-          'baf0414d_60047215' as UrlEncodedCommentId,
-          'removal reason'
-        )
-      );
-    });
-
-    suite('draft update reporting', () => {
-      let endStub: SinonStubbedMember<() => Timer>;
-      let getTimerStub: sinon.SinonStub;
-      const mockEvent = {...new Event('click'), preventDefault() {}};
-
-      setup(() => {
-        sinon.stub(element, 'save').returns(Promise.resolve({}));
-        endStub = sinon.stub();
-        const mockTimer = new MockTimer();
-        mockTimer.end = endStub;
-        getTimerStub = stubReporting('getTimer').returns(mockTimer);
-      });
-
-      test('create', async () => {
-        element.patchNum = 1 as PatchSetNum;
-        element.comment = {};
-        sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
-        await element._handleSave(mockEvent);
-        await flush();
-        const grAccountLabel = queryAndAssert(element, 'gr-account-label');
-        const spanName = queryAndAssert<HTMLSpanElement>(
-          grAccountLabel,
-          'span.name'
-        );
-        assert.equal(spanName.innerText.trim(), 'Dhruv Srivastava');
-        assert.isTrue(endStub.calledOnce);
-        assert.isTrue(getTimerStub.calledOnce);
-        assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-      });
-
-      test('update', () => {
-        element.comment = {
-          ...createComment(),
-          id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
-        };
-        sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
-        return element._handleSave(mockEvent)!.then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
-        });
-      });
-
-      test('discard', () => {
-        element.comment = {
-          ...createComment(),
-          id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
-        };
-        element.comment = createDraft();
-        sinon.stub(element, '_fireDiscard');
-        sinon.stub(element, '_eraseDraftCommentFromStorage');
-        sinon
-          .stub(element, '_deleteDraft')
-          .returns(Promise.resolve(new Response()));
-        return element._discardDraft().then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
-        });
-      });
-    });
-
-    test('edit reports interaction', () => {
-      const reportStub = stubReporting('recordDraftInteraction');
-      sinon.stub(element, '_fireEdit');
-      element.draft = true;
-      flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('discard reports interaction', () => {
-      const reportStub = stubReporting('recordDraftInteraction');
-      sinon.stub(element, '_eraseDraftCommentFromStorage');
-      sinon.stub(element, '_fireDiscard');
-      sinon
-        .stub(element, '_deleteDraft')
-        .returns(Promise.resolve(new Response()));
-      element.draft = true;
-      element.comment = createDraft();
-      flush();
-      tap(queryAndAssert(element, '.discard'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('failed save draft request', async () => {
-      element.draft = true;
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
-      const diffDraftStub = stubRestApi('saveDiffDraft').returns(
-        Promise.resolve({...new Response(), ok: false})
-      );
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      let args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0, true]);
-      assert.equal(
-        element._getSavingMessage(...args),
-        __testOnly_UNSAVED_MESSAGE
-      );
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT(Failed to save)'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is visible'
-      );
-      diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0]);
-      assert.equal(element._getSavingMessage(...args), 'All changes saved');
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(element._unableToSave);
-    });
-
-    test('failed save draft request with promise failure', async () => {
-      element.draft = true;
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
-      const diffDraftStub = stubRestApi('saveDiffDraft').returns(
-        Promise.reject(new Error())
-      );
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      let args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0, true]);
-      assert.equal(
-        element._getSavingMessage(...args),
-        __testOnly_UNSAVED_MESSAGE
-      );
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT(Failed to save)'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is visible'
-      );
-      diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0]);
-      assert.equal(element._getSavingMessage(...args), 'All changes saved');
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(element._unableToSave);
-    });
+    const stub = stubRestApi('deleteComment').returns(
+      Promise.resolve(createComment())
+    );
+    element.handleConfirmDeleteComment();
+    assert.isTrue(
+      stub.calledWith(
+        42 as NumericChangeId,
+        1 as PatchSetNum,
+        'baf0414d_60047215' as UrlEncodedCommentId,
+        'removal reason'
+      )
+    );
   });
 
   suite('gr-comment draft tests', () => {
-    let element: GrComment;
-
-    setup(() => {
-      stubRestApi('getAccount').returns(Promise.resolve(undefined));
-      stubRestApi('saveDiffDraft').returns(
-        Promise.resolve({
-          ...new Response(),
-          ok: true,
-          text() {
-            return Promise.resolve(
-              ")]}'\n{" +
-                '"id": "baf0414d_40572e03",' +
-                '"path": "/path/to/file",' +
-                '"line": 5,' +
-                '"updated": "2015-12-08 21:52:36.177000000",' +
-                '"message": "saved!",' +
-                '"side": "REVISION",' +
-                '"unresolved": false,' +
-                '"patch_set": 1' +
-                '}'
-            );
-          },
-        })
-      );
-      stubRestApi('removeChangeReviewer').returns(
-        Promise.resolve({...new Response(), ok: true})
-      );
-      element = draftFixture.instantiate() as GrComment;
-      stubStorage('getDraftComment').returns(null);
+    setup(async () => {
       element.changeNum = 42 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.editing = false;
       element.comment = {
         ...createComment(),
         __draft: true,
-        __draftID: 'temp_draft_id',
         path: '/path/to/file',
         line: 5,
-        id: undefined,
       };
     });
 
-    test('button visibility states', async () => {
-      element.showActions = false;
-      assert.isTrue(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
+    test('isSaveDisabled', async () => {
+      element.saving = false;
+      element.unresolved = true;
+      element.comment = {...createComment(), unresolved: true};
+      element.messageText = 'asdf';
+      await element.updateComplete;
+      assert.isFalse(element.isSaveDisabled());
 
-      element.showActions = true;
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
+      element.messageText = '';
+      await element.updateComplete;
+      assert.isTrue(element.isSaveDisabled());
 
-      element.draft = true;
-      await flush();
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.edit')),
-        'edit is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.discard')),
-        'discard is visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.resolve')),
-        'resolve is visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
+      element.unresolved = false;
+      await element.updateComplete;
+      assert.isFalse(element.isSaveDisabled());
 
-      element.editing = true;
-      await flush();
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.edit')),
-        'edit is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.discard')),
-        'discard not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.resolve')),
-        'resolve is visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      element.draft = false;
-      element.editing = false;
-      await flush();
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.edit')),
-        'edit is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.discard')),
-        'discard is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is not visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      element.comment!.id = 'foo' as UrlEncodedCommentId;
-      element.draft = true;
-      element.editing = true;
-      await flush();
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      // Delete button is not hidden by default
-      assert.isFalse(
-        (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
-      );
-
-      element.isRobotComment = true;
-      element.draft = true;
-      assert.isTrue(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      // It is not expected to see Robot comment drafts, but if they appear,
-      // they will behave the same as non-drafts.
-      element.draft = false;
-      assert.isTrue(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      // A robot comment with run ID should display plain text.
-      element.set(['comment', 'robot_run_id'], 'text');
-      element.editing = false;
-      element.collapsed = false;
-      await flush();
-      assert.isTrue(
-        queryAndAssert(element, '.robotRun.link').textContent === 'Run Details'
-      );
-
-      // A robot comment with run ID and url should display a link.
-      element.set(['comment', 'url'], '/path/to/run');
-      await flush();
-      assert.notEqual(
-        getComputedStyle(queryAndAssert(element, '.robotRun.link')).display,
-        'none'
-      );
-
-      // Delete button is hidden for robot comments
-      assert.isTrue(
-        (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
-      );
-    });
-
-    test('collapsible drafts', async () => {
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      assert.isTrue(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
-
-      tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is is not visible'
-      );
-
-      // When the edit button is pressed, should still see the actions
-      // and also textarea
-      element.draft = true;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      await flush();
-      assert.isTrue(fireEditStub.called);
-      assert.isFalse(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is not visible'
-      );
-
-      // When toggle again, everything should be hidden except for textarea
-      // and header middle content should be visible
-      tap(element.$.header);
-      assert.isTrue(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-textarea')),
-        'textarea is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
-
-      // When toggle again, textarea should remain open in the state it was
-      // before
-      tap(element.$.header);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is not visible'
-      );
-    });
-
-    test('robot comment layout', async () => {
-      const comment = {
-        robot_id: 'happy_robot_id' as RobotId,
-        url: '/robot/comment',
-        author: {
-          name: 'Happy Robot',
-          display_name: 'Display name Robot',
-        },
-        ...element.comment,
-      };
-      element.comment = comment;
-      element.collapsed = false;
-      await flush;
-      let runIdMessage;
-      runIdMessage = queryAndAssert(element, '.runIdMessage') as HTMLElement;
-      assert.isFalse((runIdMessage as HTMLElement).hidden);
-
-      const runDetailsLink = queryAndAssert(
-        element,
-        '.robotRunLink'
-      ) as HTMLAnchorElement;
-      assert.isTrue(
-        runDetailsLink.href.indexOf((element.comment as UIRobot).url!) !== -1
-      );
-
-      const robotServiceName = queryAndAssert(element, '.robotName');
-      assert.equal(robotServiceName.textContent?.trim(), 'happy_robot_id');
-
-      const authorName = queryAndAssert(element, '.robotId');
-      assert.isTrue((authorName as HTMLDivElement).innerText === 'Happy Robot');
-
-      element.collapsed = true;
-      await flush();
-      runIdMessage = queryAndAssert(element, '.runIdMessage');
-      assert.isTrue((runIdMessage as HTMLDivElement).hidden);
-    });
-
-    test('author name fallback to email', async () => {
-      const comment = {
-        url: '/robot/comment',
-        author: {
-          email: 'test@test.com' as EmailAddress,
-        },
-        ...element.comment,
-      };
-      element.comment = comment;
-      element.collapsed = false;
-      await flush();
-      const authorName = queryAndAssert(
-        queryAndAssert(element, 'gr-account-label'),
-        'span.name'
-      ) as HTMLSpanElement;
-      assert.equal(authorName.innerText.trim(), 'test@test.com');
-    });
-
-    test('patchset level comment', async () => {
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      const comment = {
-        ...element.comment,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        line: undefined,
-        range: undefined,
-      };
-      element.comment = comment;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.called);
-      assert.isTrue(element.editing);
-
-      element._messageText = 'hello world';
-      const eraseMessageDraftSpy = spyStorage('eraseDraftComment');
-      const mockEvent = {...new Event('click'), preventDefault: sinon.stub()};
-      element._handleSave(mockEvent);
-      await flush();
-      assert.isTrue(eraseMessageDraftSpy.called);
-    });
-
-    test('draft creation/cancellation', async () => {
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      assert.isFalse(element.editing);
-      element.draft = true;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.called);
-      assert.isTrue(element.editing);
-
-      element.comment!.message = '';
-      element._messageText = '';
-      const eraseMessageDraftSpy = sinon.spy(
-        element,
-        '_eraseDraftCommentFromStorage'
-      );
-
-      // Save should be disabled on an empty message.
-      let disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-      element._messageText = '     ';
-      disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-
-      const updateStub = sinon.stub();
-      element.addEventListener('comment-update', updateStub);
-
-      let numDiscardEvents = 0;
-      const promise = mockPromise();
-      element.addEventListener('comment-discard', () => {
-        numDiscardEvents++;
-        assert.isFalse(eraseMessageDraftSpy.called);
-        if (numDiscardEvents === 2) {
-          assert.isFalse(updateStub.called);
-          promise.resolve();
-        }
-      });
-      tap(queryAndAssert(element, '.cancel'));
-      await flush();
-      element._messageText = '';
-      element.editing = true;
-      await flush();
-      pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
-      await promise;
-    });
-
-    test('draft discard removes message from storage', async () => {
-      element._messageText = '';
-      const eraseMessageDraftSpy = sinon.spy(
-        element,
-        '_eraseDraftCommentFromStorage'
-      );
-
-      const promise = mockPromise();
-      element.addEventListener('comment-discard', () => {
-        assert.isTrue(eraseMessageDraftSpy.called);
-        promise.resolve();
-      });
-      element._handleDiscard({
-        ...new Event('click'),
-        preventDefault: sinon.stub(),
-      });
-      await promise;
-    });
-
-    test('storage is cleared only after save success', () => {
-      element._messageText = 'test';
-      const eraseStub = sinon.stub(element, '_eraseDraftCommentFromStorage');
-      stubRestApi('getResponseObject').returns(
-        Promise.resolve({...(createDraft() as ParsedJSON)})
-      );
-      const saveDraftStub = sinon
-        .stub(element, '_saveDraft')
-        .returns(Promise.resolve({...new Response(), ok: false}));
-
-      const savePromise = element.save();
-      assert.isFalse(eraseStub.called);
-      return savePromise.then(() => {
-        assert.isFalse(eraseStub.called);
-
-        saveDraftStub.restore();
-        sinon
-          .stub(element, '_saveDraft')
-          .returns(Promise.resolve({...new Response(), ok: true}));
-        return element.save().then(() => {
-          assert.isTrue(eraseStub.called);
-        });
-      });
-    });
-
-    test('_computeSaveDisabled', () => {
-      const comment = {unresolved: true};
-      const msgComment = {message: 'test', unresolved: true};
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-      assert.equal(element._computeSaveDisabled('test', comment, false), false);
-      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
-      assert.equal(
-        element._computeSaveDisabled('test', msgComment, false),
-        false
-      );
-      assert.equal(
-        element._computeSaveDisabled('test2', msgComment, false),
-        false
-      );
-      assert.equal(element._computeSaveDisabled('test', comment, true), false);
-      assert.equal(element._computeSaveDisabled('', comment, true), true);
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
+      element.saving = true;
+      await element.updateComplete;
+      assert.isTrue(element.isSaveDisabled());
     });
 
     test('ctrl+s saves comment', async () => {
-      const promise = mockPromise();
-      const stub = sinon.stub(element, 'save').callsFake(() => {
-        assert.isTrue(stub.called);
-        stub.restore();
-        promise.resolve();
-        return Promise.resolve();
-      });
-      element._messageText = 'is that the horse from horsing around??';
+      const spy = sinon.stub(element, 'save');
+      element.messageText = 'is that the horse from horsing around??';
       element.editing = true;
-      await flush();
-      pressAndReleaseKeyOn(
-        element.textarea!.$.textarea.textarea,
-        83,
-        'ctrl',
-        's'
-      );
-      await promise;
+      await element.updateComplete;
+      pressKey(element.textarea!.textarea!.textarea, 's', Modifier.CTRL_KEY);
+      assert.isTrue(spy.called);
     });
 
-    test('draft saving/editing', async () => {
-      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      const clock: SinonFakeTimers = sinon.useFakeTimers();
-      const tickAndFlush = async (repetitions: number) => {
-        for (let i = 1; i <= repetitions; i++) {
-          clock.tick(1000);
-          await flush();
-        }
-      };
+    test('save', async () => {
+      const savePromise = mockPromise<DraftInfo>();
+      const stub = sinon
+        .stub(element.getCommentsModel(), 'saveDraft')
+        .returns(savePromise);
 
-      element.draft = true;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.called);
-      tickAndFlush(1);
-      element._messageText = 'good news, everyone!';
-      tickAndFlush(1);
-      assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-update');
-      assert.isTrue(dispatchEventStub.calledTwice);
-
-      element._messageText = 'good news, everyone!';
-      await flush();
-      assert.isTrue(dispatchEventStub.calledTwice);
-
-      tap(queryAndAssert(element, '.save'));
-
-      assert.isTrue(
-        element.disabled,
-        'Element should be disabled when creating draft.'
-      );
-
-      let draft = await element._xhrPromise!;
-      const evt = dispatchEventStub.lastCall.args[0] as CustomEvent<{
-        comment: DraftInfo;
-      }>;
-      assert.equal(evt.type, 'comment-save');
-
-      const expectedDetail = {
-        comment: {
-          ...createComment(),
-          __draft: true,
-          __draftID: 'temp_draft_id',
-          id: 'baf0414d_40572e03' as UrlEncodedCommentId,
-          line: 5,
-          message: 'saved!',
-          path: '/path/to/file',
-          updated: '2015-12-08 21:52:36.177000000' as Timestamp,
-        },
-        patchNum: 1 as PatchSetNum,
-      };
-
-      assert.deepEqual(evt.detail, expectedDetail);
-      assert.isFalse(
-        element.disabled,
-        'Element should be enabled when done creating draft.'
-      );
-      assert.equal(draft.message, 'saved!');
-      assert.isFalse(element.editing);
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.calledTwice);
-      element._messageText =
-        'You’ll be delivering a package to Chapek 9, ' +
-        'a world where humans are killed on sight.';
-      tap(queryAndAssert(element, '.save'));
-      assert.isTrue(
-        element.disabled,
-        'Element should be disabled when updating draft.'
-      );
-      draft = await element._xhrPromise!;
-      assert.isFalse(
-        element.disabled,
-        'Element should be enabled when done updating draft.'
-      );
-      assert.equal(draft.message, 'saved!');
-      assert.isFalse(element.editing);
-      dispatchEventStub.restore();
-    });
-
-    test('draft prevent save when disabled', async () => {
-      const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
-      element.showActions = true;
-      element.draft = true;
-      await flush();
-      tap(element.$.header);
-      tap(queryAndAssert(element, '.edit'));
-      element._messageText = 'good news, everyone!';
-      await flush();
-
-      element.disabled = true;
-      tap(queryAndAssert(element, '.save'));
-      assert.isFalse(saveStub.called);
-
-      element.disabled = false;
-      tap(queryAndAssert(element, '.save'));
-      assert.isTrue(saveStub.calledOnce);
-    });
-
-    test('proper event fires on resolve, comment is not saved', async () => {
-      const save = sinon.stub(element, 'save');
-      const promise = mockPromise();
-      element.addEventListener('comment-update', e => {
-        assert.isTrue(e.detail.comment.unresolved);
-        assert.isFalse(save.called);
-        promise.resolve();
-      });
-      tap(queryAndAssert(element, '.resolve input'));
-      await promise;
-    });
-
-    test('resolved comment state indicated by checkbox', () => {
-      sinon.stub(element, 'save');
-      element.comment = {unresolved: false};
-      assert.isTrue(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      element.comment = {unresolved: true};
-      assert.isFalse(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-    });
-
-    test('resolved checkbox saves with tap when !editing', () => {
-      element.editing = false;
-      const save = sinon.stub(element, 'save');
-
-      element.comment = {unresolved: false};
-      assert.isTrue(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      element.comment = {unresolved: true};
-      assert.isFalse(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      assert.isFalse(save.called);
-      tap(element.$.resolvedCheckbox);
-      assert.isTrue(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      assert.isTrue(save.called);
-    });
-
-    suite('draft saving messages', () => {
-      test('_getSavingMessage', () => {
-        assert.equal(element._getSavingMessage(0), 'All changes saved');
-        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
-        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
-        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
-      });
-
-      test('_show{Start,End}Request', () => {
-        const updateStub = sinon.stub(element, '_updateRequestToast');
-        element._numPendingDraftRequests.number = 1;
-
-        element._showStartRequest();
-        assert.isTrue(updateStub.calledOnce);
-        assert.equal(updateStub.lastCall.args[0], 2);
-        assert.equal(element._numPendingDraftRequests.number, 2);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledTwice);
-        assert.equal(updateStub.lastCall.args[0], 1);
-        assert.equal(element._numPendingDraftRequests.number, 1);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledThrice);
-        assert.equal(updateStub.lastCall.args[0], 0);
-        assert.equal(element._numPendingDraftRequests.number, 0);
-      });
-    });
-
-    test('cancelling an unsaved draft discards, persists in storage', async () => {
-      const clock: SinonFakeTimers = sinon.useFakeTimers();
-      const tickAndFlush = async (repetitions: number) => {
-        for (let i = 1; i <= repetitions; i++) {
-          clock.tick(1000);
-          await flush();
-        }
-      };
-      const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = stubStorage('setDraftComment');
-      const eraseStub = stubStorage('eraseDraftComment');
-      element.comment!.id = undefined; // set id undefined for draft
-      element._messageText = 'test text';
-      tickAndFlush(1);
-
-      assert.isTrue(storeStub.called);
-      assert.equal(storeStub.lastCall.args[1], 'test text');
-      element._handleCancel({
-        ...new Event('click'),
-        preventDefault: sinon.stub(),
-      });
-      await flush();
-      assert.isTrue(discardSpy.called);
-      assert.isFalse(eraseStub.called);
-    });
-
-    test('cancelling edit on a saved draft does not store', () => {
-      element.comment!.id = 'foo' as UrlEncodedCommentId;
-      const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = stubStorage('setDraftComment');
-      element.comment!.id = undefined; // set id undefined for draft
-      element._messageText = 'test text';
-      flush();
-
-      assert.isFalse(storeStub.called);
-      element._handleCancel({...new Event('click'), preventDefault: () => {}});
-      assert.isTrue(discardSpy.called);
-    });
-
-    test('deleting text from saved draft and saving deletes the draft', () => {
-      element.comment = {
-        ...createComment(),
-        id: 'foo' as UrlEncodedCommentId,
-        message: 'test',
-      };
-      element._messageText = '';
-      const discardStub = sinon.stub(element, '_discardDraft');
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+      const textToSave = 'something, not important';
+      element.messageText = textToSave;
+      element.unresolved = true;
+      await element.updateComplete;
 
       element.save();
-      assert.isTrue(discardStub.called);
+
+      await element.updateComplete;
+      waitUntilCalled(stub, 'saveDraft()');
+      assert.equal(stub.lastCall.firstArg.message, textToSave);
+      assert.equal(stub.lastCall.firstArg.unresolved, true);
+      assert.isTrue(element.editing);
+      assert.isTrue(element.saving);
+
+      savePromise.resolve();
+      await element.updateComplete;
+
+      assert.isFalse(element.editing);
+      assert.isFalse(element.saving);
     });
 
-    test('_handleFix fires create-fix event', async () => {
-      const promise = mockPromise();
-      element.addEventListener(
-        'create-fix-comment',
-        (e: CreateFixCommentEvent) => {
-          assert.deepEqual(e.detail, element._getEventPayload());
-          promise.resolve();
-        }
-      );
-      element.isRobotComment = true;
-      element.comments = [element.comment!];
-      await flush();
+    test('save failed', async () => {
+      sinon
+        .stub(element.getCommentsModel(), 'saveDraft')
+        .returns(Promise.reject(new Error('saving failed')));
 
-      tap(queryAndAssert(element, '.fix'));
-      await promise;
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+      element.messageText = 'something, not important';
+      await element.updateComplete;
+
+      element.save();
+      await element.updateComplete;
+
+      assert.isTrue(element.unableToSave);
+      assert.isTrue(element.editing);
+      assert.isFalse(element.saving);
     });
 
-    test('do not show Please Fix button if human reply exists', () => {
-      element.comments = [
-        {
-          robot_id: 'happy_robot_id' as RobotId,
-          robot_run_id: '5838406743490560' as RobotRunId,
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf' as FixId,
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912 as AccountId,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com' as EmailAddress,
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-                width: 32,
-              },
-            ],
-          },
-          patch_set: 1 as PatchSetNum,
-          ...createComment(),
-          id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000' as Timestamp,
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          collapsed: false,
-        },
-        {
-          __draft: true,
-          __draftID: '0.wbrfbwj89sa',
-          __date: new Date(),
-          path: 'Documentation/config-gerrit.txt',
-          side: CommentSide.REVISION,
-          line: 10,
-          in_reply_to: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
-          message: '> This is a robot comment with a fix.\n\nPlease fix.',
-          unresolved: true,
-        },
-      ];
-      element.comment = element.comments[0];
-      flush();
-      assert.isNull(
-        element.shadowRoot?.querySelector('robotActions gr-button')
-      );
+    test('discard', async () => {
+      const discardPromise = mockPromise<void>();
+      const stub = sinon
+        .stub(element.getCommentsModel(), 'discardDraft')
+        .returns(discardPromise);
+
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+
+      element.discard();
+
+      await element.updateComplete;
+      waitUntilCalled(stub, 'discardDraft()');
+      assert.equal(stub.lastCall.firstArg, element.comment.id);
+      assert.isTrue(element.editing);
+      assert.isTrue(element.saving);
+
+      discardPromise.resolve();
+      await element.updateComplete;
+
+      assert.isFalse(element.editing);
+      assert.isFalse(element.saving);
     });
 
-    test('show Please Fix if no human reply', () => {
-      element.comments = [
-        {
-          robot_id: 'happy_robot_id' as RobotId,
-          robot_run_id: '5838406743490560' as RobotRunId,
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf' as FixId,
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912 as AccountId,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com' as EmailAddress,
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-                width: 32,
-              },
-            ],
-          },
-          patch_set: 1 as PatchSetNum,
-          ...createComment(),
-          id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000' as Timestamp,
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          collapsed: false,
-        },
-      ];
-      element.comment = element.comments[0];
-      flush();
-      queryAndAssert(element, '.robotActions gr-button');
-    });
-
-    test('_handleShowFix fires open-fix-preview event', async () => {
-      const promise = mockPromise();
-      element.addEventListener('open-fix-preview', e => {
-        assert.deepEqual(e.detail, element._getEventPayload());
-        promise.resolve();
-      });
+    test('resolved comment state indicated by checkbox', async () => {
+      const saveStub = sinon.stub(element.getCommentsModel(), 'saveDraft');
       element.comment = {
         ...createComment(),
+        __draft: true,
+        unresolved: false,
+      };
+      await element.updateComplete;
+
+      let checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '#resolvedCheckbox'
+      );
+      assert.isTrue(checkbox.checked);
+
+      tap(checkbox);
+      await element.updateComplete;
+
+      checkbox = queryAndAssert<HTMLInputElement>(element, '#resolvedCheckbox');
+      assert.isFalse(checkbox.checked);
+
+      assert.isTrue(saveStub.called);
+    });
+
+    test('saving empty text calls discard()', async () => {
+      const saveStub = sinon.stub(element.getCommentsModel(), 'saveDraft');
+      const discardStub = sinon.stub(
+        element.getCommentsModel(),
+        'discardDraft'
+      );
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+
+      element.messageText = '';
+      await element.updateComplete;
+
+      await element.save();
+      assert.isTrue(discardStub.called);
+      assert.isFalse(saveStub.called);
+    });
+
+    test('handleFix fires create-fix event', async () => {
+      const listener = listenOnce<CreateFixCommentEvent>(
+        element,
+        'create-fix-comment'
+      );
+      element.comment = createRobotComment();
+      element.comments = [element.comment];
+      await element.updateComplete;
+
+      tap(queryAndAssert(element, '.fix'));
+
+      const e = await listener;
+      assert.deepEqual(e.detail, element.getEventPayload());
+    });
+
+    test('do not show Please Fix button if human reply exists', async () => {
+      element.initiallyCollapsed = false;
+      const robotComment = createRobotComment();
+      element.comment = robotComment;
+      await element.updateComplete;
+
+      let actions = query(element, '.robotActions gr-button.fix');
+      assert.isOk(actions);
+
+      element.comments = [
+        robotComment,
+        {...createComment(), in_reply_to: robotComment.id},
+      ];
+      await element.updateComplete;
+      actions = query(element, '.robotActions gr-button.fix');
+      assert.isNotOk(actions);
+    });
+
+    test('handleShowFix fires open-fix-preview event', async () => {
+      const listener = listenOnce<CustomEvent<OpenFixPreviewEventDetail>>(
+        element,
+        'open-fix-preview'
+      );
+      element.comment = {
+        ...createRobotComment(),
         fix_suggestions: [{...createFixSuggestionInfo()}],
       };
-      element.isRobotComment = true;
-      await flush();
+      await element.updateComplete;
 
       tap(queryAndAssert(element, '.show-fix'));
-      await promise;
+
+      const e = await listener;
+      assert.deepEqual(e.detail, element.getEventPayload());
     });
   });
 
-  suite('respectful tips', () => {
-    let element: GrComment;
-
+  suite('auto saving', () => {
     let clock: sinon.SinonFakeTimers;
-    setup(() => {
-      stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    let savePromise: MockPromise<DraftInfo>;
+    let saveStub: SinonStub;
+
+    setup(async () => {
       clock = sinon.useFakeTimers();
+      savePromise = mockPromise<DraftInfo>();
+      saveStub = sinon
+        .stub(element.getCommentsModel(), 'saveDraft')
+        .returns(savePromise);
+
+      element.comment = createUnsaved();
+      element.editing = true;
+      await element.updateComplete;
     });
 
     teardown(() => {
@@ -1517,85 +697,48 @@
       sinon.restore();
     });
 
-    test('show tip when no cached record', async () => {
-      element = draftFixture.instantiate() as GrComment;
-      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
-      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
-      respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
-      assert.isTrue(respectfulGetStub.called);
-      assert.isTrue(respectfulSetStub.called);
-      assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+    test('basic auto saving', async () => {
+      const textarea = queryAndAssert<HTMLElement>(element, '#editTextarea');
+      dispatch(textarea, 'text-changed', {value: 'some new text  '});
+
+      clock.tick(AUTO_SAVE_DEBOUNCE_DELAY_MS / 2);
+      assert.isFalse(saveStub.called);
+
+      clock.tick(AUTO_SAVE_DEBOUNCE_DELAY_MS);
+      assert.isTrue(saveStub.called);
+      assert.equal(
+        saveStub.firstCall.firstArg.message,
+        'some new text  '.trimEnd()
+      );
     });
 
-    test('add 14-day delays once dismissed', async () => {
-      element = draftFixture.instantiate() as GrComment;
-      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
-      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
-      respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
-      assert.isTrue(respectfulGetStub.called);
-      assert.isTrue(respectfulSetStub.called);
-      assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
-      assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+    test('saving while auto saving', async () => {
+      const textarea = queryAndAssert<HTMLElement>(element, '#editTextarea');
+      dispatch(textarea, 'text-changed', {value: 'auto save text'});
 
-      tap(queryAndAssert(element, '.respectfulReviewTip .close'));
-      flush();
-      assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
-    });
+      clock.tick(2 * AUTO_SAVE_DEBOUNCE_DELAY_MS);
+      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.firstCall.firstArg.message, 'auto save text');
+      saveStub.reset();
 
-    test('do not show tip when fall out of probability', async () => {
-      element = draftFixture.instantiate() as GrComment;
-      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
-      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
-      respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 3;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
-      assert.isTrue(respectfulGetStub.called);
-      assert.isFalse(respectfulSetStub.called);
-      assert.isNotOk(query(element, '.respectfulReviewTip'));
-    });
+      element.messageText = 'actual save text';
+      const save = element.save();
+      await element.updateComplete;
+      // First wait for the auto saving to finish.
+      assert.isFalse(saveStub.called);
 
-    test('show tip when editing changed to true', async () => {
-      element = draftFixture.instantiate() as GrComment;
-      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
-      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
-      respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: false};
-      await flush();
-      assert.isFalse(respectfulGetStub.called);
-      assert.isFalse(respectfulSetStub.called);
-      assert.isNotOk(query(element, '.respectfulReviewTip'));
-
-      element.editing = true;
-      await flush();
-      assert.isTrue(respectfulGetStub.called);
-      assert.isTrue(respectfulSetStub.called);
-      assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
-    });
-
-    test('no tip when cached record', async () => {
-      element = draftFixture.instantiate() as GrComment;
-      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
-      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
-      respectfulGetStub.returns({updated: 0});
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
-      assert.isTrue(respectfulGetStub.called);
-      assert.isFalse(respectfulSetStub.called);
-      assert.isNotOk(query(element, '.respectfulReviewTip'));
+      // Resolve auto-saving promise.
+      savePromise.resolve({
+        ...element.comment,
+        __draft: true,
+        id: 'exp123' as UrlEncodedCommentId,
+        updated: '2018-02-13 22:48:48.018000000' as Timestamp,
+      });
+      await save;
+      // Only then save.
+      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.firstCall.firstArg.message, 'actual save text');
+      assert.equal(saveStub.firstCall.firstArg.id, 'exp123');
     });
   });
 });
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-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 9f65dd4..81b6fd4 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -221,8 +221,10 @@
    *
    * @param noScroll prevent any potential scrolling in response
    * setting the cursor.
+   * @param applyFocus indicates if it should try to focus after move operation
+   * (e.g. focusOnMove).
    */
-  setCursor(element: HTMLElement, noScroll?: boolean) {
+  setCursor(element: HTMLElement, noScroll?: boolean, applyFocus?: boolean) {
     if (!this.targetableStops.includes(element)) {
       this.unsetCursor();
       return;
@@ -238,6 +240,9 @@
     this._updateIndex();
     this._decorateTarget();
 
+    if (applyFocus) {
+      this._focusAfterMove();
+    }
     if (noScroll && behavior) {
       this.scrollMode = behavior;
     }
@@ -341,15 +346,17 @@
       this._targetHeight = this.target.scrollHeight;
     }
 
-    if (this.focusOnMove) {
-      this.target.focus();
-    }
-
     this._decorateTarget();
-
+    this._focusAfterMove();
     return clipped ? CursorMoveResult.CLIPPED : CursorMoveResult.MOVED;
   }
 
+  _focusAfterMove() {
+    if (this.focusOnMove) {
+      this.target?.focus();
+    }
+  }
+
   _decorateTarget() {
     if (this.target && this.cursorTargetClass) {
       this.target.classList.add(this.cursorTargetClass);
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
index d0bd420..47b26b2 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -17,25 +17,22 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-cursor-manager.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {fixture, html} from '@open-wc/testing-helpers';
 import {AbortStop, CursorMoveResult} from '../../../api/core.js';
 import {GrCursorManager} from './gr-cursor-manager.js';
 
-const basicTestFixutre = fixtureFromTemplate(html`
-    <ul>
-      <li>A</li>
-      <li>B</li>
-      <li>C</li>
-      <li>D</li>
-    </ul>
-`);
-
 suite('gr-cursor-manager tests', () => {
   let cursor;
   let list;
 
-  setup(() => {
-    list = basicTestFixutre.instantiate();
+  setup(async () => {
+    list = await fixture(html`
+    <ul>
+      <li>A</li>
+      <li>B</li>
+      <li>C</li>
+      <li>D</li>
+    </ul>`);
     cursor = new GrCursorManager();
     cursor.cursorTargetClass = 'targeted';
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
index 99f9265..497dc94 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
@@ -30,7 +30,7 @@
 import {TimeFormat, DateFormat} from '../../../constants/constants';
 import {assertNever} from '../../../utils/common-util';
 import {Timestamp} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 
 const TimeFormats = {
   TIME_12: 'h:mm A', // 2:14 PM
@@ -107,7 +107,7 @@
   @property({type: Boolean})
   relativeOptionNoAgo = false;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
index 860a7e7..2f446cb 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
@@ -18,17 +18,18 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-date-formatter.js';
 import {parseDate} from '../../../utils/date-util.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {fixture, html} from '@open-wc/testing-helpers';
 import {stubRestApi} from '../../../test/test-utils.js';
 
-const basicFixture = fixtureFromTemplate(html`
-<gr-date-formatter withTooltip dateStr="2015-09-24 23:30:17.033000000">
-</gr-date-formatter>
-`);
+const basicTemplate = html`
+  <gr-date-formatter withTooltip dateStr="2015-09-24 23:30:17.033000000">
+  </gr-date-formatter>
+`;
 
-const lightFixture = fixtureFromTemplate(html`
-<gr-date-formatter dateStr="2015-09-24 23:30:17.033000000"></gr-date-formatter>
-`);
+const lightTemplate = html`
+  <gr-date-formatter dateStr="2015-09-24 23:30:17.033000000">
+  </gr-date-formatter>
+`;
 
 suite('gr-date-formatter tests', () => {
   let element;
@@ -73,15 +74,17 @@
   }
 
   suite('STD + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'STD',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = basicFixture.instantiate();
+    setup(async () => {
+      await stubRestAPI({
+        time_format: 'HHMM_24',
+        date_format: 'STD',
+        relative_date_in_change_table: false,
+      });
+
+      element = await fixture(basicTemplate);
       sinon.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
+      await element._loadPreferences();
+    });
 
     test('invalid dates are quietly rejected', () => {
       assert.notOk((new Date('foo')).valueOf());
@@ -124,15 +127,16 @@
   });
 
   suite('US + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'US',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = basicFixture.instantiate();
+    setup(async () => {
+      await stubRestAPI({
+        time_format: 'HHMM_24',
+        date_format: 'US',
+        relative_date_in_change_table: false,
+      });
+      element = await fixture(basicTemplate);
       sinon.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
+      await element._loadPreferences();
+    });
 
     test('Within 24 hours on same day', async () => {
       await testDates('2015-07-29 20:34:14.985000000',
@@ -160,15 +164,17 @@
   });
 
   suite('ISO + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'ISO',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = basicFixture.instantiate();
+    setup(async () => {
+      await stubRestAPI({
+        time_format: 'HHMM_24',
+        date_format: 'ISO',
+        relative_date_in_change_table: false,
+      });
+
+      element = await fixture(basicTemplate);
       sinon.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
+      await element._loadPreferences();
+    });
 
     test('Within 24 hours on same day', async () => {
       await testDates('2015-07-29 20:34:14.985000000',
@@ -196,15 +202,17 @@
   });
 
   suite('EURO + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'EURO',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = basicFixture.instantiate();
+    setup(async () => {
+      await stubRestAPI({
+        time_format: 'HHMM_24',
+        date_format: 'EURO',
+        relative_date_in_change_table: false,
+      });
+
+      element = await fixture(basicTemplate);
       sinon.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
+      await element._loadPreferences();
+    });
 
     test('Within 24 hours on same day', async () => {
       await testDates('2015-07-29 20:34:14.985000000',
@@ -232,15 +240,17 @@
   });
 
   suite('UK + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'UK',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = basicFixture.instantiate();
+    setup(async () => {
+      stubRestAPI({
+        time_format: 'HHMM_24',
+        date_format: 'UK',
+        relative_date_in_change_table: false,
+      });
+
+      element = await fixture(basicTemplate);
       sinon.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
+      await element._loadPreferences();
+    });
 
     test('Within 24 hours on same day', async () => {
       await testDates('2015-07-29 20:34:14.985000000',
@@ -268,16 +278,13 @@
   });
 
   suite('STD + 12 hours time format preference', () => {
-    setup(() =>
+    setup(async () => {
       // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'STD'}
-      ).then(() => {
-        element = basicFixture.instantiate();
-        sinon.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
+      await stubRestAPI({time_format: 'HHMM_12', date_format: 'STD'});
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element._loadPreferences();
+    });
 
     test('Within 24 hours on same day', async () => {
       await testDates('2015-07-29 20:34:14.985000000',
@@ -289,16 +296,13 @@
   });
 
   suite('US + 12 hours time format preference', () => {
-    setup(() =>
+    setup(async () => {
       // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'US'}
-      ).then(() => {
-        element = basicFixture.instantiate();
-        sinon.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
+      await stubRestAPI({time_format: 'HHMM_12', date_format: 'US'});
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element._loadPreferences();
+    });
 
     test('Within 24 hours on same day', async () => {
       await testDates('2015-07-29 20:34:14.985000000',
@@ -310,16 +314,13 @@
   });
 
   suite('ISO + 12 hours time format preference', () => {
-    setup(() =>
+    setup(async () => {
       // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'ISO'}
-      ).then(() => {
-        element = basicFixture.instantiate();
-        sinon.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
+      await stubRestAPI({time_format: 'HHMM_12', date_format: 'ISO'});
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element._loadPreferences();
+    });
 
     test('Within 24 hours on same day', async () => {
       await testDates('2015-07-29 20:34:14.985000000',
@@ -331,16 +332,13 @@
   });
 
   suite('EURO + 12 hours time format preference', () => {
-    setup(() =>
+    setup(async () => {
       // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'EURO'}
-      ).then(() => {
-        element = basicFixture.instantiate();
-        sinon.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
+      await stubRestAPI({time_format: 'HHMM_12', date_format: 'EURO'});
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element._loadPreferences();
+    });
 
     test('Within 24 hours on same day', async () => {
       await testDates('2015-07-29 20:34:14.985000000',
@@ -352,16 +350,13 @@
   });
 
   suite('UK + 12 hours time format preference', () => {
-    setup(() =>
+    setup(async () => {
       // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'UK'}
-      ).then(() => {
-        element = basicFixture.instantiate();
-        sinon.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
+      stubRestAPI({time_format: 'HHMM_12', date_format: 'UK'});
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element._loadPreferences();
+    });
 
     test('Within 24 hours on same day', async () => {
       await testDates('2015-07-29 20:34:14.985000000',
@@ -373,15 +368,16 @@
   });
 
   suite('relative date preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_12',
-      date_format: 'STD',
-      relative_date_in_change_table: true,
-    }).then(() => {
-      element = basicFixture.instantiate();
+    setup(async () => {
+      stubRestAPI({
+        time_format: 'HHMM_12',
+        date_format: 'STD',
+        relative_date_in_change_table: true,
+      });
+      element = await fixture(basicTemplate);
       sinon.stub(element, '_getUtcOffsetString').returns('');
       return element._loadPreferences();
-    }));
+    });
 
     test('Within 24 hours on same day', async () => {
       await testDates('2015-07-29 20:34:14.985000000',
@@ -401,14 +397,15 @@
   });
 
   suite('logged in', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_12',
-      date_format: 'US',
-      relative_date_in_change_table: true,
-    }).then(() => {
-      element = basicFixture.instantiate();
-      return element._loadPreferences();
-    }));
+    setup(async () => {
+      await stubRestAPI({
+        time_format: 'HHMM_12',
+        date_format: 'US',
+        relative_date_in_change_table: true,
+      });
+      element = await fixture(basicTemplate);
+      await element._loadPreferences();
+    });
 
     test('Preferences are respected', () => {
       assert.equal(element.timeFormat, 'h:mm A');
@@ -419,10 +416,11 @@
   });
 
   suite('logged out', () => {
-    setup(() => stubRestAPI(null).then(() => {
-      element = basicFixture.instantiate();
-      return element._loadPreferences();
-    }));
+    setup(async () => {
+      await stubRestAPI(null);
+      element = await fixture(basicTemplate);
+      await element._loadPreferences();
+    });
 
     test('Default preferences are respected', () => {
       assert.equal(element.timeFormat, 'HH:mm');
@@ -435,7 +433,7 @@
   suite('with tooltip', () => {
     setup(async () => {
       await stubRestAPI(null);
-      element = basicFixture.instantiate();
+      element = await fixture(basicTemplate);
       await element._loadPreferences();
       await element.updateComplete;
     });
@@ -449,7 +447,7 @@
   suite('without tooltip', () => {
     setup(async () => {
       await stubRestAPI(null);
-      element = lightFixture.instantiate();
+      element = await fixture(lightTemplate);
       await element._loadPreferences();
       await element.updateComplete;
     });
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 97ee39e..4df53da 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -51,9 +51,13 @@
   @property({type: String, attribute: 'cancel-label'})
   cancelLabel = 'Cancel';
 
+  // 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;
 
@@ -130,11 +134,11 @@
           </div>
         </main>
         <footer>
-          <slot name="footer"></slot>
           <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}
@@ -143,7 +147,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-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index e560773..44b6fa2 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,148 +14,325 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 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 {getAppContext} from '../../../services/app-context';
+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';
-import {appContext} 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(): Promise<void>;
-}
 
 @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;
 
-  private readonly restApiService = appContext.restApiService;
+  @query('#fontSizeInput') private fontSizeInput?: HTMLInputElement;
 
-  loadData() {
-    return this.restApiService.getDiffPreferences().then(prefs => {
-      this.diffPrefs = prefs;
+  @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;
+
+  override connectedCallback() {
+    super.connectedCallback();
+    subscribe(this, this.userModel.diffPreferences$, diffPreferences => {
+      if (!diffPreferences) return;
+      this.originalDiffPrefs = diffPreferences;
+      this.diffPrefs = {...diffPreferences};
     });
   }
 
-  _handleDiffPrefsChanged() {
-    this.hasUnsavedChanges = true;
+  static override get styles() {
+    return [sharedStyles, formStyles];
   }
 
-  _handleDiffContextChanged() {
-    this.set('diffPrefs.context', Number(this.$.contextLineSelect.value));
-    this._handleDiffPrefsChanged();
+  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>
+    `;
   }
 
-  _handleLineWrappingTap() {
-    this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleDiffLineLengthChanged() {
-    this.set('diffPrefs.line_length', Number(this.$.columnsInput.value));
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleDiffTabSizeChanged() {
-    this.set('diffPrefs.tab_size', Number(this.$.tabSizeInput.value));
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleDiffFontSizeChanged() {
-    this.set('diffPrefs.font_size', Number(this.$.fontSizeInput.value));
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleShowTabsTap() {
-    this.set('diffPrefs.show_tabs', this.$.showTabsInput.checked);
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleShowTrailingWhitespaceTap() {
-    this.set(
-      'diffPrefs.show_whitespace_errors',
-      this.$.showTrailingWhitespaceInput.checked
-    );
-    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();
-  }
-
-  save() {
-    if (!this.diffPrefs)
-      return Promise.reject(new Error('Missing diff preferences'));
-    return this.restApiService.saveDiffPreferences(this.diffPrefs).then(_ => {
-      this.hasUnsavedChanges = false;
+  private readonly handleDiffContextChanged = () => {
+    this.diffPrefs!.context = Number(this.contextLineSelect!.value);
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
     });
+  };
+
+  private readonly handleLineWrappingTap = () => {
+    this.diffPrefs!.line_wrapping = this.lineWrappingInput!.checked;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleDiffLineLengthChanged = () => {
+    this.diffPrefs!.line_length = Number(this.columnsInput!.value);
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleDiffTabSizeChanged = () => {
+    this.diffPrefs!.tab_size = Number(this.tabSizeInput!.value);
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleDiffFontSizeChanged = () => {
+    this.diffPrefs!.font_size = Number(this.fontSizeInput!.value);
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleShowTabsTap = () => {
+    this.diffPrefs!.show_tabs = this.showTabsInput!.checked;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  // 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 (
+      Boolean(this.originalDiffPrefs?.syntax_highlighting) !==
+        Boolean(this.diffPrefs?.syntax_highlighting) ||
+      this.originalDiffPrefs?.context !== this.diffPrefs?.context ||
+      Boolean(this.originalDiffPrefs?.line_wrapping) !==
+        Boolean(this.diffPrefs?.line_wrapping) ||
+      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)
+    );
   }
 
-  /**
-   * 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;
+  async save() {
+    if (!this.diffPrefs) return;
+    await this.userModel.updateDiffPreference(this.diffPrefs);
+    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 6c1404e..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,11 +52,113 @@
 
     element = basicFixture.instantiate();
 
-    await element.loadData();
-    await flush();
+    await element.updateComplete;
   });
 
   test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ ` <div
+      class="gr-form-styles"
+      id="diffPreferences"
+    >
+      <section>
+        <label class="title" for="contextLineSelect">Context</label>
+        <span class="value">
+          <gr-select id="contextSelect">
+            <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 class="title" for="lineWrappingInput">Fit to screen</label>
+        <span class="value">
+          <input id="lineWrappingInput" type="checkbox" />
+        </span>
+      </section>
+      <section>
+        <label class="title" for="columnsInput">Diff width</label>
+        <span class="value">
+          <iron-input>
+            <input id="columnsInput" type="number" />
+          </iron-input>
+        </span>
+      </section>
+      <section>
+        <label class="title" for="tabSizeInput">Tab width</label>
+        <span class="value">
+          <iron-input>
+            <input id="tabSizeInput" type="number" />
+          </iron-input>
+        </span>
+      </section>
+      <section>
+        <label class="title" for="fontSizeInput">Font size</label>
+        <span class="value">
+          <iron-input>
+            <input id="fontSizeInput" type="number" />
+          </iron-input>
+        </span>
+      </section>
+      <section>
+        <label class="title" for="showTabsInput">Show tabs</label>
+        <span class="value">
+          <input checked="" id="showTabsInput" type="checkbox" />
+        </span>
+      </section>
+      <section>
+        <label class="title" for="showTrailingWhitespaceInput">
+          Show trailing whitespace
+        </label>
+        <span class="value">
+          <input checked="" id="showTrailingWhitespaceInput" type="checkbox" />
+        </span>
+      </section>
+      <section>
+        <label class="title" for="syntaxHighlightInput">
+          Syntax highlighting
+        </label>
+        <span class="value">
+          <input checked="" id="syntaxHighlightInput" type="checkbox" />
+        </span>
+      </section>
+      <section>
+        <label class="title" for="automaticReviewInput">
+          Automatically mark viewed files reviewed
+        </label>
+        <span class="value">
+          <input checked="" id="automaticReviewInput" type="checkbox" />
+        </span>
+      </section>
+      <section>
+        <div class="pref">
+          <label class="title" for="ignoreWhiteSpace">
+            Ignore Whitespace
+          </label>
+          <span class="value">
+            <gr-select>
+              <select id="ignoreWhiteSpace">
+                <option value="IGNORE_NONE">None</option>
+                <option value="IGNORE_TRAILING">Trailing</option>
+                <option value="IGNORE_LEADING_AND_TRAILING">
+                  Leading & trailing
+                </option>
+                <option value="IGNORE_ALL">All</option>
+              </select>
+            </gr-select>
+          </span>
+        </div>
+      </section>
+    </div>`);
+  });
+
+  test('renders preferences', () => {
     // Rendered with the expected preferences selected.
     const contextInput = valueOf('Context', 'diffPreferences')
       .firstElementChild as IronInputElement;
@@ -114,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 8322682..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
@@ -14,123 +14,204 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {Subscription} from 'rxjs';
 import '@polymer/paper-tabs/paper-tab';
 import '@polymer/paper-tabs/paper-tabs';
 import '../gr-shell-command/gr-shell-command';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-download-commands_html';
-import {customElement, property} from '@polymer/decorators';
-import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {queryAndAssert} from '../../../utils/common-util';
 import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
-import {preferences$} from '../../../services/user/user-model';
-import {takeUntil} from 'rxjs/operators';
-import {Subject} from 'rxjs';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {fire} from '../../../utils/event-util';
+import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementEventMap {
     'selected-changed': CustomEvent<{value: number}>;
+    'selected-scheme-changed': BindValueChangeEvent;
   }
   interface HTMLElementTagNameMap {
     'gr-download-commands': GrDownloadCommands;
   }
 }
 
-export interface GrDownloadCommands {
-  $: {
-    downloadTabs: PaperTabsElement;
-  };
-}
-
 export interface Command {
   title: string;
   command: string;
 }
 
 @customElement('gr-download-commands')
-export class GrDownloadCommands extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDownloadCommands extends LitElement {
   // TODO(TS): maybe default to [] as only used in dom-repeat
   @property({type: Array})
   commands?: Command[];
 
-  @property({type: Boolean})
-  _loggedIn = false;
+  // private but used in test
+  @state() loggedIn = false;
 
   @property({type: Array})
   schemes: string[] = [];
 
-  @property({type: String, notify: true})
+  @property({type: String})
   selectedScheme?: string;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-keyboard-shortcut-tooltips'})
   showKeyboardShortcutTooltips = false;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userService = appContext.userService;
+  // Private but used in tests.
+  readonly userModel = getAppContext().userModel;
 
-  disconnected$ = new Subject();
+  private subscriptions: Subscription[] = [];
 
   override connectedCallback() {
     super.connectedCallback();
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
+    this.restApiService.getLoggedIn().then(loggedIn => {
+      this.loggedIn = loggedIn;
     });
-    preferences$.pipe(takeUntil(this.disconnected$)).subscribe(prefs => {
-      if (prefs?.download_scheme) {
-        // Note (issue 5180): normalize the download scheme with lower-case.
-        this.selectedScheme = prefs.download_scheme.toLowerCase();
-      }
-    });
+    this.subscriptions.push(
+      this.userModel.preferences$.subscribe(prefs => {
+        if (prefs?.download_scheme) {
+          // Note (issue 5180): normalize the download scheme with lower-case.
+          this.selectedScheme = prefs.download_scheme.toLowerCase();
+          fire(this, 'selected-scheme-changed', {value: this.selectedScheme});
+        }
+      })
+    );
   }
 
   override disconnectedCallback() {
-    this.disconnected$.next();
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
     super.disconnectedCallback();
   }
 
-  focusOnCopy() {
-    queryAndAssert<GrShellCommand>(this, 'gr-shell-command').focusOnCopy();
+  static override get styles() {
+    return [
+      paperStyles,
+      sharedStyles,
+      css`
+        paper-tabs {
+          height: 3rem;
+          margin-bottom: var(--spacing-m);
+          --paper-tabs-selection-bar-color: var(--link-color);
+        }
+        paper-tab {
+          max-width: 15rem;
+          text-transform: uppercase;
+          --paper-tab-ink: var(--link-color);
+        }
+        label,
+        input {
+          display: block;
+        }
+        label {
+          font-weight: var(--font-weight-bold);
+        }
+        .schemes {
+          display: flex;
+          justify-content: space-between;
+        }
+        .commands {
+          display: flex;
+          flex-direction: column;
+        }
+        gr-shell-command {
+          margin-bottom: var(--spacing-m);
+        }
+        .hidden {
+          display: none;
+        }
+      `,
+    ];
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
+  override render() {
+    return html`
+      <div class="schemes">${this.renderDownloadTabs()}</div>
+      ${this.renderCommands()}
+    `;
   }
 
-  _handleTabChange(e: CustomEvent<{value: number}>) {
+  private renderDownloadTabs() {
+    const selectedIndex =
+      this.schemes.findIndex(scheme => scheme === this.selectedScheme) || 0;
+    return html`
+      <paper-tabs
+        id="downloadTabs"
+        class=${this.computeShowTabs()}
+        .selected=${selectedIndex}
+        @selected-changed=${this.handleTabChange}
+      >
+        ${this.schemes.map(scheme => this.renderPaperTab(scheme))}
+      </paper-tabs>
+    `;
+  }
+
+  private renderPaperTab(scheme: string) {
+    return html` <paper-tab data-scheme=${scheme}>${scheme}</paper-tab> `;
+  }
+
+  private renderCommands() {
+    return html`
+      <div class="commands" ?hidden=${!this.schemes.length}></div>
+        ${this.commands?.map((command, index) =>
+          this.renderShellCommand(command, index)
+        )}
+      </div>
+    `;
+  }
+
+  private renderShellCommand(command: Command, index: number) {
+    return html`
+      <gr-shell-command
+        class=${this.computeClass(command.title)}
+        .label=${command.title}
+        .command=${command.command}
+        .tooltip=${this.computeTooltip(index)}
+      ></gr-shell-command>
+    `;
+  }
+
+  async focusOnCopy() {
+    await this.updateComplete;
+    await queryAndAssert<GrShellCommand>(
+      this,
+      'gr-shell-command'
+    ).focusOnCopy();
+  }
+
+  private handleTabChange = (e: CustomEvent<{value: number}>) => {
     const scheme = this.schemes[e.detail.value];
     if (scheme && scheme !== this.selectedScheme) {
-      this.set('selectedScheme', scheme);
-      if (this._loggedIn) {
-        this.userService.updatePreferences({
+      this.selectedScheme = scheme;
+      fire(this, 'selected-scheme-changed', {value: scheme});
+      if (this.loggedIn) {
+        this.userModel.updatePreferences({
           download_scheme: this.selectedScheme,
         });
       }
     }
-  }
+  };
 
-  _computeSelected(schemes: string[], selectedScheme?: string) {
-    return `${schemes.findIndex(scheme => scheme === selectedScheme) || 0}`;
-  }
-
-  _computeShowTabs(schemes: string[]) {
-    return schemes.length > 1 ? '' : 'hidden';
-  }
-
-  _computeTooltip(showKeyboardShortcutTooltips: boolean, index: number) {
-    return index <= 4 && showKeyboardShortcutTooltips
+  private computeTooltip(index: number) {
+    return index <= 4 && this.showKeyboardShortcutTooltips
       ? `Keyboard shortcut: ${index + 1}`
       : '';
   }
 
+  private computeShowTabs() {
+    return this.schemes.length > 1 ? '' : 'hidden';
+  }
+
   // TODO: maybe unify with strToClassName from dom-util
-  _computeClass(title: string) {
+  private computeClass(title: string) {
     // Only retain [a-z] chars, so "Cherry Pick" becomes "cherrypick".
     return '_label_' + title.replace(/[^a-z]+/gi, '').toLowerCase();
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
deleted file mode 100644
index 5a75c13..0000000
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
+++ /dev/null
@@ -1,75 +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">
-    paper-tabs {
-      height: 3rem;
-      margin-bottom: var(--spacing-m);
-      --paper-tabs-selection-bar-color: var(--link-color);
-    }
-    paper-tab {
-      max-width: 15rem;
-      text-transform: uppercase;
-      --paper-tab-ink: var(--link-color);
-    }
-    label,
-    input {
-      display: block;
-    }
-    label {
-      font-weight: var(--font-weight-bold);
-    }
-    .schemes {
-      display: flex;
-      justify-content: space-between;
-    }
-    .commands {
-      display: flex;
-      flex-direction: column;
-    }
-    gr-shell-command {
-      margin-bottom: var(--spacing-m);
-    }
-    .hidden {
-      display: none;
-    }
-  </style>
-  <div class="schemes">
-    <paper-tabs
-      id="downloadTabs"
-      class$="[[_computeShowTabs(schemes)]]"
-      selected="[[_computeSelected(schemes, selectedScheme)]]"
-      on-selected-changed="_handleTabChange"
-    >
-      <template is="dom-repeat" items="[[schemes]]" as="scheme">
-        <paper-tab data-scheme$="[[scheme]]">[[scheme]]</paper-tab>
-      </template>
-    </paper-tabs>
-  </div>
-  <div class="commands" hidden$="[[!schemes.length]]" hidden="">
-    <template is="dom-repeat" items="[[commands]]" as="command" indexAs="index">
-      <gr-shell-command
-        class$="[[_computeClass(command.title)]]"
-        label="[[command.title]]"
-        command="[[command.command]]"
-        tooltip="[[_computeTooltip(showKeyboardShortcutTooltips, index)]]"
-      ></gr-shell-command>
-    </template>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index ef712ac..9efecfd 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -18,12 +18,17 @@
 import '../../../test/common-test-setup-karma';
 import './gr-download-commands';
 import {GrDownloadCommands} from './gr-download-commands';
-import {isHidden, queryAndAssert, stubRestApi} from '../../../test/test-utils';
-import {updatePreferences} from '../../../services/user/user-model';
+import {
+  isHidden,
+  query,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
 import {createPreferences} from '../../../test/test-data-generators';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
 import {createDefaultPreferences} from '../../../constants/constants';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 
 const basicFixture = fixtureFromElement('gr-download-commands');
 
@@ -64,42 +69,54 @@
       element.schemes = SCHEMES;
       element.commands = COMMANDS;
       element.selectedScheme = SELECTED_SCHEME;
-      await flush();
+      await element.updateComplete;
     });
 
-    test('focusOnCopy', () => {
+    test('focusOnCopy', async () => {
       const focusStub = sinon.stub(
         queryAndAssert<GrShellCommand>(element, 'gr-shell-command'),
         'focusOnCopy'
       );
-      element.focusOnCopy();
+      await element.focusOnCopy();
       assert.isTrue(focusStub.called);
     });
 
-    test('element visibility', () => {
+    test('element visibility', async () => {
       assert.isFalse(isHidden(queryAndAssert(element, 'paper-tabs')));
       assert.isFalse(isHidden(queryAndAssert(element, '.commands')));
+      assert.isTrue(Boolean(query(element, '#downloadTabs')));
 
       element.schemes = [];
+      await element.updateComplete;
       assert.isTrue(isHidden(queryAndAssert(element, 'paper-tabs')));
+      assert.isTrue(Boolean(query(element, '.commands')));
       assert.isTrue(isHidden(queryAndAssert(element, '.commands')));
+      // Should still be present but hidden
+      assert.isTrue(Boolean(query(element, '#downloadTabs')));
+      assert.isTrue(isHidden(queryAndAssert(element, '#downloadTabs')));
     });
 
-    test('tab selection', () => {
-      assert.equal(element.$.downloadTabs.selected, '0');
+    test('tab selection', async () => {
+      assert.equal(
+        queryAndAssert<PaperTabsElement>(element, '#downloadTabs').selected,
+        '0'
+      );
       MockInteractions.tap(queryAndAssert(element, '[data-scheme="ssh"]'));
-      flush();
+      await element.updateComplete;
       assert.equal(element.selectedScheme, 'ssh');
-      assert.equal(element.$.downloadTabs.selected, '2');
+      assert.equal(
+        queryAndAssert<PaperTabsElement>(element, '#downloadTabs').selected,
+        '2'
+      );
     });
 
-    test('saves scheme to preferences', () => {
-      element._loggedIn = true;
+    test('saves scheme to preferences', async () => {
+      element.loggedIn = true;
       const savePrefsStub = stubRestApi('savePreferences').returns(
         Promise.resolve(createDefaultPreferences())
       );
 
-      flush();
+      await element.updateComplete;
 
       const repoTab = queryAndAssert(element, 'paper-tab[data-scheme="repo"]');
 
@@ -114,22 +131,22 @@
   });
   suite('authenticated', () => {
     test('loads scheme from preferences', async () => {
-      updatePreferences({
+      const element = basicFixture.instantiate();
+      await element.updateComplete;
+      element.userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'repo',
       });
-      const element = basicFixture.instantiate();
-      await flush();
       assert.equal(element.selectedScheme, 'repo');
     });
 
     test('normalize scheme from preferences', async () => {
-      updatePreferences({
+      const element = basicFixture.instantiate();
+      await element.updateComplete;
+      element.userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'REPO',
       });
-      const element = basicFixture.instantiate();
-      await flush();
       assert.equal(element.selectedScheme, 'repo');
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index 6180f35..109482f 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -28,6 +28,7 @@
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {Timestamp} from '../../../types/common';
 import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
+import {GrButton} from '../gr-button/gr-button';
 
 /**
  * Required values are text and value. mobileText and triggerText will
@@ -52,6 +53,7 @@
 export interface GrDropdownList {
   $: {
     dropdown: IronDropdownElement;
+    trigger: GrButton;
   };
 }
 
@@ -78,7 +80,7 @@
   @property({type: Number})
   initialCount = 75;
 
-  @property({type: Object})
+  @property({type: Array})
   items?: DropdownItem[];
 
   @property({type: String})
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
similarity index 71%
rename from polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
rename to polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
index 909a3bbf..0416bf7 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
@@ -15,44 +15,54 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-dropdown-list.js';
+import '../../../test/common-test-setup-karma';
+import './gr-dropdown-list';
+import {GrDropdownList} from './gr-dropdown-list';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {PaperListboxElement} from '@polymer/paper-listbox';
+import {Timestamp} from '../../../types/common';
 
 const basicFixture = fixtureFromElement('gr-dropdown-list');
 
 suite('gr-dropdown-list tests', () => {
-  let element;
+  let element: GrDropdownList;
 
   setup(() => {
     element = basicFixture.instantiate();
   });
 
   test('hide copy by default', () => {
-    const copyEl = element.shadowRoot
-        .querySelector('#triggerText + gr-copy-clipboard');
-    assert.isTrue(!!copyEl);
+    const copyEl = query<HTMLElement>(
+      element,
+      '#triggerText + gr-copy-clipboard'
+    )!;
+    assert.isOk(copyEl);
     assert.isTrue(copyEl.hidden);
   });
 
   test('show copy if enabled', () => {
     element.showCopyForTriggerText = true;
     flush();
-    const copyEl = element.shadowRoot.querySelector(
-        '#triggerText + gr-copy-clipboard');
-    assert.isTrue(!!copyEl);
+    const copyEl = query<HTMLElement>(
+      element,
+      '#triggerText + gr-copy-clipboard'
+    )!;
+    assert.isOk(copyEl);
     assert.isFalse(copyEl.hidden);
   });
 
   test('tap on trigger opens menu', () => {
-    sinon.stub(element, 'open')
-        .callsFake(() => { element.$.dropdown.open(); });
+    sinon.stub(element, 'open').callsFake(() => {
+      element.$.dropdown.open();
+    });
     assert.isFalse(element.$.dropdown.opened);
     MockInteractions.tap(element.$.trigger);
     assert.isTrue(element.$.dropdown.opened);
   });
 
   test('_computeMobileText', () => {
-    const item = {
+    const item: any = {
       value: 1,
       text: 'text',
     };
@@ -80,18 +90,20 @@
         disabled: true,
         bottomText: 'Bottom Text 3',
         triggerText: 'Button Text 3',
-        date: '2017-08-18 23:11:42.569000000',
+        date: '2017-08-18 23:11:42.569000000' as Timestamp,
         text: 'Top Text 3',
         mobileText: 'Mobile Text 3',
       },
     ];
-    assert.equal(element.shadowRoot
-        .querySelector('paper-listbox').selected, element.value);
+    assert.equal(
+      queryAndAssert<PaperListboxElement>(element, 'paper-listbox').selected,
+      element.value
+    );
     assert.equal(element.text, 'Button Text 2');
     await flush();
 
-    const items = element.root.querySelectorAll('paper-item');
-    const mobileItems = element.root.querySelectorAll('option');
+    const items = queryAll<HTMLInputElement>(element, 'paper-item');
+    const mobileItems = queryAll<HTMLOptionElement>(element, 'option');
     assert.equal(items.length, 3);
     assert.equal(mobileItems.length, 3);
 
@@ -104,10 +116,12 @@
 
     assert.isNotOk(items[0].querySelector('gr-date-formatter'));
     assert.isNotOk(items[0].querySelector('.bottomContent'));
-    assert.equal(items[0].dataset.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(items[0].querySelector('.topContent div')
-        .innerText, element.items[0].text);
+    assert.equal(
+      queryAndAssert<HTMLDivElement>(items[0], '.topContent div').innerText,
+      element.items[0].text
+    );
 
     // Since no mobile specific text, it should fall back to text.
     assert.equal(mobileItems[0].text, element.items[0].text);
@@ -121,10 +135,12 @@
 
     assert.isNotOk(items[1].querySelector('gr-date-formatter'));
     assert.isOk(items[1].querySelector('.bottomContent'));
-    assert.equal(items[1].dataset.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(items[1].querySelector('.topContent div')
-        .innerText, element.items[1].text);
+    assert.equal(
+      queryAndAssert<HTMLDivElement>(items[1], '.topContent div').innerText,
+      element.items[1].text
+    );
 
     // Since there is mobile specific text, it should that.
     assert.equal(mobileItems[1].text, element.items[1].mobileText);
@@ -142,10 +158,12 @@
 
     assert.isOk(items[2].querySelector('gr-date-formatter'));
     assert.isOk(items[2].querySelector('.bottomContent'));
-    assert.equal(items[2].dataset.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(items[2].querySelector('.topContent div')
-        .innerText, element.items[2].text);
+    assert.equal(
+      queryAndAssert<HTMLDivElement>(items[2], '.topContent div').innerText,
+      element.items[2].text
+    );
 
     // Since there is mobile specific text, it should that.
     assert.equal(mobileItems[2].text, element.items[2].mobileText);
@@ -161,4 +179,3 @@
     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 2b56de6..f62278a 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -133,19 +133,16 @@
   override connectedCallback() {
     super.connectedCallback();
     this.cleanups.push(
-      addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
+      addShortcut(this, {key: Key.UP}, () => this._handleUp())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
+      addShortcut(this, {key: Key.DOWN}, () => this._handleDown())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
+      addShortcut(this, {key: Key.ENTER}, () => this._handleEnter())
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.SPACE}, e => this._handleEnter(e))
+      addShortcut(this, {key: Key.SPACE}, () => this._handleEnter())
     );
   }
 
@@ -159,10 +156,8 @@
   /**
    * Handle the up key.
    */
-  _handleUp(e: Event) {
+  _handleUp() {
     if (this.$.dropdown.opened) {
-      e.preventDefault();
-      e.stopPropagation();
       this.cursor.previous();
     } else {
       this._open();
@@ -172,10 +167,8 @@
   /**
    * Handle the down key.
    */
-  _handleDown(e: Event) {
+  _handleDown() {
     if (this.$.dropdown.opened) {
-      e.preventDefault();
-      e.stopPropagation();
       this.cursor.next();
     } else {
       this._open();
@@ -183,22 +176,9 @@
   }
 
   /**
-   * Handle the tab key.
-   */
-  _handleTab(e: Event) {
-    if (this.$.dropdown.opened) {
-      // Tab in a native select is a no-op. Emulate this.
-      e.preventDefault();
-      e.stopPropagation();
-    }
-  }
-
-  /**
    * Handle the enter key.
    */
-  _handleEnter(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
+  _handleEnter() {
     if (this.$.dropdown.opened) {
       // TODO(milutin): This solution is not particularly robust in general.
       // Since gr-tooltip-content click on shadow dom is not propagated down,
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 866ee5a..5d0f52a 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
@@ -17,15 +17,24 @@
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
-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 {appContext} from '../../../services/app-context';
+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 {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;
@@ -34,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.
    *
@@ -60,57 +69,39 @@
    * @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})
+  @property({
+    type: Boolean,
+    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 = appContext.storageService;
+  private readonly storage = getAppContext().storageService;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   // Tests use this so needs to be non private
   storeTask?: DelayedTask;
@@ -120,12 +111,209 @@
     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);
+        }
+        .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);
+        }
+      `,
+    ];
+  }
+
+  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>
+          `
+        )}
+        ${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() {
@@ -135,15 +323,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
@@ -157,8 +345,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:
     //
@@ -172,7 +360,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) {
@@ -189,73 +381,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 7877a1f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
+++ /dev/null
@@ -1,153 +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-top-width: 1px;
-      border-top-style: solid;
-      border-radius: 0 0 4px 4px;
-      border-color: var(--border-color);
-      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;
-    }
-    .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>
-  <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>
-  <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>
-`;
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..9b30591 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,103 @@
 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>
+        <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 +122,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 +182,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 +194,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 e0d1d15..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,63 +326,61 @@
     );
   }
 
-  _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;
+    }
     const inputContainer = queryAndAssert(this, '.inputContainer');
     const isEventFromInput = event
       .composedPath()
       .some(element => element === inputContainer);
     if (isEventFromInput) {
-      event.preventDefault();
-      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) {
-      event.preventDefault();
-      this._cancel();
+      this.cancel();
     }
   }
 
-  _handleCommit() {
-    this._save();
+  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.js
deleted file mode 100644
index b6bb87b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
+++ /dev/null
@@ -1,204 +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-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>
-`);
-
-suite('gr-editable-label tests', () => {
-  let element;
-  let elementNoPlaceholder;
-  let input;
-  let label;
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    elementNoPlaceholder = noPlaceholderFixture.instantiate();
-    flush();
-    label = element.shadowRoot.querySelector('label');
-
-    await flush();
-    // In Polymer 2 inputElement isn't nativeInput anymore
-    const paperInput = element.shadowRoot.querySelector('#input');
-    input = paperInput.$.nativeInput || paperInput.inputElement;
-  });
-
-  test('element render', () => {
-    // The dropdown is closed and the label is visible:
-    assert.isFalse(element.$.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');
-    });
-  });
-
-  test('title with placeholder', () => {
-    assert.equal(element.title, 'value text');
-    element.value = '';
-
-    flush();
-    assert.equal(element.title, 'label text');
-  });
-
-  test('title without placeholder', () => {
-    assert.equal(elementNoPlaceholder.title, '');
-    element.value = 'value text';
-
-    flush();
-    assert.equal(element.title, 'value text');
-  });
-
-  test('edit value', async () => {
-    const editedSpy = sinon.spy();
-    element.addEventListener('changed', editedSpy);
-    assert.isFalse(element.editing);
-
-    MockInteractions.tap(label);
-    flush();
-
-    assert.isTrue(element.editing);
-    assert.isFalse(editedSpy.called);
-
-    element._inputText = 'new text';
-    // Press enter:
-    MockInteractions.keyDownOn(input, 13, null, 'Enter');
-    flush();
-
-    assert.isTrue(editedSpy.called);
-    assert.equal(input.value, 'new text');
-    assert.isFalse(element.editing);
-  });
-
-  test('save button', () => {
-    const editedSpy = sinon.spy();
-    element.addEventListener('changed', editedSpy);
-    assert.isFalse(element.editing);
-
-    MockInteractions.tap(label);
-    flush();
-
-    assert.isTrue(element.editing);
-    assert.isFalse(editedSpy.called);
-
-    element._inputText = 'new text';
-    // Press enter:
-    MockInteractions.tap(element.$.saveBtn, 13, null, 'Enter');
-    flush();
-
-    assert.isTrue(editedSpy.called);
-    assert.equal(input.value, 'new text');
-    assert.isFalse(element.editing);
-  });
-
-  test('edit and then escape key', () => {
-    const editedSpy = sinon.spy();
-    element.addEventListener('changed', editedSpy);
-    assert.isFalse(element.editing);
-
-    MockInteractions.tap(label);
-    flush();
-
-    assert.isTrue(element.editing);
-    assert.isFalse(editedSpy.called);
-
-    element._inputText = 'new text';
-    // Press escape:
-    MockInteractions.keyDownOn(input, 27, null, 'Escape');
-    flush();
-
-    assert.isFalse(editedSpy.called);
-    // Text changes should be discarded.
-    assert.equal(input.value, 'value text');
-    assert.isFalse(element.editing);
-  });
-
-  test('cancel button', () => {
-    const editedSpy = sinon.spy();
-    element.addEventListener('changed', editedSpy);
-    assert.isFalse(element.editing);
-
-    MockInteractions.tap(label);
-    flush();
-
-    assert.isTrue(element.editing);
-    assert.isFalse(editedSpy.called);
-
-    element._inputText = 'new text';
-    // Press escape:
-    MockInteractions.tap(element.$.cancelBtn);
-    flush();
-
-    assert.isFalse(editedSpy.called);
-    // Text changes should be discarded.
-    assert.equal(input.value, 'value text');
-    assert.isFalse(element.editing);
-  });
-
-  suite('gr-editable-label read-only tests', () => {
-    let element;
-    let label;
-
-    setup(() => {
-      element = readOnlyFixture.instantiate();
-      flush();
-      label = element.shadowRoot
-          .querySelector('label');
-    });
-
-    test('disallows edit when read-only', () => {
-      // The dropdown is closed.
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.tap(label);
-
-      flush();
-
-      // The dropdown is still closed.
-      assert.isFalse(element.$.dropdown.opened);
-    });
-
-    test('label is not marked as editable', () => {
-      assert.isFalse(label.classList.contains('editable'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
new file mode 100644
index 0000000..a439d05
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
@@ -0,0 +1,257 @@
+/**
+ * @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 './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: GrEditableLabel;
+  let elementNoPlaceholder: GrEditableLabel;
+  let input: HTMLInputElement;
+  let label: HTMLLabelElement;
+
+  setup(async () => {
+    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');
+
+    // In Polymer 2 inputElement isn't nativeInput anymore
+    const paperInput = queryAndAssert<PaperInputElement>(element, '#input');
+    input = (paperInput.$.nativeInput ||
+      paperInput.inputElement) as HTMLInputElement;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(`<label
+      aria-label="value text"
+      class="editable"
+      part="label"
+      title="value text"
+    >
+      value text
+    </label>
+    <iron-dropdown
+      allowoutsidescroll=""
+      aria-disabled="false"
+      aria-hidden="true"
+      horizontal-align="auto"
+      id="dropdown"
+      style="outline: none; display: none;"
+      vertical-align="auto"
+    >
+      <div class="dropdown-content" slot="dropdown-content">
+        <div class="inputContainer" part="input-container">
+          <paper-input
+            aria-disabled="false"
+            id="input"
+            tabindex="0"
+          ></paper-input>
+          <div class="buttons">
+            <gr-button
+              aria-disabled="false"
+              id="cancelBtn"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              cancel
+            </gr-button>
+            <gr-button
+              aria-disabled="false"
+              id="saveBtn"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              save
+            </gr-button>
+          </div>
+        </div>
+      </div>
+    </iron-dropdown>`);
+  });
+
+  test('element render', async () => {
+    // The dropdown is closed and the label is visible:
+    const dropdown = queryAndAssert<IronDropdownElement>(element, '#dropdown');
+    assert.isFalse(dropdown.opened);
+    assert.isTrue(label.classList.contains('editable'));
+    assert.equal(label.textContent, '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', async () => {
+    assert.equal(element.title, 'value text');
+    element.value = '';
+
+    await element.updateComplete;
+    assert.equal(element.title, 'label text');
+  });
+
+  test('title without placeholder', async () => {
+    assert.equal(elementNoPlaceholder.title, '');
+    element.value = 'value text';
+
+    await flush();
+    assert.equal(element.title, 'value text');
+  });
+
+  test('edit value', async () => {
+    const editedSpy = sinon.spy();
+    element.addEventListener('changed', editedSpy);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+    await flush();
+
+    assert.isTrue(element.editing);
+    assert.isFalse(editedSpy.called);
+
+    element.inputText = 'new text';
+    // Press enter:
+    MockInteractions.keyDownOn(input, 13, null, 'Enter');
+    await flush();
+
+    assert.isTrue(editedSpy.called);
+    assert.equal(input.value, 'new text');
+    assert.isFalse(element.editing);
+  });
+
+  test('save button', async () => {
+    const editedSpy = sinon.spy();
+    element.addEventListener('changed', editedSpy);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+    await flush();
+
+    assert.isTrue(element.editing);
+    assert.isFalse(editedSpy.called);
+
+    element.inputText = 'new text';
+    // Press enter:
+    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', async () => {
+    const editedSpy = sinon.spy();
+    element.addEventListener('changed', editedSpy);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+    await flush();
+
+    assert.isTrue(element.editing);
+    assert.isFalse(editedSpy.called);
+
+    element.inputText = 'new text';
+    // Press escape:
+    MockInteractions.keyDownOn(input, 27, null, 'Escape');
+    await flush();
+
+    assert.isFalse(editedSpy.called);
+    // Text changes should be discarded.
+    assert.equal(input.value, 'value text');
+    assert.isFalse(element.editing);
+  });
+
+  test('cancel button', async () => {
+    const editedSpy = sinon.spy();
+    element.addEventListener('changed', editedSpy);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+    await flush();
+
+    assert.isTrue(element.editing);
+    assert.isFalse(editedSpy.called);
+
+    element.inputText = 'new text';
+    // Press escape:
+    MockInteractions.tap(queryAndAssert<GrButton>(element, '#cancelBtn'));
+    await flush();
+
+    assert.isFalse(editedSpy.called);
+    // Text changes should be discarded.
+    assert.equal(input.value, 'value text');
+    assert.isFalse(element.editing);
+  });
+
+  suite('gr-editable-label read-only tests', () => {
+    let element: GrEditableLabel;
+    let label: HTMLLabelElement;
+
+    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', async () => {
+      // The dropdown is closed.
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        '#dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+      label.click();
+
+      await element.updateComplete;
+
+      // The dropdown is still closed.
+      assert.isFalse(dropdown.opened);
+    });
+
+    test('label is not marked as editable', () => {
+      assert.isFalse(label.classList.contains('editable'));
+    });
+  });
+});
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 17621c3..44a176d 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
@@ -20,18 +20,50 @@
 import {customElement, property} from 'lit/decorators';
 
 const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
+const INLINE_PATTERN = /(\[.+?\]\(.+?\)|`[^`]+?`)/;
+const EXTRACT_LINK_PATTERN = /\[(.+?)\]\((.+?)\)/;
 
-export type Block = ListBlock | QuoteBlock | TextBlock;
+export type Block = ListBlock | QuoteBlock | Paragraph | CodeBlock | PreBlock;
 export interface ListBlock {
   type: 'list';
-  items: string[];
+  items: ListItem[];
 }
+export interface ListItem {
+  spans: InlineItem[];
+}
+
 export interface QuoteBlock {
   type: 'quote';
   blocks: Block[];
 }
-export interface TextBlock {
-  type: 'paragraph' | 'code' | 'pre';
+export interface Paragraph {
+  type: 'paragraph';
+  spans: InlineItem[];
+}
+export interface CodeBlock {
+  type: 'code';
+  text: string;
+}
+export interface PreBlock {
+  type: 'pre';
+  text: string;
+}
+
+export type InlineItem = TextSpan | LinkSpan | CodeSpan;
+
+export interface TextSpan {
+  type: 'text';
+  text: string;
+}
+
+export interface LinkSpan {
+  type: 'link';
+  text: string;
+  url: string;
+}
+
+export interface CodeSpan {
+  type: 'code';
   text: string;
 }
 
@@ -58,6 +90,9 @@
           display: block;
           font-family: var(--font-family);
         }
+        a {
+          color: var(--link-color);
+        }
         p,
         ul,
         code,
@@ -77,7 +112,6 @@
         :host([noTrailingMargin]) gr-linked-text.pre:last-child {
           margin: 0;
         }
-        code,
         blockquote {
           border-left: 1px solid #aaa;
           padding: 0 var(--spacing-m);
@@ -85,18 +119,25 @@
         code {
           display: block;
           white-space: pre-wrap;
-          color: var(--deemphasized-text-color);
+          background-color: var(--background-color-secondary);
+          border: 1px solid var(--border-color);
+          border-left-width: var(--spacing-s);
+          margin: var(--spacing-m) 0;
+          padding: var(--spacing-s) var(--spacing-m);
+          overflow-x: scroll;
         }
         li {
           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);
-          /* usually 16px = 12px + 4px */
-          line-height: calc(var(--font-size-code) + var(--spacing-s));
+          line-height: var(--line-height-mono);
+          background-color: var(--background-color-secondary);
+          border: 1px solid var(--border-color);
+          padding: 1px var(--spacing-s);
         }
       `,
     ];
@@ -111,13 +152,16 @@
   /**
    * Given a source string, parse into an array of block objects. Each block
    * has a `type` property which takes any of the following values.
-   * * 'paragraph'
+   * * 'paragraph' (Paragraph of regular text)
    * * 'quote' (Block quote.)
    * * 'pre' (Pre-formatted text.)
    * * 'list' (Unordered list.)
    * * 'code' (code blocks.)
    *
-   * For blocks of type 'paragraph', 'pre' and 'code' there is a `text`
+   * For blocks of type 'paragraph' there is a list of spans that is the content
+   * for that paragraph.
+   *
+   * For blocks of type 'pre' and 'code' there is a `text`
    * property that maps to a string of the block's content.
    *
    * For blocks of type 'list', there is an `items` property that maps to a
@@ -198,7 +242,9 @@
         );
         result.push({
           type: 'paragraph',
-          text: lines.slice(i, endOfRegularLines).join('\n'),
+          spans: this.computeInlineItems(
+            lines.slice(i, endOfRegularLines).join('\n')
+          ),
         });
         i = endOfRegularLines - 1;
       }
@@ -207,6 +253,41 @@
     return result;
   }
 
+  private computeInlineItems(content: string): InlineItem[] {
+    const result: InlineItem[] = [];
+    const textSpans = content.split(INLINE_PATTERN);
+    for (let i = 0; i < textSpans.length; ++i) {
+      // Because INLINE_PATTERN has a single capturing group, string.split will
+      // return strings before and after each match as well as the matched
+      // group. These are always interleaved starting with a non-matched string
+      // which may be empty.
+      if (textSpans[i].length === 0) {
+        // No point in processing empty strings.
+        continue;
+      } else if (i % 2 === 0) {
+        // A non-matched string.
+        result.push({type: 'text', text: textSpans[i]});
+      } else if (textSpans[i].startsWith('`')) {
+        result.push({type: 'code', text: textSpans[i].slice(1, -1)});
+      } else {
+        const m = textSpans[i].match(EXTRACT_LINK_PATTERN);
+        if (!m) {
+          result.push({type: 'text', text: textSpans[i]});
+        } else {
+          // eslint-disable-next-line @typescript-eslint/no-unused-vars
+          const [_, text, url] = m;
+          // Disallow javascript protocol in the href as an XSS mitigation
+          if (url.trimStart().startsWith('javascript:')) {
+            result.push({type: 'link', text, url: ''});
+          } else {
+            result.push({type: 'link', text, url});
+          }
+        }
+      }
+    }
+    return result;
+  }
+
   private getEndOfSection(
     lines: string[],
     startIndex: number,
@@ -231,8 +312,14 @@
    * @param lines The block containing the list.
    */
   private makeList(lines: string[]): Block {
-    const items = lines.map(line => line.substring(1).trim());
-    return {type: 'list', items};
+    return {
+      type: 'list',
+      items: lines.map(line => {
+        return {
+          spans: this.computeInlineItems(line.substring(1).trim()),
+        };
+      }),
+    };
   }
 
   private isRegularLine(line: string): boolean {
@@ -269,21 +356,50 @@
     return /^\s+$/.test(line);
   }
 
-  private renderLinkedText(content: string, isPre?: boolean): TemplateResult {
+  private renderInlineText(content: string): TemplateResult {
     return html`
       <gr-linked-text
-        class="${isPre ? 'pre' : ''}"
         .config=${this.config}
         content=${content}
         pre
+        inline
       ></gr-linked-text>
     `;
   }
 
+  private renderLink(text: string, url: string): TemplateResult {
+    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 {
+    switch (span.type) {
+      case 'text':
+        return this.renderInlineText(span.text);
+      case 'link':
+        return this.renderLink(span.text, span.url);
+      case 'code':
+        return this.renderInlineCode(span.text);
+      default:
+        return html``;
+    }
+  }
+
+  private renderListItem(item: ListItem): TemplateResult {
+    return html` <li>
+      ${item.spans.map(item => this.renderInlineItem(item))}
+    </li>`;
+  }
+
   private renderBlock(block: Block): TemplateResult {
     switch (block.type) {
       case 'paragraph':
-        return html`<p>${this.renderLinkedText(block.text)}</p>`;
+        return html` <p>
+          ${block.spans.map(item => this.renderInlineItem(item))}
+        </p>`;
       case 'quote':
         return html`
           <blockquote>
@@ -293,13 +409,11 @@
       case 'code':
         return html`<code>${block.text}</code>`;
       case 'pre':
-        return this.renderLinkedText(block.text, true);
+        return html`<pre><code>${block.text}</code></pre>`;
       case 'list':
         return html`
           <ul>
-            ${block.items.map(
-              item => html`<li>${this.renderLinkedText(item)}</li>`
-            )}
+            ${block.items.map(item => this.renderListItem(item))}
           </ul>
         `;
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index 8cecf71..3a23a38 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -21,8 +21,14 @@
   GrFormattedText,
   Block,
   ListBlock,
-  TextBlock,
+  Paragraph,
   QuoteBlock,
+  PreBlock,
+  CodeBlock,
+  InlineItem,
+  ListItem,
+  TextSpan,
+  LinkSpan,
 } from './gr-formatted-text';
 
 const basicFixture = fixtureFromElement('gr-formatted-text');
@@ -30,13 +36,42 @@
 suite('gr-formatted-text tests', () => {
   let element: GrFormattedText;
 
-  function assertTextBlock(block: Block, type: string, text: string) {
-    assert.equal(block.type, type);
-    const textBlock = block as TextBlock;
-    assert.equal(textBlock.text, text);
+  function assertSpan(actual: InlineItem, expected: InlineItem) {
+    assert.equal(actual.type, expected.type);
+    assert.equal(actual.text, expected.text);
+    switch (actual.type) {
+      case 'link':
+        assert.equal(actual.url, (expected as LinkSpan).url);
+        break;
+    }
   }
 
-  function assertListBlock(block: Block, items: string[]) {
+  function assertTextBlock(block: Block, spans: InlineItem[]) {
+    assert.equal(block.type, 'paragraph');
+    const paragraph = block as Paragraph;
+    assert.equal(paragraph.spans.length, spans.length);
+    for (let i = 0; i < paragraph.spans.length; ++i) {
+      assertSpan(paragraph.spans[i], spans[i]);
+    }
+  }
+
+  function assertPreBlock(block: Block, text: string) {
+    assert.equal(block.type, 'pre');
+    const preBlock = block as PreBlock;
+    assert.equal(preBlock.text, text);
+  }
+
+  function assertCodeBlock(block: Block, text: string) {
+    assert.equal(block.type, 'code');
+    const preBlock = block as CodeBlock;
+    assert.equal(preBlock.text, text);
+  }
+
+  function assertSimpleTextBlock(block: Block, text: string) {
+    assertTextBlock(block, [{type: 'text', text}]);
+  }
+
+  function assertListBlock(block: Block, items: ListItem[]) {
     assert.equal(block.type, 'list');
     const listBlock = block as ListBlock;
     assert.deepEqual(listBlock.items, items);
@@ -55,25 +90,48 @@
     assert.lengthOf(element._computeBlocks(''), 0);
   });
 
-  test('parse simple', () => {
-    const comment = 'Para1';
+  for (const text of [
+    'Para1',
+    'Para 1\nStill para 1',
+    'Para 1\n\nPara 2\n\nPara 3',
+  ]) {
+    test('parse simple', () => {
+      const comment = {type: 'text', text} as TextSpan;
+      const result = element._computeBlocks(text);
+      assert.lengthOf(result, 1);
+      assertTextBlock(result[0], [comment]);
+    });
+  }
+
+  test('parse link', () => {
+    const comment = '[text](url)';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'paragraph', comment);
+    assertTextBlock(result[0], [{type: 'link', text: 'text', url: 'url'}]);
   });
 
-  test('parse multiline para', () => {
-    const comment = 'Para 1\nStill para 1';
+  test('link with javascript protocol does not set href', () => {
+    const comment = '[text](javascript:alert`1`)';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'paragraph', comment);
+    assertTextBlock(result[0], [{type: 'link', text: 'text', url: ''}]);
   });
 
-  test('parse para break without special blocks', () => {
-    const comment = 'Para 1\n\nPara 2\n\nPara 3';
+  test('link with whitespace and javascript protocol does not set href', () => {
+    const comment = '[text](   javascript:alert`1`)';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'paragraph', comment);
+    assertTextBlock(result[0], [{type: 'link', text: 'text', url: ''}]);
+  });
+
+  test('parse inline code', () => {
+    const comment = 'text `code`';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertTextBlock(result[0], [
+      {type: 'text', text: 'text '},
+      {type: 'code', text: 'code'},
+    ]);
   });
 
   test('parse quote', () => {
@@ -82,7 +140,7 @@
     assert.lengthOf(result, 1);
     const quoteBlock = assertQuoteBlock(result[0]);
     assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(quoteBlock.blocks[0], 'paragraph', 'Quote text');
+    assertSimpleTextBlock(quoteBlock.blocks[0], 'Quote text');
   });
 
   test('parse quote lead space', () => {
@@ -91,7 +149,7 @@
     assert.lengthOf(result, 1);
     const quoteBlock = assertQuoteBlock(result[0]);
     assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(quoteBlock.blocks[0], 'paragraph', 'Quote text');
+    assertSimpleTextBlock(quoteBlock.blocks[0], 'Quote text');
   });
 
   test('parse multiline quote', () => {
@@ -100,9 +158,8 @@
     assert.lengthOf(result, 1);
     const quoteBlock = assertQuoteBlock(result[0]);
     assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(
+    assertSimpleTextBlock(
       quoteBlock.blocks[0],
-      'paragraph',
       'Quote line 1\nQuote line 2\nQuote line 3'
     );
   });
@@ -111,42 +168,55 @@
     const comment = '    Four space indent.';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'pre', comment);
+    assertPreBlock(result[0], comment);
   });
 
   test('parse one space pre', () => {
     const comment = ' One space indent.\n Another line.';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'pre', comment);
+    assertPreBlock(result[0], comment);
   });
 
   test('parse tab pre', () => {
     const comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'pre', comment);
+    assertPreBlock(result[0], comment);
   });
 
   test('parse star list', () => {
     const comment = '* Item 1\n* Item 2\n* Item 3';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 1);
-    assertListBlock(result[0], ['Item 1', 'Item 2', 'Item 3']);
+    assertListBlock(result[0], [
+      {spans: [{type: 'text', text: 'Item 1'}]},
+      {spans: [{type: 'text', text: 'Item 2'}]},
+      {spans: [{type: 'text', text: 'Item 3'}]},
+    ]);
   });
 
   test('parse dash list', () => {
     const comment = '- Item 1\n- Item 2\n- Item 3';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 1);
-    assertListBlock(result[0], ['Item 1', 'Item 2', 'Item 3']);
+    assertListBlock(result[0], [
+      {spans: [{type: 'text', text: 'Item 1'}]},
+      {spans: [{type: 'text', text: 'Item 2'}]},
+      {spans: [{type: 'text', text: 'Item 3'}]},
+    ]);
   });
 
   test('parse mixed list', () => {
     const comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 1);
-    assertListBlock(result[0], ['Item 1', 'Item 2', 'Item 3', 'Item 4']);
+    assertListBlock(result[0], [
+      {spans: [{type: 'text', text: 'Item 1'}]},
+      {spans: [{type: 'text', text: 'Item 2'}]},
+      {spans: [{type: 'text', text: 'Item 3'}]},
+      {spans: [{type: 'text', text: 'Item 4'}]},
+    ]);
   });
 
   test('parse mixed block types', () => {
@@ -166,58 +236,67 @@
       'Parting words.';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 7);
-    assertTextBlock(
-      result[0],
-      'paragraph',
-      'Paragraph\nacross\na\nfew\nlines.\n'
-    );
+    assertSimpleTextBlock(result[0], 'Paragraph\nacross\na\nfew\nlines.\n');
 
     const quoteBlock = assertQuoteBlock(result[1]);
     assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(
+    assertSimpleTextBlock(
       quoteBlock.blocks[0],
-      'paragraph',
       'Quote\nacross\nnot many lines.'
     );
 
-    assertTextBlock(result[2], 'paragraph', 'Another paragraph\n');
-    assertListBlock(result[3], ['Series', 'of', 'list', 'items']);
-    assertTextBlock(result[4], 'paragraph', 'Yet another paragraph\n');
-    assertTextBlock(result[5], 'pre', '\tPreformatted text.');
-    assertTextBlock(result[6], 'paragraph', 'Parting words.');
+    assertSimpleTextBlock(result[2], 'Another paragraph\n');
+    assertListBlock(result[3], [
+      {spans: [{type: 'text', text: 'Series'}]},
+      {spans: [{type: 'text', text: 'of'}]},
+      {spans: [{type: 'text', text: 'list'}]},
+      {spans: [{type: 'text', text: 'items'}]},
+    ]);
+    assertSimpleTextBlock(result[4], 'Yet another paragraph\n');
+    assertPreBlock(result[5], '\tPreformatted text.');
+    assertSimpleTextBlock(result[6], 'Parting words.');
   });
 
   test('bullet list 1', () => {
     const comment = 'A\n\n* line 1';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'A\n');
-    assertListBlock(result[1], ['line 1']);
+    assertSimpleTextBlock(result[0], 'A\n');
+    assertListBlock(result[1], [{spans: [{type: 'text', text: 'line 1'}]}]);
   });
 
   test('bullet list 2', () => {
     const comment = 'A\n\n* line 1\n* 2nd line';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'A\n');
-    assertListBlock(result[1], ['line 1', '2nd line']);
+    assertSimpleTextBlock(result[0], 'A\n');
+    assertListBlock(result[1], [
+      {spans: [{type: 'text', text: 'line 1'}]},
+      {spans: [{type: 'text', text: '2nd line'}]},
+    ]);
   });
 
   test('bullet list 3', () => {
     const comment = 'A\n* line 1\n* 2nd line\n\nB';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 3);
-    assertTextBlock(result[0], 'paragraph', 'A');
-    assertListBlock(result[1], ['line 1', '2nd line']);
-    assertTextBlock(result[2], 'paragraph', 'B');
+    assertSimpleTextBlock(result[0], 'A');
+    assertListBlock(result[1], [
+      {spans: [{type: 'text', text: 'line 1'}]},
+      {spans: [{type: 'text', text: '2nd line'}]},
+    ]);
+    assertSimpleTextBlock(result[2], 'B');
   });
 
   test('bullet list 4', () => {
     const comment = '* line 1\n* 2nd line\n\nB';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 2);
-    assertListBlock(result[0], ['line 1', '2nd line']);
-    assertTextBlock(result[1], 'paragraph', 'B');
+    assertListBlock(result[0], [
+      {spans: [{type: 'text', text: 'line 1'}]},
+      {spans: [{type: 'text', text: '2nd line'}]},
+    ]);
+    assertSimpleTextBlock(result[1], 'B');
   });
 
   test('bullet list 5', () => {
@@ -227,10 +306,10 @@
       '* Be very unlucky\n';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'To see this bug, you have to:');
+    assertSimpleTextBlock(result[0], 'To see this bug, you have to:');
     assertListBlock(result[1], [
-      'Be on IMAP or EAS (not on POP)',
-      'Be very unlucky',
+      {spans: [{type: 'text', text: 'Be on IMAP or EAS (not on POP)'}]},
+      {spans: [{type: 'text', text: 'Be very unlucky'}]},
     ]);
   });
 
@@ -242,10 +321,10 @@
       '* Be very unlucky\n';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'To see this bug,\nyou have to:');
+    assertSimpleTextBlock(result[0], 'To see this bug,\nyou have to:');
     assertListBlock(result[1], [
-      'Be on IMAP or EAS (not on POP)',
-      'Be very unlucky',
+      {spans: [{type: 'text', text: 'Be on IMAP or EAS (not on POP)'}]},
+      {spans: [{type: 'text', text: 'Be very unlucky'}]},
     ]);
   });
 
@@ -253,25 +332,46 @@
     const comment = 'A\n- line 1\n- 2nd line';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'A');
-    assertListBlock(result[1], ['line 1', '2nd line']);
+    assertSimpleTextBlock(result[0], 'A');
+    assertListBlock(result[1], [
+      {spans: [{type: 'text', text: 'line 1'}]},
+      {spans: [{type: 'text', text: '2nd line'}]},
+    ]);
   });
 
   test('dash list 2', () => {
     const comment = 'A\n- line 1\n- 2nd line\n\nB';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 3);
-    assertTextBlock(result[0], 'paragraph', 'A');
-    assertListBlock(result[1], ['line 1', '2nd line']);
-    assertTextBlock(result[2], 'paragraph', 'B');
+    assertSimpleTextBlock(result[0], 'A');
+    assertListBlock(result[1], [
+      {spans: [{type: 'text', text: 'line 1'}]},
+      {spans: [{type: 'text', text: '2nd line'}]},
+    ]);
+    assertSimpleTextBlock(result[2], 'B');
   });
 
   test('dash list 3', () => {
     const comment = '- line 1\n- 2nd line\n\nB';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 2);
-    assertListBlock(result[0], ['line 1', '2nd line']);
-    assertTextBlock(result[1], 'paragraph', 'B');
+    assertListBlock(result[0], [
+      {spans: [{type: 'text', text: 'line 1'}]},
+      {spans: [{type: 'text', text: '2nd line'}]},
+    ]);
+    assertSimpleTextBlock(result[1], 'B');
+  });
+
+  test('list with links', () => {
+    const comment = '- [text](http://url)\n- 2nd line';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertListBlock(result[0], [
+      {
+        spans: [{type: 'link', text: 'text', url: 'http://url'}],
+      },
+      {spans: [{type: 'text', text: '2nd line'}]},
+    ]);
   });
 
   test('nested list will NOT be recognized', () => {
@@ -279,51 +379,51 @@
     const comment = '- line 1\n  - line with indentation\n- line 2';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 3);
-    assertListBlock(result[0], ['line 1']);
-    assertTextBlock(result[1], 'pre', '  - line with indentation');
-    assertListBlock(result[2], ['line 2']);
+    assertListBlock(result[0], [{spans: [{type: 'text', text: 'line 1'}]}]);
+    assertPreBlock(result[1], '  - line with indentation');
+    assertListBlock(result[2], [{spans: [{type: 'text', text: 'line 2'}]}]);
   });
 
   test('pre format 1', () => {
     const comment = 'A\n  This is pre\n  formatted';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'A');
-    assertTextBlock(result[1], 'pre', '  This is pre\n  formatted');
+    assertSimpleTextBlock(result[0], 'A');
+    assertPreBlock(result[1], '  This is pre\n  formatted');
   });
 
   test('pre format 2', () => {
     const comment = 'A\n  This is pre\n  formatted\n\nbut this is not';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 3);
-    assertTextBlock(result[0], 'paragraph', 'A');
-    assertTextBlock(result[1], 'pre', '  This is pre\n  formatted');
-    assertTextBlock(result[2], 'paragraph', 'but this is not');
+    assertSimpleTextBlock(result[0], 'A');
+    assertPreBlock(result[1], '  This is pre\n  formatted');
+    assertSimpleTextBlock(result[2], 'but this is not');
   });
 
   test('pre format 3', () => {
     const comment = 'A\n  Q\n    <R>\n  S\n\nB';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 3);
-    assertTextBlock(result[0], 'paragraph', 'A');
-    assertTextBlock(result[1], 'pre', '  Q\n    <R>\n  S');
-    assertTextBlock(result[2], 'paragraph', 'B');
+    assertSimpleTextBlock(result[0], 'A');
+    assertPreBlock(result[1], '  Q\n    <R>\n  S');
+    assertSimpleTextBlock(result[2], 'B');
   });
 
   test('pre format 4', () => {
     const comment = '  Q\n    <R>\n  S\n\nB';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'pre', '  Q\n    <R>\n  S');
-    assertTextBlock(result[1], 'paragraph', 'B');
+    assertPreBlock(result[0], '  Q\n    <R>\n  S');
+    assertSimpleTextBlock(result[1], 'B');
   });
 
   test('pre format 5', () => {
     const comment = '  Q\n    <R>\n  S\n \nB';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'pre', '  Q\n    <R>\n  S');
-    assertTextBlock(result[1], 'paragraph', ' \nB');
+    assertPreBlock(result[0], '  Q\n    <R>\n  S');
+    assertSimpleTextBlock(result[1], ' \nB');
   });
 
   test('quote 1', () => {
@@ -332,11 +432,7 @@
     assert.lengthOf(result, 1);
     const quoteBlock = assertQuoteBlock(result[0]);
     assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(
-      quoteBlock.blocks[0],
-      'paragraph',
-      "I'm happy with quotes!!"
-    );
+    assertSimpleTextBlock(quoteBlock.blocks[0], "I'm happy with quotes!!");
   });
 
   test('quote 2', () => {
@@ -345,27 +441,19 @@
     assert.lengthOf(result, 2);
     const quoteBlock = assertQuoteBlock(result[0]);
     assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(
-      quoteBlock.blocks[0],
-      'paragraph',
-      "I'm happy\nwith quotes!"
-    );
-    assertTextBlock(result[1], 'paragraph', 'See above.');
+    assertSimpleTextBlock(quoteBlock.blocks[0], "I'm happy\nwith quotes!");
+    assertSimpleTextBlock(result[1], 'See above.');
   });
 
   test('quote 3', () => {
     const comment = 'See this said:\n > a quoted\n > string block\n\nOK?';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 3);
-    assertTextBlock(result[0], 'paragraph', 'See this said:');
+    assertSimpleTextBlock(result[0], 'See this said:');
     const quoteBlock = assertQuoteBlock(result[1]);
     assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(
-      quoteBlock.blocks[0],
-      'paragraph',
-      'a quoted\nstring block'
-    );
-    assertTextBlock(result[2], 'paragraph', 'OK?');
+    assertSimpleTextBlock(quoteBlock.blocks[0], 'a quoted\nstring block');
+    assertSimpleTextBlock(result[2], 'OK?');
   });
 
   test('nested quotes', () => {
@@ -376,46 +464,46 @@
     assert.lengthOf(outerQuoteBlock.blocks, 2);
     const nestedQuoteBlock = assertQuoteBlock(outerQuoteBlock.blocks[0]);
     assert.lengthOf(nestedQuoteBlock.blocks, 1);
-    assertTextBlock(nestedQuoteBlock.blocks[0], 'paragraph', 'prior');
-    assertTextBlock(outerQuoteBlock.blocks[1], 'paragraph', 'next');
+    assertSimpleTextBlock(nestedQuoteBlock.blocks[0], 'prior');
+    assertSimpleTextBlock(outerQuoteBlock.blocks[1], 'next');
   });
 
   test('code 1', () => {
     const comment = '```\n// test code\n```';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'code', '// test code');
+    assertCodeBlock(result[0], '// test code');
   });
 
   test('code 2', () => {
     const comment = 'test code\n```// test code```';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'test code');
-    assertTextBlock(result[1], 'code', '// test code');
+    assertSimpleTextBlock(result[0], 'test code');
+    assertCodeBlock(result[1], '// test code');
   });
 
   test('not a code block', () => {
     const comment = 'test code\n```// test code';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'paragraph', 'test code\n```// test code');
+    assertSimpleTextBlock(result[0], 'test code\n```// test code');
   });
 
   test('not a code block 2', () => {
     const comment = 'test code\n```\n// test code';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'test code');
-    assertTextBlock(result[1], 'paragraph', '```\n// test code');
+    assertSimpleTextBlock(result[0], 'test code');
+    assertSimpleTextBlock(result[1], '```\n// test code');
   });
 
   test('not a code block 3', () => {
     const comment = 'test code\n```';
     const result = element._computeBlocks(comment);
     assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'test code');
-    assertTextBlock(result[1], 'paragraph', '```');
+    assertSimpleTextBlock(result[0], 'test code');
+    assertSimpleTextBlock(result[1], '```');
   });
 
   test('mix all 1', () => {
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 6a34fbb..4493e8d 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
@@ -18,16 +18,17 @@
 import '@polymer/iron-icon/iron-icon';
 import '../gr-avatar/gr-avatar';
 import '../gr-button/gr-button';
-import {appContext} from '../../../services/app-context';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import {getAppContext} from '../../../services/app-context';
 import {accountKey, isSelf} from '../../../utils/account-util';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators';
 import {
   AccountInfo,
   ChangeInfo,
   ServerInfo,
   ReviewInput,
 } from '../../../types/common';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {
   canHaveAttention,
   getAddedByReason,
@@ -42,7 +43,9 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {css, html, LitElement} from 'lit';
+import {ifDefined} from 'lit/directives/if-defined';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
 const base = HovercardMixin(LitElement);
@@ -52,7 +55,7 @@
   @property({type: Object})
   account!: AccountInfo;
 
-  @property({type: Object})
+  @state()
   _selfAccount?: AccountInfo;
 
   /**
@@ -81,14 +84,9 @@
   @property({type: Object})
   _config?: ServerInfo;
 
-  reporting: ReportingService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private readonly restApiService = appContext.restApiService;
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
+  private readonly reporting = getAppContext().reportingService;
 
   override connectedCallback() {
     super.connectedCallback();
@@ -111,6 +109,9 @@
         .voteable {
           padding: var(--spacing-s) var(--spacing-l);
         }
+        .links {
+          padding: var(--spacing-m) 0px var(--spacing-l) var(--spacing-xxl);
+        }
         .top {
           display: flex;
           padding-top: var(--spacing-xl);
@@ -151,6 +152,17 @@
           position: relative;
           top: 3px;
         }
+        iron-icon.linkIcon {
+          width: var(--line-height-normal, 20px);
+          height: var(--line-height-normal, 20px);
+          vertical-align: top;
+          color: var(--deemphasized-text-color);
+          padding-right: 12px;
+        }
+        .links a {
+          color: var(--link-color);
+          padding: 0px 4px;
+        }
         .reason {
           padding-top: var(--spacing-s);
         }
@@ -171,24 +183,23 @@
     return html`
       <div class="top">
         <div class="avatar">
-          <gr-avautar .account=${this.account} imageSize="56"></gr-avatar>
+          <gr-avatar .account=${this.account} imageSize="56"></gr-avatar>
         </div>
         <div class="account">
           <h3 class="name heading-3">${this.account.name}</h3>
           <div class="email">${this.account.email}</div>
         </div>
       </div>
-      ${this.renderAccountStatus()}
-      ${
-        this.voteableText
-          ? html`
-              <div class="voteable">
-                <span class="title">Voteable:</span>
-                <span class="value">${this.voteableText}</span>
-              </div>
-            `
-          : ''
-      }
+      ${this.renderAccountStatusPlugins()} ${this.renderAccountStatus()}
+      ${this.renderLinks()}
+      ${this.voteableText
+        ? html`
+            <div class="voteable">
+              <span class="title">Voteable:</span>
+              <span class="value">${this.voteableText}</span>
+            </div>
+          `
+        : ''}
       ${this.renderNeedsAttention()} ${this.renderAddToAttention()}
       ${this.renderRemoveFromAttention()} ${this.renderReviewerOrCcActions()}
     `;
@@ -203,7 +214,7 @@
           class="removeReviewerOrCC"
           link=""
           no-uppercase
-          @click="${this.handleRemoveReviewerOrCC}"
+          @click=${this.handleRemoveReviewerOrCC}
         >
           Remove ${this.computeReviewerOrCCText()}
         </gr-button>
@@ -213,7 +224,7 @@
           class="changeReviewerOrCC"
           link=""
           no-uppercase
-          @click="${this.handleChangeReviewerOrCCStatus}"
+          @click=${this.handleChangeReviewerOrCCStatus}
         >
           ${this.computeChangeReviewerOrCCText()}
         </gr-button>
@@ -221,14 +232,51 @@
     `;
   }
 
+  private renderAccountStatusPlugins() {
+    return html`
+      <gr-endpoint-decorator name="hovercard-status">
+        <gr-endpoint-param
+          name="account"
+          .value=${this.account}
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    `;
+  }
+
+  private renderLinks() {
+    return html` <div class="links">
+      <iron-icon class="linkIcon" icon="gr-icons:link"></iron-icon
+      ><a
+        href=${ifDefined(this.computeOwnerChangesLink())}
+        @click=${() => {
+          this.forceHide();
+          return true;
+        }}
+        @enter=${() => {
+          this.forceHide();
+          return true;
+        }}
+        >Changes</a
+      >·<a
+        href=${ifDefined(this.computeOwnerDashboardLink())}
+        @click=${() => {
+          this.forceHide();
+          return true;
+        }}
+        @enter=${() => {
+          this.forceHide();
+          return true;
+        }}
+        >Dashboard</a
+      >
+    </div>`;
+  }
+
   private renderAccountStatus() {
     if (!this.account.status) return;
     return html`
       <div class="status">
-        <span class="title">
-          <iron-icon icon="gr-icons:calendar"></iron-icon>
-          Status:
-        </span>
+        <span class="title">About me:</span>
         <span class="value">${this.account.status}</span>
       </div>
     `;
@@ -263,7 +311,7 @@
           ${lastUpdate
             ? html` (<gr-date-formatter
                   withTooltip
-                  .dateStr="${lastUpdate}"
+                  .dateStr=${lastUpdate}
                 ></gr-date-formatter
                 >)`
             : ''}
@@ -280,7 +328,7 @@
           class="addToAttentionSet"
           link=""
           no-uppercase
-          @click="${this.handleClickAddToAttentionSet}"
+          @click=${this.handleClickAddToAttentionSet}
         >
           Add to attention set
         </gr-button>
@@ -296,7 +344,7 @@
           class="removeFromAttentionSet"
           link=""
           no-uppercase
-          @click="${this.handleClickRemoveFromAttentionSet}"
+          @click=${this.handleClickRemoveFromAttentionSet}
         >
           Remove from attention set
         </gr-button>
@@ -310,6 +358,25 @@
     return isSelf(this.account, this._selfAccount) ? 'Your' : 'Their';
   }
 
+  computeOwnerChangesLink() {
+    if (!this.account) return undefined;
+    return GerritNav.getUrlForOwner(
+      this.account.email ||
+        this.account.username ||
+        this.account.name ||
+        `${this.account._account_id}`
+    );
+  }
+
+  computeOwnerDashboardLink() {
+    if (!this.account) return undefined;
+    if (this.account._account_id)
+      return GerritNav.getUrlForUserDashboard(`${this.account._account_id}`);
+    if (this.account.email)
+      return GerritNav.getUrlForUserDashboard(this.account.email);
+    return undefined;
+  }
+
   get isAttentionEnabled() {
     return (
       !!this.highlightAttention &&
@@ -441,7 +508,7 @@
       .then(() => {
         this.dispatchEventThroughTarget('hide-alert');
       });
-    this.hide(e);
+    this.mouseHide(e);
   }
 
   private handleClickRemoveFromAttentionSet(e: MouseEvent) {
@@ -472,7 +539,7 @@
       .then(() => {
         this.dispatchEventThroughTarget('hide-alert');
       });
-    this.hide(e);
+    this.mouseHide(e);
   }
 
   private reportingDetails() {
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
deleted file mode 100644
index 5530d7c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
+++ /dev/null
@@ -1,255 +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-hovercard-account.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {ReviewerState} from '../../../constants/constants.js';
-import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-hovercard-account class="hovered"></gr-hovercard-account>
-`);
-
-suite('gr-hovercard-account tests', () => {
-  let element;
-
-  const ACCOUNT = {
-    email: 'kermit@gmail.com',
-    username: 'kermit',
-    name: 'Kermit The Frog',
-    _account_id: '31415926535',
-  };
-
-  setup(async () => {
-    stubRestApi('getAccount').returns(Promise.resolve({...ACCOUNT}));
-    element = basicFixture.instantiate();
-    element.account = {...ACCOUNT};
-    element.change = {
-      attention_set: {},
-      reviewers: {},
-      owner: {...ACCOUNT},
-    };
-    element.show({});
-    await flush();
-  });
-
-  teardown(() => {
-    element.hide({});
-  });
-
-  test('account name is shown', () => {
-    assert.equal(element.shadowRoot.querySelector('.name').innerText,
-        'Kermit The Frog');
-  });
-
-  test('computePronoun', () => {
-    element.account = {_account_id: '1'};
-    element._selfAccount = {_account_id: '1'};
-    assert.equal(element.computePronoun(), 'Your');
-    element.account = {_account_id: '2'};
-    assert.equal(element.computePronoun(), 'Their');
-  });
-
-  test('account status is not shown if the property is not set', () => {
-    assert.isNull(element.shadowRoot.querySelector('.status'));
-  });
-
-  test('account status is displayed', async () => {
-    element.account = {status: 'OOO', ...ACCOUNT};
-    await element.updateComplete;
-    assert.equal(element.shadowRoot.querySelector('.status .value').innerText,
-        'OOO');
-  });
-
-  test('voteable div is not shown if the property is not set', () => {
-    assert.isNull(element.shadowRoot.querySelector('.voteable'));
-  });
-
-  test('voteable div is displayed', async () => {
-    element.voteableText = 'CodeReview: +2';
-    await element.updateComplete;
-    assert.equal(element.shadowRoot.querySelector('.voteable .value').innerText,
-        element.voteableText);
-  });
-
-  test('remove reviewer', async () => {
-    element.change = {
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [ACCOUNT],
-      },
-    };
-    await element.updateComplete;
-    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
-    const reloadListener = sinon.spy();
-    element._target.addEventListener('reload', reloadListener);
-    const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
-    assert.isOk(button);
-    assert.equal(button.innerText, 'Remove Reviewer');
-    MockInteractions.tap(button);
-    await element.updateComplete;
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('move reviewer to cc', async () => {
-    element.change = {
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [ACCOUNT],
-      },
-    };
-    await element.updateComplete;
-    const saveReviewStub = stubRestApi(
-        'saveChangeReview').returns(
-        Promise.resolve({ok: true}));
-    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
-    const reloadListener = sinon.spy();
-    element._target.addEventListener('reload', reloadListener);
-
-    const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
-
-    assert.isOk(button);
-    assert.equal(button.innerText, 'Move Reviewer to CC');
-    MockInteractions.tap(button);
-    await element.updateComplete;
-    assert.isTrue(saveReviewStub.called);
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('move reviewer to cc', async () => {
-    element.change = {
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [],
-      },
-    };
-    await element.updateComplete;
-    const saveReviewStub = stubRestApi(
-        'saveChangeReview').returns(Promise.resolve({ok: true}));
-    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
-    const reloadListener = sinon.spy();
-    element._target.addEventListener('reload', reloadListener);
-
-    const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
-    assert.isOk(button);
-    assert.equal(button.innerText, 'Move CC to Reviewer');
-
-    MockInteractions.tap(button);
-    await element.updateComplete;
-    assert.isTrue(saveReviewStub.called);
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('remove cc', async () => {
-    element.change = {
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [],
-      },
-    };
-    await element.updateComplete;
-    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
-    const reloadListener = sinon.spy();
-    element._target.addEventListener('reload', reloadListener);
-
-    const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
-
-    assert.equal(button.innerText, 'Remove CC');
-    assert.isOk(button);
-    MockInteractions.tap(button);
-    await element.updateComplete;
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('add to attention set', async () => {
-    const apiPromise = mockPromise();
-    const apiSpy = stubRestApi('addToAttentionSet').returns(apiPromise);
-    element.highlightAttention = true;
-    element._target = document.createElement('div');
-    await element.updateComplete;
-    const showAlertListener = sinon.spy();
-    const hideAlertListener = sinon.spy();
-    const updatedListener = sinon.spy();
-    element._target.addEventListener('show-alert', showAlertListener);
-    element._target.addEventListener('hide-alert', hideAlertListener);
-    element._target.addEventListener('attention-set-updated', updatedListener);
-
-    const button = element.shadowRoot.querySelector('.addToAttentionSet');
-    assert.isOk(button);
-    assert.isTrue(element._isShowing, 'hovercard is showing');
-    MockInteractions.tap(button);
-
-    assert.equal(Object.keys(element.change.attention_set).length, 1);
-    const attention_set_info = Object.values(element.change.attention_set)[0];
-    assert.equal(attention_set_info.reason,
-        `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>`
-        + ` using the hovercard menu`);
-    assert.equal(attention_set_info.reason_account._account_id,
-        ACCOUNT._account_id);
-    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
-    assert.isTrue(updatedListener.called, 'updatedListener was called');
-    assert.isFalse(element._isShowing, 'hovercard is hidden');
-
-    apiPromise.resolve({});
-    await element.updateComplete;
-    assert.isTrue(apiSpy.calledOnce);
-    assert.equal(apiSpy.lastCall.args[2],
-        `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>`
-        + ` using the hovercard menu`);
-    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
-  });
-
-  test('remove from attention set', async () => {
-    const apiPromise = mockPromise();
-    const apiSpy = stubRestApi('removeFromAttentionSet').returns(apiPromise);
-    element.highlightAttention = true;
-    element.change = {
-      attention_set: {31415926535: {}},
-      reviewers: {},
-      owner: {...ACCOUNT},
-    };
-    element._target = document.createElement('div');
-    await element.updateComplete;
-    const showAlertListener = sinon.spy();
-    const hideAlertListener = sinon.spy();
-    const updatedListener = sinon.spy();
-    element._target.addEventListener('show-alert', showAlertListener);
-    element._target.addEventListener('hide-alert', hideAlertListener);
-    element._target.addEventListener('attention-set-updated', updatedListener);
-
-    const button = element.shadowRoot.querySelector('.removeFromAttentionSet');
-    assert.isOk(button);
-    assert.isTrue(element._isShowing, 'hovercard is showing');
-    MockInteractions.tap(button);
-
-    assert.equal(Object.keys(element.change.attention_set).length, 0);
-    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
-    assert.isTrue(updatedListener.called, 'updatedListener was called');
-    assert.isFalse(element._isShowing, 'hovercard is hidden');
-
-    apiPromise.resolve({});
-    await element.updateComplete;
-
-    assert.isTrue(apiSpy.calledOnce);
-    assert.equal(apiSpy.lastCall.args[2],
-        `Removed by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>`
-        + ` using the hovercard menu`);
-    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
-  });
-});
-
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
new file mode 100644
index 0000000..77fc4f6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
@@ -0,0 +1,336 @@
+/**
+ * @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 {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import './gr-hovercard-account';
+import {GrHovercardAccount} from './gr-hovercard-account';
+import {
+  mockPromise,
+  query,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  AccountDetailInfo,
+  AccountId,
+  EmailAddress,
+  ReviewerState,
+} from '../../../api/rest-api.js';
+import {
+  createAccountDetailWithId,
+  createChange,
+} from '../../../test/test-data-generators.js';
+import {GrButton} from '../gr-button/gr-button.js';
+
+suite('gr-hovercard-account tests', () => {
+  let element: GrHovercardAccount;
+
+  const ACCOUNT: AccountDetailInfo = {
+    ...createAccountDetailWithId(31),
+    email: 'kermit@gmail.com' as EmailAddress,
+    username: 'kermit',
+    name: 'Kermit The Frog',
+    status: 'I am a frog',
+    _account_id: 31415926535 as AccountId,
+  };
+
+  setup(async () => {
+    stubRestApi('getAccount').returns(Promise.resolve({...ACCOUNT}));
+    const change = {
+      ...createChange(),
+      attention_set: {},
+      reviewers: {},
+      owner: {...ACCOUNT},
+    };
+    element = await fixture<GrHovercardAccount>(
+      html`<gr-hovercard-account
+        class="hovered"
+        .account=${ACCOUNT}
+        .change=${change}
+      >
+      </gr-hovercard-account>`
+    );
+    await element.show({});
+    await element.updateComplete;
+  });
+
+  teardown(async () => {
+    element.mouseHide(new MouseEvent('click'));
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div id="container" role="tooltip" tabindex="-1">
+        <div class="top">
+          <div class="avatar">
+            <gr-avatar hidden="" imagesize="56"></gr-avatar>
+          </div>
+          <div class="account">
+            <h3 class="heading-3 name">Kermit The Frog</h3>
+            <div class="email">kermit@gmail.com</div>
+          </div>
+        </div>
+        <gr-endpoint-decorator name="hovercard-status">
+          <gr-endpoint-param name="account"></gr-endpoint-param>
+        </gr-endpoint-decorator>
+        <div class="status">
+          <span class="title">About me:</span>
+          <span class="value">I am a frog</span>
+        </div>
+        <div class="links">
+          <iron-icon class="linkIcon" icon="gr-icons:link"></iron-icon>
+          <a href="">Changes</a>·<a href="">Dashboard</a>
+        </div>
+      </div>
+    `);
+  });
+
+  test('account name is shown', () => {
+    const name = queryAndAssert<HTMLHeadingElement>(element, '.name');
+    assert.equal(name.innerText, 'Kermit The Frog');
+  });
+
+  test('computePronoun', async () => {
+    element.account = createAccountDetailWithId(1);
+    element._selfAccount = createAccountDetailWithId(1);
+    await element.updateComplete;
+    assert.equal(element.computePronoun(), 'Your');
+    element.account = createAccountDetailWithId(2);
+    await element.updateComplete;
+    assert.equal(element.computePronoun(), 'Their');
+  });
+
+  test('account status is not shown if the property is not set', async () => {
+    element.account = {...ACCOUNT, status: undefined};
+    await element.updateComplete;
+    assert.isUndefined(query(element, '.status'));
+  });
+
+  test('account status is displayed', () => {
+    const status = queryAndAssert<HTMLSpanElement>(element, '.status .value');
+    assert.equal(status.innerText, 'I am a frog');
+  });
+
+  test('voteable div is not shown if the property is not set', () => {
+    assert.isUndefined(query(element, '.voteable'));
+  });
+
+  test('voteable div is displayed', async () => {
+    element.voteableText = 'CodeReview: +2';
+    await element.updateComplete;
+    const voteableEl = queryAndAssert<HTMLSpanElement>(
+      element,
+      '.voteable .value'
+    );
+    assert.equal(voteableEl.innerText, element.voteableText);
+  });
+
+  test('remove reviewer', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    await element.updateComplete;
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element._target?.addEventListener('reload', reloadListener);
+    const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Remove Reviewer');
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    await element.updateComplete;
+    const saveReviewStub = stubRestApi('saveChangeReview').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element._target?.addEventListener('reload', reloadListener);
+
+    const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
+
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move Reviewer to CC');
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    await element.updateComplete;
+    const saveReviewStub = stubRestApi('saveChangeReview').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element._target?.addEventListener('reload', reloadListener);
+
+    const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move CC to Reviewer');
+
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('remove cc', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    await element.updateComplete;
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element._target?.addEventListener('reload', reloadListener);
+
+    const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
+
+    assert.equal(button.innerText, 'Remove CC');
+    assert.isOk(button);
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('add to attention set', async () => {
+    const apiPromise = mockPromise<Response>();
+    const apiSpy = stubRestApi('addToAttentionSet').returns(apiPromise);
+    element.highlightAttention = true;
+    element._target = document.createElement('div');
+    await element.updateComplete;
+    const showAlertListener = sinon.spy();
+    const hideAlertListener = sinon.spy();
+    const updatedListener = sinon.spy();
+    element._target.addEventListener('show-alert', showAlertListener);
+    element._target.addEventListener('hide-alert', hideAlertListener);
+    element._target.addEventListener('attention-set-updated', updatedListener);
+
+    const button = queryAndAssert<GrButton>(element, '.addToAttentionSet');
+    assert.isOk(button);
+    assert.isTrue(element._isShowing, 'hovercard is showing');
+    button.click();
+
+    assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 1);
+    const attention_set_info = Object.values(
+      element.change?.attention_set ?? {}
+    )[0];
+    assert.equal(
+      attention_set_info.reason,
+      `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+        ' using the hovercard menu'
+    );
+    assert.equal(
+      attention_set_info.reason_account?._account_id,
+      ACCOUNT._account_id
+    );
+    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+    assert.isTrue(updatedListener.called, 'updatedListener was called');
+    assert.isFalse(element._isShowing, 'hovercard is hidden');
+
+    apiPromise.resolve({...new Response(), ok: true});
+    await element.updateComplete;
+    assert.isTrue(apiSpy.calledOnce);
+    assert.equal(
+      apiSpy.lastCall.args[2],
+      `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+        ' using the hovercard menu'
+    );
+    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+  });
+
+  test('remove from attention set', async () => {
+    const apiPromise = mockPromise<Response>();
+    const apiSpy = stubRestApi('removeFromAttentionSet').returns(apiPromise);
+    element.highlightAttention = true;
+    element.change = {
+      ...createChange(),
+      attention_set: {
+        '31415926535': {account: ACCOUNT, reason: 'a good reason'},
+      },
+      reviewers: {},
+      owner: {...ACCOUNT},
+    };
+    element._target = document.createElement('div');
+    await element.updateComplete;
+    const showAlertListener = sinon.spy();
+    const hideAlertListener = sinon.spy();
+    const updatedListener = sinon.spy();
+    element._target.addEventListener('show-alert', showAlertListener);
+    element._target.addEventListener('hide-alert', hideAlertListener);
+    element._target.addEventListener('attention-set-updated', updatedListener);
+
+    const button = queryAndAssert<GrButton>(element, '.removeFromAttentionSet');
+    assert.isOk(button);
+    assert.isTrue(element._isShowing, 'hovercard is showing');
+    button.click();
+
+    assert.isDefined(element.change?.attention_set);
+    assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 0);
+    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+    assert.isTrue(updatedListener.called, 'updatedListener was called');
+    assert.isFalse(element._isShowing, 'hovercard is hidden');
+
+    apiPromise.resolve({...new Response(), ok: true});
+    await element.updateComplete;
+
+    assert.isTrue(apiSpy.calledOnce);
+    assert.equal(
+      apiSpy.lastCall.args[2],
+      `Removed by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+        ' using the hovercard menu'
+    );
+    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+  });
+});
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 9b183cc..dc2cbc7 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -85,6 +85,10 @@
       <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://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>
+      <!-- This SVG is a copy from https://fonts.google.com/icons?selected=Material+Icons:event_busy&icon.query=block-->
+      <g id="block"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z"/></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="robot"><path d="M4.137453,5.61015591 L4.54835569,1.5340419 C4.5717665,1.30180904 4.76724872,1.12504213 5.00065859,1.12504213 C5.23327176,1.12504213 5.42730868,1.30282046 5.44761309,1.53454578 L5.76084628,5.10933916 C6.16304484,5.03749412 6.57714381,5 7,5 L17,5 C20.8659932,5 24,8.13400675 24,12 L24,15.1250421 C24,18.9910354 20.8659932,22.1250421 17,22.1250421 L7,22.1250421 C3.13400675,22.1250421 2.19029351e-15,18.9910354 0,15.1250421 L0,12 C-3.48556243e-16,9.15382228 1.69864167,6.70438358 4.137453,5.61015591 Z M5.77553049,6.12504213 C3.04904264,6.69038358 1,9.10590202 1,12 L1,15.1250421 C1,18.4387506 3.6862915,21.1250421 7,21.1250421 L17,21.1250421 C20.3137085,21.1250421 23,18.4387506 23,15.1250421 L23,12 C23,8.6862915 20.3137085,6 17,6 L7,6 C6.60617231,6 6.2212068,6.03794347 5.84855971,6.11037415 L5.84984496,6.12504213 L5.77553049,6.12504213 Z M6.93003717,6.95027711 L17.1232083,6.95027711 C19.8638332,6.95027711 22.0855486,9.17199258 22.0855486,11.9126175 C22.0855486,14.6532424 19.8638332,16.8749579 17.1232083,16.8749579 L6.93003717,16.8749579 C4.18941226,16.8749579 1.9676968,14.6532424 1.9676968,11.9126175 C1.9676968,9.17199258 4.18941226,6.95027711 6.93003717,6.95027711 Z M7.60124392,14.0779303 C9.03787127,14.0779303 10.2024878,12.9691885 10.2024878,11.6014862 C10.2024878,10.2337839 9.03787127,9.12504213 7.60124392,9.12504213 C6.16461657,9.12504213 5,10.2337839 5,11.6014862 C5,12.9691885 6.16461657,14.0779303 7.60124392,14.0779303 Z M16.617997,14.1098288 C18.0638768,14.1098288 19.2359939,12.9939463 19.2359939,11.6174355 C19.2359939,10.2409246 18.0638768,9.12504213 16.617997,9.12504213 C15.1721172,9.12504213 14,10.2409246 14,11.6174355 C14,12.9939463 15.1721172,14.1098288 16.617997,14.1098288 Z M9.79751216,18.1250421 L15,18.1250421 L15,19.1250421 C15,19.6773269 14.5522847,20.1250421 14,20.1250421 L10.7975122,20.1250421 C10.2452274,20.1250421 9.79751216,19.6773269 9.79751216,19.1250421 L9.79751216,18.1250421 Z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
@@ -164,6 +168,12 @@
       <g id="description"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=settings_backup_restore and 0.65 scale and 4 translate https://fonts.google.com/icons?selected=Material+Icons&icon.query=done-->
       <g id="overridden"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 15 zM2 4v6h6V8H5.09C6.47 5.61 9.04 4 12 4c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8H2c0 5.52 4.48 10 10.01 10C17.53 22 22 17.52 22 12S17.53 2 12.01 2C8.73 2 5.83 3.58 4 6.01V4H2z"/><path xmlns="http://www.w3.org/2000/svg" d="M9.85 14.53 7.12 11.8l-.91.91L9.85 16.35 17.65 8.55l-.91-.91L9.85 14.53z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:event_busy -->
+      <g id="unavailable"><path d="M0 0h24v24H0z" fill="none"/><path d="M9.31 17l2.44-2.44L14.19 17l1.06-1.06-2.44-2.44 2.44-2.44L14.19 10l-2.44 2.44L9.31 10l-1.06 1.06 2.44 2.44-2.44 2.44L9.31 17zM19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11z"/></g>
+      <!-- This SVG is a custom PolyGerrit SVG -->
+      <g id="not-working-hours"><path d="M20.8,13.9c-0.6,0.1-1.3,0.2-2,0.2c-4.9,0-8.9-4-8.9-8.9c0-0.7,0.1-1.4,0.2-2c-4,0.9-6.9,4.5-6.9,8.7c0,4.9,4,8.9,8.9,8.9C16.3,20.8,19.9,17.9,20.8,13.9z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:pending_actions -->
+      <g id="scheduled"><path d="M0 0h24v24H0z" fill="none"/><path d="M17.0 22.0Q14.925 22.0 13.4625 20.5375Q12.0 19.075 12.0 17.0Q12.0 14.925 13.4625 13.4625Q14.925 12.0 17.0 12.0Q19.075 12.0 20.5375 13.4625Q22.0 14.925 22.0 17.0Q22.0 19.075 20.5375 20.5375Q19.075 22.0 17.0 22.0ZM18.675 19.375 19.375 18.675 17.5 16.8V14.0H16.5V17.2ZM5.0 21.0Q4.175 21.0 3.5875 20.4125Q3.0 19.825 3.0 19.0V5.0Q3.0 4.175 3.5875 3.5875Q4.175 3.0 5.0 3.0H9.175Q9.5 2.125 10.2625 1.5625Q11.025 1.0 12.0 1.0Q12.975 1.0 13.7375 1.5625Q14.5 2.125 14.825 3.0H19.0Q19.825 3.0 20.4125 3.5875Q21.0 4.175 21.0 5.0V11.25Q20.55 10.925 20.05 10.7Q19.55 10.475 19.0 10.3V5.0Q19.0 5.0 19.0 5.0Q19.0 5.0 19.0 5.0H17.0V8.0H7.0V5.0H5.0Q5.0 5.0 5.0 5.0Q5.0 5.0 5.0 5.0V19.0Q5.0 19.0 5.0 19.0Q5.0 19.0 5.0 19.0H10.3Q10.475 19.55 10.7 20.05Q10.925 20.55 11.25 21.0ZM12.0 5.0Q12.425 5.0 12.7125 4.7125Q13.0 4.425 13.0 4.0Q13.0 3.575 12.7125 3.2875Q12.425 3.0 12.0 3.0Q11.575 3.0 11.2875 3.2875Q11.0 3.575 11.0 4.0Q11.0 4.425 11.2875 4.7125Q11.575 5.0 12.0 5.0Z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index 82c7118..3add34d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -17,7 +17,7 @@
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
 import {Side} from '../../../constants/constants';
 import {EventType, PluginApi} from '../../../api/plugin';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {AnnotationPluginApi, CoverageProvider} from '../../../api/annotation';
 
 export class GrAnnotationActionsInterface implements AnnotationPluginApi {
@@ -30,7 +30,7 @@
 
   private coverageProvider?: CoverageProvider;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   constructor(private readonly plugin: PluginApi) {
     this.reporting.trackApi(this.plugin, 'annotation', 'constructor');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
index 996edf3..1088b27 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
@@ -17,9 +17,6 @@
 
 import '../../../test/common-test-setup-karma.js';
 import '../../change/gr-change-actions/gr-change-actions.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
 
 suite('gr-annotation-actions-js-api tests', () => {
   let annotationActions;
@@ -27,7 +24,7 @@
   let plugin;
 
   setup(() => {
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     annotationActions = plugin.annotationApi();
   });
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 36e5c25..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
@@ -18,10 +18,18 @@
 import {getBaseUrl} from '../../../utils/url-util';
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 
 export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
 
+export const THEME_JS = '/static/gerrit-theme.js';
+
+const THEME_NAME = 'gerrit-theme';
+
+export function isThemeFile(path: string) {
+  return path.endsWith(THEME_JS);
+}
+
 /**
  * Retrieves the name of the plugin base on the url.
  */
@@ -40,12 +48,10 @@
   if (window.ASSETS_PATH && url.href.includes(window.ASSETS_PATH)) {
     pathname = url.href.replace(window.ASSETS_PATH, '');
   }
-  // Site theme is server from predefined path.
-  if (
-    ['/static/gerrit-theme.html', '/static/gerrit-theme.js'].includes(pathname)
-  ) {
-    return 'gerrit-theme';
-  } else if (!pathname.startsWith('/plugins')) {
+
+  if (isThemeFile(pathname)) return THEME_NAME;
+
+  if (!pathname.startsWith('/plugins')) {
     console.warn(
       'Plugin not being loaded from /plugins base path:',
       url.href,
@@ -56,20 +62,20 @@
 
   // 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];
 }
 
-// TODO(taoalpha): to be deprecated.
 export function send(
+  restApiService: RestApiService,
   method: HttpMethod,
   url: string,
   opt_callback?: (response: unknown) => void,
   opt_payload?: RequestPayload
 ) {
-  return appContext.restApiService
+  return restApiService
     .send(method, url, opt_payload)
     .then(response => {
       if (response.status < 200 || response.status >= 300) {
@@ -81,7 +87,7 @@
           }
         });
       } else {
-        return appContext.restApiService.getResponseObject(response);
+        return restApiService.getResponseObject(response);
       }
     })
     .then(response => {
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 a33b145..82528cd 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -16,7 +16,7 @@
  */
 import {PluginApi, TargetElement} from '../../../api/plugin';
 import {ActionInfo, RequireProperties} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {
   ActionPriority,
   ActionType,
@@ -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 {
@@ -68,7 +73,9 @@
 
   ActionType = ActionType;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly jsApiService = getAppContext().jsApiService;
 
   constructor(public plugin: PluginApi, el?: GrChangeActionsElement) {
     this.reporting.trackApi(this.plugin, 'actions', 'constructor');
@@ -92,7 +99,7 @@
    */
   ensureEl(): GrChangeActionsElement {
     if (!this.el) {
-      const sharedApiElement = appContext.jsApiService;
+      const sharedApiElement = this.jsApiService;
       this.setEl(
         sharedApiElement.getElement(
           TargetElement.CHANGE_ACTIONS
@@ -109,7 +116,8 @@
       return;
     }
 
-    el.push('primaryActionKeys', key);
+    el.primaryActionKeys.push(key);
+    el.requestUpdate();
   }
 
   removePrimaryActionKey(key: string) {
@@ -125,20 +133,17 @@
 
   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 {
@@ -148,8 +153,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 87f6052..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
+++ /dev/null
@@ -1,198 +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';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const basicFixture = fixtureFromElement('gr-change-actions');
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-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();
-      pluginApi.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;
-      pluginApi.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-change-reply-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
index aa86de4..b93bc4a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import {GrReplyDialog} from '../../../services/gr-rest-api/gr-rest-api';
+import {GrReplyDialog} from '../../change/gr-reply-dialog/gr-reply-dialog';
 import {PluginApi, TargetElement} from '../../../api/plugin';
 import {JsApiService} from './gr-js-api-types';
 import {
@@ -25,14 +25,14 @@
   ReplyChangedCallback,
   ValueChangedDetail,
 } from '../../../api/change-reply';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {HookApi, PluginElement} from '../../../api/hook';
 
 /**
  * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
  */
 export class GrChangeReplyInterface implements ChangeReplyPluginApi {
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   constructor(
     readonly plugin: PluginApi,
@@ -47,7 +47,7 @@
     ) as unknown as GrReplyDialog;
   }
 
-  getLabelValue(label: string): string {
+  getLabelValue(label: string): string | number | undefined {
     this.reporting.trackApi(this.plugin, 'reply', 'getLabelValue');
     return this._el.getLabelValue(label);
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
index 2324588..52d6ab3 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
@@ -17,16 +17,12 @@
 
 import '../../../test/common-test-setup-karma.js';
 import '../../change/gr-reply-dialog/gr-reply-dialog.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-reply-dialog');
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-change-reply-js-api tests', () => {
   let element;
-
   let changeReply;
   let plugin;
 
@@ -36,7 +32,7 @@
 
   suite('early init', () => {
     setup(() => {
-      pluginApi.install(p => { plugin = p; }, '0.1',
+      window.Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
       changeReply = plugin.changeReply();
       element = basicFixture.instantiate();
@@ -64,7 +60,7 @@
   suite('normal init', () => {
     setup(() => {
       element = basicFixture.instantiate();
-      pluginApi.install(p => { plugin = p; }, '0.1',
+      window.Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
       changeReply = plugin.changeReply();
     });
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 1d4bd06..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
@@ -21,8 +21,11 @@
  */
 import {getPluginLoader, PluginOptionMap} from './gr-plugin-loader';
 import {send} from './gr-api-utils';
-import {appContext} from '../../../services/app-context';
+import {getAppContext, AppContext} from '../../../services/app-context';
 import {PluginApi} from '../../../api/plugin';
+import {AuthService} from '../../../services/gr-auth/gr-auth';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
 import {
@@ -37,6 +40,7 @@
 import {spinnerStyles} from '../../../styles/gr-spinner-styles';
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
+import {assertIsDefined} from '../../../utils/common-util';
 
 /**
  * These are the methods and properties that are exposed explicitly in the
@@ -74,15 +78,15 @@
 
   // exposed methods
   Nav: typeof GerritNav;
-  Auth: typeof appContext.authService;
+  Auth: AuthService;
 }
 
-export function initGerritPluginApi() {
-  window.Gerrit = window.Gerrit ?? new GerritImpl();
+export function initGerritPluginApi(appContext: AppContext) {
+  window.Gerrit = window.Gerrit ?? new GerritImpl(appContext);
 }
 
-export function _testOnly_initGerritPluginApi(): GerritInternal {
-  initGerritPluginApi();
+export function _testOnly_getGerritInternalPluginApi(): GerritInternal {
+  if (!window.Gerrit) throw new Error('initGerritPluginApi was not called');
   return window.Gerrit as GerritInternal;
 }
 
@@ -91,8 +95,8 @@
   callback?: (response: Response) => void
 ) {
   console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
-  return appContext.restApiService
-    .send(HttpMethod.DELETE, url)
+  return getAppContext()
+    .restApiService.send(HttpMethod.DELETE, url)
     .then(response => {
       if (response.status !== 204) {
         return response.text().then(text => {
@@ -120,7 +124,13 @@
 
   public readonly Nav = GerritNav;
 
-  public readonly Auth = appContext.authService;
+  public readonly Auth: AuthService;
+
+  private readonly reportingService: ReportingService;
+
+  private readonly eventEmitter: EventEmitterService;
+
+  private readonly restApiService: RestApiService;
 
   public readonly styles = {
     font: fontStyles,
@@ -131,12 +141,24 @@
     table: tableStyles,
   };
 
+  constructor(appContext: AppContext) {
+    this.Auth = appContext.authService;
+    this.reportingService = appContext.reportingService;
+    this.eventEmitter = appContext.eventEmitter;
+    this.restApiService = appContext.restApiService;
+    assertIsDefined(this.reportingService, 'reportingService');
+    assertIsDefined(this.eventEmitter, 'eventEmitter');
+    assertIsDefined(this.restApiService, 'restApiService');
+  }
+
+  finalize() {}
+
   /**
    * @deprecated Use plugin.styles().css(rulesStr) instead. Please, consult
    * the documentation how to replace it accordingly.
    */
   css(rulesStr: string) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'css');
+    this.reportingService.trackApi(fakeApi, 'global', 'css');
     console.warn(
       'Gerrit.css(rulesStr) is deprecated!',
       'Use plugin.styles().css(rulesStr)'
@@ -161,18 +183,18 @@
   }
 
   getLoggedIn() {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'getLoggedIn');
+    this.reportingService.trackApi(fakeApi, 'global', 'getLoggedIn');
     console.warn(
       'Gerrit.getLoggedIn() is deprecated! ' +
         'Use plugin.restApi().getLoggedIn()'
     );
-    return appContext.restApiService.getLoggedIn();
+    return this.restApiService.getLoggedIn();
   }
 
   get(url: string, callback?: (response: unknown) => void) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'get');
+    this.reportingService.trackApi(fakeApi, 'global', 'get');
     console.warn('.get() is deprecated! Use plugin.restApi().get()');
-    send(HttpMethod.GET, url, callback);
+    send(this.restApiService, HttpMethod.GET, url, callback);
   }
 
   post(
@@ -180,9 +202,9 @@
     payload?: RequestPayload,
     callback?: (response: unknown) => void
   ) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'post');
+    this.reportingService.trackApi(fakeApi, 'global', 'post');
     console.warn('.post() is deprecated! Use plugin.restApi().post()');
-    send(HttpMethod.POST, url, callback, payload);
+    send(this.restApiService, HttpMethod.POST, url, callback, payload);
   }
 
   put(
@@ -190,43 +212,35 @@
     payload?: RequestPayload,
     callback?: (response: unknown) => void
   ) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'put');
+    this.reportingService.trackApi(fakeApi, 'global', 'put');
     console.warn('.put() is deprecated! Use plugin.restApi().put()');
-    send(HttpMethod.PUT, url, callback, payload);
+    send(this.restApiService, HttpMethod.PUT, url, callback, payload);
   }
 
   delete(url: string, callback?: (response: Response) => void) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'delete');
+    this.reportingService.trackApi(fakeApi, 'global', 'delete');
     deprecatedDelete(url, callback);
   }
 
   awaitPluginsLoaded() {
-    appContext.reportingService.trackApi(
-      fakeApi,
-      'global',
-      'awaitPluginsLoaded'
-    );
+    this.reportingService.trackApi(fakeApi, 'global', 'awaitPluginsLoaded');
     return getPluginLoader().awaitPluginsLoaded();
   }
 
   // TODO(taoalpha): consider removing these proxy methods
   // and using getPluginLoader() directly
   _loadPlugins(plugins: string[] = []) {
-    appContext.reportingService.trackApi(fakeApi, 'global', '_loadPlugins');
+    this.reportingService.trackApi(fakeApi, 'global', '_loadPlugins');
     getPluginLoader().loadPlugins(plugins);
   }
 
   _arePluginsLoaded() {
-    appContext.reportingService.trackApi(
-      fakeApi,
-      'global',
-      '_arePluginsLoaded'
-    );
+    this.reportingService.trackApi(fakeApi, 'global', '_arePluginsLoaded');
     return getPluginLoader().arePluginsLoaded();
   }
 
   _isPluginEnabled(pathOrUrl: string) {
-    appContext.reportingService.trackApi(fakeApi, 'global', '_isPluginEnabled');
+    this.reportingService.trackApi(fakeApi, 'global', '_isPluginEnabled');
     return getPluginLoader().isPluginEnabled(pathOrUrl);
   }
 
@@ -235,7 +249,7 @@
   }
 
   _isPluginLoaded(pathOrUrl: string) {
-    appContext.reportingService.trackApi(fakeApi, 'global', '_isPluginLoaded');
+    this.reportingService.trackApi(fakeApi, 'global', '_isPluginLoaded');
     return getPluginLoader().isPluginLoaded(pathOrUrl);
   }
 
@@ -262,48 +276,44 @@
    * });
    */
   addListener(eventName: string, cb: EventCallback) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'addListener');
-    return appContext.eventEmitter.addListener(eventName, cb);
+    this.reportingService.trackApi(fakeApi, 'global', 'addListener');
+    return this.eventEmitter.addListener(eventName, cb);
   }
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   dispatch(eventName: string, detail: any) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'dispatch');
-    return appContext.eventEmitter.dispatch(eventName, detail);
+    this.reportingService.trackApi(fakeApi, 'global', 'dispatch');
+    return this.eventEmitter.dispatch(eventName, detail);
   }
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   emit(eventName: string, detail: any) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'emit');
-    return appContext.eventEmitter.emit(eventName, detail);
+    this.reportingService.trackApi(fakeApi, 'global', 'emit');
+    return this.eventEmitter.emit(eventName, detail);
   }
 
   off(eventName: string, cb: EventCallback) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'off');
-    return appContext.eventEmitter.off(eventName, cb);
+    this.reportingService.trackApi(fakeApi, 'global', 'off');
+    this.eventEmitter.off(eventName, cb);
   }
 
   on(eventName: string, cb: EventCallback) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'on');
-    return appContext.eventEmitter.on(eventName, cb);
+    this.reportingService.trackApi(fakeApi, 'global', 'on');
+    return this.eventEmitter.on(eventName, cb);
   }
 
   once(eventName: string, cb: EventCallback) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'once');
-    return appContext.eventEmitter.once(eventName, cb);
+    this.reportingService.trackApi(fakeApi, 'global', 'once');
+    return this.eventEmitter.once(eventName, cb);
   }
 
   removeAllListeners(eventName: string) {
-    appContext.reportingService.trackApi(
-      fakeApi,
-      'global',
-      'removeAllListeners'
-    );
-    return appContext.eventEmitter.removeAllListeners(eventName);
+    this.reportingService.trackApi(fakeApi, 'global', 'removeAllListeners');
+    this.eventEmitter.removeAllListeners(eventName);
   }
 
   removeListener(eventName: string, cb: EventCallback) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'removeListener');
-    return appContext.eventEmitter.removeListener(eventName, cb);
+    this.reportingService.trackApi(fakeApi, 'global', 'removeListener');
+    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 95a67ab..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
+++ /dev/null
@@ -1,69 +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_initGerritPluginApi} from './gr-gerrit.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {appContext} from '../../../services/app-context.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-gerrit tests', () => {
-  let element;
-
-  let clock;
-
-  setup(() => {
-    clock = sinon.useFakeTimers();
-
-    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
-    stubRestApi('send').returns(Promise.resolve({status: 200}));
-    element = appContext.jsApiService;
-  });
-
-  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 4bb5fd4..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';
@@ -32,14 +32,17 @@
 } from './gr-js-api-types';
 import {EventType, TargetElement} from '../../../api/plugin';
 import {DiffLayer, HighlightJS, ParsedChangeInfo} from '../../../types/types';
-import {appContext} from '../../../services/app-context';
 import {MenuLink} from '../../../api/admin';
+import {Finalizable} from '../../../services/registry';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 const elements: {[key: string]: HTMLElement} = {};
 const eventCallbacks: {[key: string]: EventCallback[]} = {};
 
-export class GrJsApiInterface implements JsApiService {
-  private readonly reporting = appContext.reportingService;
+export class GrJsApiInterface implements JsApiService, Finalizable {
+  constructor(readonly reporting: ReportingService) {}
+
+  finalize() {}
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   handleEvent(type: EventType, detail: any) {
@@ -95,8 +98,12 @@
     const cancelSubmit = submitCallbacks.some(callback => {
       try {
         return callback(change, revision) === false;
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          new Error('canSubmitChange callback error'),
+          undefined,
+          err
+        );
       }
       return false;
     });
@@ -116,8 +123,12 @@
     for (const cb of this._getEventCallbacks(EventType.HISTORY)) {
       try {
         cb(detail.path);
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          new Error('handleHistory callback error'),
+          undefined,
+          err
+        );
       }
     }
   }
@@ -157,8 +168,12 @@
     for (const cb of this._getEventCallbacks(EventType.SHOW_CHANGE)) {
       try {
         cb(change, revision, info);
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          new Error('showChange callback error'),
+          undefined,
+          err
+        );
       }
     }
   }
@@ -170,8 +185,12 @@
     for (const cb of registeredCallbacks) {
       try {
         cb(detail.revisionActions, detail.change);
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          new Error('showRevisionActions callback error'),
+          undefined,
+          err
+        );
       }
     }
   }
@@ -180,8 +199,12 @@
     for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
       try {
         cb(change, msg);
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          new Error('commitMessage callback error'),
+          undefined,
+          err
+        );
       }
     }
   }
@@ -191,8 +214,12 @@
     for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
       try {
         cb(detail.node);
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          new Error('comment callback error'),
+          undefined,
+          err
+        );
       }
     }
   }
@@ -201,8 +228,12 @@
     for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
       try {
         cb(detail.change);
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          new Error('labelChange callback error'),
+          undefined,
+          err
+        );
       }
     }
   }
@@ -211,8 +242,12 @@
     for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
       try {
         cb(detail.hljs);
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          new Error('HighlightjsLoaded callback error'),
+          undefined,
+          err
+        );
       }
     }
   }
@@ -221,8 +256,12 @@
     for (const cb of this._getEventCallbacks(EventType.REVERT)) {
       try {
         revertMsg = cb(change, revertMsg, origMsg) as string;
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          new Error('modifyRevertMsg callback error'),
+          undefined,
+          err
+        );
       }
     }
     return revertMsg;
@@ -240,8 +279,12 @@
           revertSubmissionMsg,
           origMsg
         ) as string;
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          new Error('modifyRevertSubmissionMsg callback error'),
+          undefined,
+          err
+        );
       }
     }
     return revertSubmissionMsg;
@@ -254,8 +297,12 @@
       try {
         const layer = annotationApi.createLayer(path);
         if (layer) layers.push(layer);
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          new Error('getDiffLayers callback error'),
+          undefined,
+          err
+        );
       }
     }
     return layers;
@@ -266,8 +313,12 @@
       try {
         const annotationApi = cb as unknown as GrAnnotationActionsInterface;
         annotationApi.disposeLayer(path);
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          new Error('disposeDiffLayers callback error'),
+          undefined,
+          err
+        );
       }
     }
   }
@@ -310,10 +361,14 @@
         if (hasOwnProperty(r, 'labels')) {
           review = r as ReviewInput;
         } else {
-          review = {labels: r as LabelNameToValuesMap};
+          review = {labels: r as LabelNameToValueMap};
         }
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          new Error('getReviewPostRevert callback error'),
+          undefined,
+          err
+        );
       }
     }
     return review;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index 29db685..c45bbf5 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -21,12 +21,9 @@
 import {EventType} from '../../../api/plugin.js';
 import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
 import {getPluginLoader} from './gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
 import {stubRestApi} from '../../../test/test-utils.js';
-import {appContext} from '../../../services/app-context.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
+import {getAppContext} from '../../../services/app-context.js';
 
 suite('GrJsApiInterface tests', () => {
   let element;
@@ -45,9 +42,9 @@
 
     stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
     sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
-    element = appContext.jsApiService;
+    element = getAppContext().jsApiService;
     errorStub = sinon.stub(element.reporting, 'error');
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     getPluginLoader().loadPlugins([]);
   });
@@ -300,7 +297,7 @@
     setup(() => {
       stubBaseUrl('/r');
 
-      pluginApi.install(p => { baseUrlPlugin = p; }, '0.1',
+      window.Gerrit.install(p => { baseUrlPlugin = p; }, '0.1',
           'http://test.com/r/plugins/baseurlplugin/static/test.js');
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index 9644ef3..7e6a0c7 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -21,6 +21,7 @@
   ReviewInput,
   RevisionInfo,
 } from '../../../types/common';
+import {Finalizable} from '../../../services/registry';
 import {EventType, TargetElement} from '../../../api/plugin';
 import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
@@ -40,7 +41,7 @@
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventCallback = (...args: any[]) => any;
 
-export interface JsApiService {
+export interface JsApiService extends Finalizable {
   getElement(key: TargetElement): HTMLElement;
   addEventCallback(eventName: EventType, callback: EventCallback): void;
   modifyRevertSubmissionMsg(
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index 46c7ad6..2f9bbcf 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -22,7 +22,7 @@
 import {windowLocationReload} from '../../../utils/dom-util';
 import {PopupPluginApi} from '../../../api/popup';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 
 interface ButtonCallBacks {
   onclick: (event: Event) => boolean;
@@ -31,7 +31,7 @@
 export class GrPluginActionContext {
   private popups: PopupPluginApi[] = [];
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   constructor(
     public readonly plugin: PluginApi,
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
index 34c976a..d4b93a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
@@ -18,18 +18,15 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-js-api-interface.js';
 import {GrPluginActionContext} from './gr-plugin-action-context.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {addListenerForTest} from '../../../test/test-utils.js';
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-plugin-action-context tests', () => {
   let instance;
 
   let plugin;
 
   setup(() => {
-    pluginApi.install(p => { plugin = p; }, '0.1',
+    window.Gerrit.install(p => { plugin = p; }, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     instance = new GrPluginActionContext(plugin);
   });
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 b039a7e..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;
     }
   }
@@ -177,7 +177,6 @@
   }
 }
 
-// TODO(dmfilippov): Convert to service and add to appContext
 let pluginEndpoints = new GrPluginEndpoints();
 
 // To avoid mutable-exports, we don't want to export above variable directly
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
index c7bdfb4..16846f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
@@ -18,12 +18,9 @@
 import {resetPlugins} from '../../../test/test-utils';
 import './gr-js-api-interface';
 import {GrPluginEndpoints} from './gr-plugin-endpoints';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit';
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 export class MockHook<T extends PluginElement> implements HookApi<T> {
   handleInstanceDetached(_: T) {}
 
@@ -59,7 +56,7 @@
   setup(() => {
     domHook = new MockHook<PluginElement>();
     instance = new GrPluginEndpoints();
-    pluginApi.install(
+    window.Gerrit.install(
       plugin => (decoratePlugin = plugin),
       '0.1',
       'http://test.com/plugins/testplugin/static/decorate.js'
@@ -70,7 +67,7 @@
       moduleName: 'decorate-module',
       domHook,
     });
-    pluginApi.install(
+    window.Gerrit.install(
       plugin => (stylePlugin = plugin),
       '0.1',
       'http://test.com/plugins/testplugin/static/style.js'
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 7c99480..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
@@ -14,14 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {appContext} from '../../../services/app-context';
-import {PLUGIN_LOADING_TIMEOUT_MS, getPluginNameFromUrl} from './gr-api-utils';
+import {getAppContext} from '../../../services/app-context';
+import {
+  PLUGIN_LOADING_TIMEOUT_MS,
+  getPluginNameFromUrl,
+  isThemeFile,
+  THEME_JS,
+} from './gr-api-utils';
 import {Plugin} from './gr-public-js-api';
 import {getBaseUrl} from '../../../utils/url-util';
 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 {
   /** State that indicates the plugin is pending to be loaded. */
@@ -83,9 +88,11 @@
   // Resolver to resolve _loadingPromise once all plugins loaded
   _loadingResolver: (() => void) | null = null;
 
+  private instanceId?: string;
+
   _getReporting() {
     if (!this._reporting) {
-      this._reporting = appContext.reportingService;
+      this._reporting = getAppContext().reportingService;
     }
     return this._reporting;
   }
@@ -100,7 +107,8 @@
   /**
    * Load multiple plugins with certain options.
    */
-  loadPlugins(plugins: string[] = []) {
+  loadPlugins(plugins: string[] = [], instanceId?: string) {
+    this.instanceId = instanceId;
     this._pluginListLoaded = true;
 
     plugins.forEach(path => {
@@ -134,8 +142,8 @@
     if (!(url instanceof URL)) {
       try {
         url = new URL(url);
-      } catch (e) {
-        this._getReporting().error(e);
+      } catch (e: unknown) {
+        this._getReporting().error(new Error('url parse error'), undefined, e);
         return false;
       }
     }
@@ -182,8 +190,16 @@
     try {
       callback(plugin);
       this._pluginInstalled(url, plugin);
-    } catch (e) {
-      this._failToLoad(`${e.name}: ${e.message}`, src);
+    } catch (e: unknown) {
+      if (e instanceof Error) {
+        this._failToLoad(`${e.name}: ${e.message}`, src);
+      } else {
+        this._getReporting().error(
+          new Error('plugin callback error'),
+          undefined,
+          e
+        );
+      }
     }
   }
 
@@ -209,20 +225,16 @@
       this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
     }
     this._checkIfCompleted();
-    return `Timeout when loading plugins: ${pending
+    const errorMessage = `Timeout when loading plugins: ${pending
       .map(p => p.name)
       .join(',')}`;
+    fireAlert(document, errorMessage);
+    return errorMessage;
   }
 
   _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();
   }
@@ -241,7 +253,7 @@
         plugin: null,
       });
     }
-    console.info(`Plugin ${key} ${state}`);
+    console.debug(`Plugin ${key} ${state}`);
     return this._plugins.get(key)!;
   }
 
@@ -309,16 +321,16 @@
   }
 
   _urlFor(pathOrUrl: string, assetsPath?: string): string {
-    // theme is per host, should always load from assetsPath
-    const isThemeFile = pathOrUrl.endsWith('static/gerrit-theme.js');
-    const shouldTryLoadFromAssetsPathFirst = !isThemeFile && assetsPath;
+    if (isThemeFile(pathOrUrl)) {
+      if (assetsPath && this.instanceId) {
+        return `${assetsPath}/hosts/${this.instanceId}${THEME_JS}`;
+      }
+      return window.location.origin + getBaseUrl() + THEME_JS;
+    }
+
     if (pathOrUrl.startsWith('http')) {
       // Plugins are loaded from another domain or preloaded.
-      if (
-        pathOrUrl.includes(location.host) &&
-        shouldTryLoadFromAssetsPathFirst &&
-        assetsPath
-      ) {
+      if (pathOrUrl.includes(location.host) && assetsPath) {
         // if is loading from host server, try replace with cdn when assetsPath provided
         return pathOrUrl.replace(location.origin, assetsPath);
       }
@@ -328,11 +340,9 @@
     if (!pathOrUrl.startsWith('/')) {
       pathOrUrl = '/' + pathOrUrl;
     }
-
-    if (shouldTryLoadFromAssetsPathFirst && assetsPath) {
+    if (assetsPath) {
       return assetsPath + pathOrUrl;
     }
-
     return window.location.origin + getBaseUrl() + pathOrUrl;
   }
 
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 60%
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 ab69267..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,29 +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 {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
+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;
   });
 
@@ -47,26 +52,38 @@
   });
 
   test('reuse plugin for install calls', () => {
-    pluginApi.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;
-    pluginApi.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);
   });
 
   test('versioning', () => {
     const callback = sinon.spy();
-    pluginApi.install(callback, '0.0pre-alpha');
+    window.Gerrit.install(callback, '0.0pre-alpha');
     assert(callback.notCalled);
   });
 
   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);
   });
@@ -88,11 +105,13 @@
   });
 
   test('plugins installed successfully', async () => {
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => void 0, undefined, 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',
@@ -106,8 +125,8 @@
   });
 
   test('isPluginEnabled and isPluginLoaded', () => {
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => void 0, undefined, url);
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
+      window.Gerrit.install(() => void 0, undefined, url);
     });
 
     const plugins = [
@@ -117,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 () => {
@@ -136,16 +153,22 @@
     const alertStub = sinon.stub();
     addListenerForTest(document, 'show-alert', alertStub);
 
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.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);
 
@@ -164,20 +187,26 @@
     const alertStub = sinon.stub();
     addListenerForTest(document, 'show-alert', alertStub);
 
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.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();
@@ -197,14 +226,20 @@
     const alertStub = sinon.stub();
     addListenerForTest(document, 'show-alert', alertStub);
 
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.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);
 
@@ -223,13 +258,14 @@
     const alertStub = sinon.stub();
     addListenerForTest(document, 'show-alert', alertStub);
 
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.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);
 
@@ -240,11 +276,13 @@
   });
 
   test('multiple assets for same plugin installed successfully', async () => {
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => void 0, undefined, 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',
@@ -259,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', () => {
@@ -273,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(() => {
@@ -336,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')
+      );
     });
   });
 
@@ -372,23 +403,20 @@
       '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 => {
-      pluginApi.install(() => pluginCallback(url), undefined, url);
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
+      window.Gerrit.install(() => pluginCallback(url), undefined, url);
     });
 
     pluginLoader.loadPlugins(plugins);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
index 2b6db21..3c2842d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -16,7 +16,7 @@
  */
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback, RestPluginApi} from '../../../api/rest';
 import {PluginApi} from '../../../api/plugin';
 
@@ -35,9 +35,9 @@
 }
 
 export class GrPluginRestApi implements RestPluginApi {
-  private readonly restApi = appContext.restApiService;
+  private readonly restApi = getAppContext().restApiService;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   constructor(readonly plugin: PluginApi, private readonly prefix = '') {
     this.reporting.trackApi(this.plugin, 'rest', 'constructor');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
index d2b5658..730f163 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
@@ -18,11 +18,8 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-js-api-interface.js';
 import {GrPluginRestApi} from './gr-plugin-rest-api.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {stubRestApi} from '../../../test/test-utils.js';
 
-const pluginApi = _testOnly_initGerritPluginApi();
-
 suite('gr-plugin-rest-api tests', () => {
   let instance;
   let getResponseObjectStub;
@@ -33,7 +30,7 @@
     getResponseObjectStub = stubRestApi('getResponseObject').returns(
         Promise.resolve());
     sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
-    pluginApi.install(p => {}, '0.1',
+    window.Gerrit.install(p => {}, '0.1',
         'http://test.com/plugins/testplugin/static/test.js');
     instance = new GrPluginRestApi();
   });
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 18737a9..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
@@ -32,7 +32,7 @@
 import {HttpMethod} from '../../../constants/constants';
 import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
 import {GrChecksApi} from '../../plugins/gr-checks-api/gr-checks-api';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {AdminPluginApi} from '../../../api/admin';
 import {AnnotationPluginApi} from '../../../api/annotation';
 import {EventHelperPluginApi} from '../../../api/event-helper';
@@ -71,9 +71,11 @@
 
   private readonly _name: string = PLUGIN_NAME_NOT_SET;
 
-  private readonly jsApi = appContext.jsApiService;
+  private readonly jsApi = getAppContext().jsApiService;
 
-  private readonly report = appContext.reportingService;
+  private readonly report = getAppContext().reportingService;
+
+  private readonly restApiService = getAppContext().restApiService;
 
   constructor(url?: string) {
     this.domHooks = new GrDomHooksManager(this);
@@ -97,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,
@@ -175,7 +180,7 @@
 
   getServerInfo() {
     this.report.trackApi(this, 'plugin', 'getServerInfo');
-    return appContext.restApiService.getConfig();
+    return this.restApiService.getConfig();
   }
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -212,7 +217,7 @@
     callback?: SendCallback,
     payload?: RequestPayload
   ) {
-    return send(method, this.url(url), callback, payload);
+    return send(this.restApiService, method, this.url(url), callback, payload);
   }
 
   annotationApi(): AnnotationPluginApi {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
index 0427b43..d600101 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {PluginApi} from '../../../api/plugin';
 import {EventDetails, ReportingPluginApi} from '../../../api/reporting';
 
@@ -23,7 +23,7 @@
  * Defines all methods that will be exported to plugin from reporting service.
  */
 export class GrReportingJsApi implements ReportingPluginApi {
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   constructor(private readonly plugin: PluginApi) {
     this.reporting.trackApi(this.plugin, 'reporting', 'constructor');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
deleted file mode 100644
index 71cc565..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
+++ /dev/null
@@ -1,77 +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 '../../change/gr-reply-dialog/gr-reply-dialog.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-import {appContext} from '../../../services/app-context.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-reporting-js-api tests', () => {
-  let reporting;
-  let plugin;
-
-  setup(() => {
-    stubRestApi('getAccount').returns(Promise.resolve(null));
-  });
-
-  suite('early init', () => {
-    setup(() => {
-      pluginApi.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      reporting = plugin.reporting();
-    });
-
-    teardown(() => {
-      reporting = null;
-    });
-
-    test('redirect reportInteraction call to reportingService', () => {
-      sinon.spy(appContext.reportingService, 'reportPluginInteractionLog');
-      reporting.reportInteraction('test', {});
-      assert.isTrue(appContext.reportingService.reportPluginInteractionLog
-          .called);
-      assert.equal(
-          appContext.reportingService.reportPluginInteractionLog.lastCall
-              .args[0],
-          'testplugin-test'
-      );
-      assert.deepEqual(
-          appContext.reportingService.reportPluginInteractionLog.lastCall
-              .args[1],
-          {}
-      );
-    });
-
-    test('redirect reportLifeCycle call to reportingService', () => {
-      sinon.spy(appContext.reportingService, 'reportPluginLifeCycleLog');
-      reporting.reportLifeCycle('test', {});
-      assert.isTrue(appContext.reportingService.reportPluginLifeCycleLog
-          .called);
-      assert.equal(
-          appContext.reportingService.reportPluginLifeCycleLog.lastCall.args[0],
-          'testplugin-test'
-      );
-      assert.deepEqual(
-          appContext.reportingService.reportPluginLifeCycleLog.lastCall.args[1],
-          {}
-      );
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
new file mode 100644
index 0000000..c96a075
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
@@ -0,0 +1,64 @@
+/**
+ * @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 '../../change/gr-reply-dialog/gr-reply-dialog.js';
+import {getAppContext} from '../../../services/app-context.js';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {PluginApi} from '../../../api/plugin.js';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting.js';
+import {ReportingPluginApi} from '../../../api/reporting.js';
+
+suite('gr-reporting-js-api tests', () => {
+  let plugin: PluginApi;
+  let reportingService: ReportingService;
+
+  setup(() => {
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    reportingService = getAppContext().reportingService;
+  });
+
+  suite('early init', () => {
+    let reporting: ReportingPluginApi;
+    setup(() => {
+      window.Gerrit.install(
+        p => {
+          plugin = p;
+        },
+        '0.1',
+        'http://test.com/plugins/testplugin/static/test.js'
+      );
+      reporting = plugin.reporting();
+    });
+
+    test('redirect reportInteraction call to reportingService', () => {
+      const spy = sinon.spy(reportingService, 'reportPluginInteractionLog');
+      reporting.reportInteraction('test', {});
+      assert.isTrue(spy.called);
+      assert.equal(spy.lastCall.args[0], 'testplugin-test');
+      assert.deepEqual(spy.lastCall.args[1], {});
+    });
+
+    test('redirect reportLifeCycle call to reportingService', () => {
+      const spy = sinon.spy(reportingService, 'reportPluginLifeCycleLog');
+      reporting.reportLifeCycle('test', {});
+      assert.isTrue(spy.called);
+      assert.equal(spy.lastCall.args[0], 'testplugin-test');
+      assert.deepEqual(spy.lastCall.args[1], {});
+    });
+  });
+});
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 a65bb75..13375b3 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
@@ -19,13 +19,11 @@
 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 {
   AccountInfo,
   LabelInfo,
@@ -44,16 +42,16 @@
   getVotingRangeOrDefault,
   hasNeutralStatus,
   hasVoted,
+  showNewSubmitRequirements,
   valueString,
 } from '../../../utils/label-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {votingStyles} from '../../../styles/gr-voting-styles';
 import {ifDefined} from 'lit/directives/if-defined';
 import {fireReload} from '../../../utils/event-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {sortReviewers} from '../../../utils/attention-set-util';
 
 declare global {
@@ -104,13 +102,11 @@
   @property({type: Boolean})
   showAllReviewers = true;
 
-  /** temporary until submit requirements are finished */
-  @property({type: Boolean})
-  showAlwaysOldUI = false;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly reporting = getAppContext().reportingService;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly flagsService = getAppContext().flagsService;
 
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
@@ -176,7 +172,7 @@
         gr-button[disabled] iron-icon {
           color: var(--border-color);
         }
-        gr-account-link {
+        gr-account-label {
           --account-max-length: 100px;
           margin-right: var(--spacing-xs);
         }
@@ -205,19 +201,13 @@
         gr-vote-chip {
           --gr-vote-chip-width: 14px;
           --gr-vote-chip-height: 14px;
-          margin-right: var(--spacing-s);
         }
       `,
     ];
   }
 
-  private readonly flagsService = appContext.flagsService;
-
   override render() {
-    if (
-      this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) &&
-      !this.showAlwaysOldUI
-    ) {
+    if (showNewSubmitRequirements(this.flagsService, this.change)) {
       return this.renderNewSubmitRequirements();
     } else {
       return this.renderOldSubmitRequirements();
@@ -228,11 +218,19 @@
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
     const reviewers = (this.change?.reviewers['REVIEWER'] ?? [])
-      .filter(
-        reviewer =>
-          (this.showAllReviewers && canVote(labelInfo, reviewer)) ||
-          (!this.showAllReviewers && hasVoted(labelInfo, reviewer))
-      )
+      .filter(reviewer => {
+        if (this.showAllReviewers) {
+          if (isDetailedLabelInfo(labelInfo)) {
+            return canVote(labelInfo, reviewer);
+          } else {
+            // isQuickLabelInfo
+            return hasVoted(labelInfo, reviewer);
+          }
+        } else {
+          // !showAllReviewers
+          return hasVoted(labelInfo, reviewer);
+        }
+      })
       .sort((r1, r2) => sortReviewers(r1, r2, this.change, this.account));
     return html`<div>
       ${reviewers.map(reviewer => this.renderReviewerVote(reviewer))}
@@ -258,16 +256,26 @@
 
   renderReviewerVote(reviewer: AccountInfo) {
     const labelInfo = this.labelInfo;
-    if (!labelInfo || !isDetailedLabelInfo(labelInfo)) return;
-    const approvalInfo = getApprovalInfo(labelInfo, reviewer);
+    if (!labelInfo) return;
+    const approvalInfo = isDetailedLabelInfo(labelInfo)
+      ? getApprovalInfo(labelInfo, reviewer)
+      : undefined;
     const noVoteYet =
-      !approvalInfo || hasNeutralStatus(labelInfo, approvalInfo);
+      !hasVoted(labelInfo, reviewer) ||
+      (isDetailedLabelInfo(labelInfo) &&
+        hasNeutralStatus(labelInfo, approvalInfo));
     return html`<div class="reviewer-row">
-      <gr-account-chip .account="${reviewer}" .change="${this.change}">
+      <gr-account-chip
+        .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>
       ${noVoteYet
@@ -282,7 +290,7 @@
       <td>
         <gr-tooltip-content
           has-tooltip
-          title="${this._computeValueTooltip(labelInfo, mappedLabel.value)}"
+          title=${this._computeValueTooltip(labelInfo, mappedLabel.value)}
         >
           <gr-label class="${mappedLabel.className} voteChip font-small">
             ${mappedLabel.value}
@@ -290,10 +298,11 @@
         </gr-tooltip-content>
       </td>
       <td>
-        <gr-account-link
-          .account="${mappedLabel.account}"
-          .change="${change}"
-        ></gr-account-link>
+        <gr-account-label
+          clickable
+          .account=${mappedLabel.account}
+          .change=${change}
+        ></gr-account-label>
       </td>
       <td>${this.renderRemoveVote(mappedLabel.account)}</td>
     </tr>`;
@@ -317,8 +326,8 @@
       <gr-button
         link
         aria-label="Remove vote"
-        @click="${this.onDeleteVote}"
-        data-account-id="${ifDefined(reviewer._account_id)}"
+        @click=${this.onDeleteVote}
+        data-account-id=${ifDefined(reviewer._account_id as number | undefined)}
         class="deleteBtn ${this.computeDeleteClass(
           reviewer,
           this.mutable,
@@ -438,7 +447,7 @@
     if (!this.change) return;
 
     e.preventDefault();
-    let target = (dom(e) as EventApi).rootTarget as GrButton;
+    let target = e.composedPath()[0] as GrButton;
     while (!target.classList.contains('deleteBtn')) {
       if (!target.parentElement) {
         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..0ac49a7 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
@@ -28,12 +28,12 @@
 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,
   createParsedChange,
 } from '../../../test/test-data-generators';
 import {LabelInfo} from '../../../types/common';
+import {GrAccountLabel} from '../gr-account-label/gr-account-label';
 
 const basicFixture = fixtureFromElement('gr-label-info');
 
@@ -198,7 +198,7 @@
         },
       };
       await element.updateComplete;
-      const chips = queryAll<GrAccountLink>(element, 'gr-account-link');
+      const chips = queryAll<GrAccountLabel>(element, 'gr-account-label');
       assert.equal(chips[0].account!._account_id, element.account._account_id);
     });
   });
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 85f29cb..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
@@ -16,24 +16,19 @@
  */
 import '../gr-autocomplete/gr-autocomplete';
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-labeled-autocomplete_html';
-import {customElement, property} from '@polymer/decorators';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, query} from 'lit/decorators';
 import {
   GrAutocomplete,
   AutocompleteQuery,
 } from '../gr-autocomplete/gr-autocomplete';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
 
-export interface GrLabeledAutocomplete {
-  $: {
-    autocomplete: GrAutocomplete;
-  };
-}
 @customElement('gr-labeled-autocomplete')
-export class GrLabeledAutocomplete extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrLabeledAutocomplete extends LitElement {
+  @query('#autocomplete')
+  autocomplete?: GrAutocomplete;
 
   /**
    * Fired when a value is chosen.
@@ -44,7 +39,7 @@
   @property({type: Object})
   query: AutocompleteQuery = () => Promise.resolve([]);
 
-  @property({type: String, notify: true})
+  @property({type: String})
   text = '';
 
   @property({type: String})
@@ -56,15 +51,73 @@
   @property({type: Boolean})
   disabled = false;
 
-  _handleTriggerClick(e: Event) {
+  static override get styles() {
+    return css`
+      :host {
+        display: block;
+        width: 12em;
+      }
+      #container {
+        background: var(--chip-background-color);
+        border-radius: 1em;
+        padding: var(--spacing-m);
+      }
+      #header {
+        color: var(--deemphasized-text-color);
+        font-weight: var(--font-weight-bold);
+        font-size: var(--font-size-small);
+      }
+      #body {
+        display: flex;
+      }
+      #trigger {
+        color: var(--deemphasized-text-color);
+        cursor: pointer;
+        padding-left: var(--spacing-s);
+      }
+      #trigger:hover {
+        color: var(--primary-text-color);
+      }
+    `;
+  }
+
+  override render() {
+    return html`
+      <div id="container">
+        <div id="header">${this.label}</div>
+        <div id="body">
+          <gr-autocomplete
+            id="autocomplete"
+            threshold="0"
+            .query=${this.query}
+            ?disabled=${this.disabled}
+            .placeholder=${this.placeholder}
+            borderless=""
+          ></gr-autocomplete>
+          <div id="trigger" @click=${this._handleTriggerClick}>▼</div>
+        </div>
+      </div>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('text')) {
+      fire(this, 'text-changed', {value: this.text});
+    }
+  }
+
+  // Private but used in tests.
+  _handleTriggerClick = (e: Event) => {
     // Stop propagation here so we don't confuse gr-autocomplete, which
     // listens for taps on body to try to determine when it's blurred.
     e.stopPropagation();
-    this.$.autocomplete.focus();
-  }
+    assertIsDefined(this.autocomplete);
+    this.autocomplete.focus();
+  };
 
   setText(text: string) {
-    this.$.autocomplete.setText(text);
+    assertIsDefined(this.autocomplete);
+    this.autocomplete.setText(text);
   }
 
   clear() {
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts
deleted file mode 100644
index 934ab84..0000000
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts
+++ /dev/null
@@ -1,61 +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: 12em;
-    }
-    #container {
-      background: var(--chip-background-color);
-      border-radius: 1em;
-      padding: var(--spacing-m);
-    }
-    #header {
-      color: var(--deemphasized-text-color);
-      font-weight: var(--font-weight-bold);
-      font-size: var(--font-size-small);
-    }
-    #body {
-      display: flex;
-    }
-    #trigger {
-      color: var(--deemphasized-text-color);
-      cursor: pointer;
-      padding-left: var(--spacing-s);
-    }
-    #trigger:hover {
-      color: var(--primary-text-color);
-    }
-  </style>
-  <div id="container">
-    <div id="header">[[label]]</div>
-    <div id="body">
-      <gr-autocomplete
-        id="autocomplete"
-        threshold="0"
-        query="[[query]]"
-        disabled="[[disabled]]"
-        placeholder="[[placeholder]]"
-        borderless=""
-      ></gr-autocomplete>
-      <div id="trigger" on-click="_handleTriggerClick">▼</div>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
index d6fc45f..ee3ddcb 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
@@ -18,28 +18,51 @@
 import '../../../test/common-test-setup-karma';
 import './gr-labeled-autocomplete';
 import {GrLabeledAutocomplete} from './gr-labeled-autocomplete';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const basicFixture = fixtureFromElement('gr-labeled-autocomplete');
 
 suite('gr-labeled-autocomplete tests', () => {
   let element: GrLabeledAutocomplete;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
   test('tapping trigger focuses autocomplete', () => {
     const e = {stopPropagation: () => undefined};
     const stopPropagationStub = sinon.stub(e, 'stopPropagation');
-    const autocompleteStub = sinon.stub(element.$.autocomplete, 'focus');
+    assertIsDefined(element.autocomplete);
+    const autocompleteStub = sinon.stub(element.autocomplete, 'focus');
     element._handleTriggerClick(e as Event);
     assert.isTrue(stopPropagationStub.calledOnce);
     assert.isTrue(autocompleteStub.calledOnce);
   });
 
   test('setText', () => {
-    const setTextStub = sinon.stub(element.$.autocomplete, 'setText');
+    assertIsDefined(element.autocomplete);
+    const setTextStub = sinon.stub(element.autocomplete, 'setText');
     element.setText('foo-bar');
     assert.isTrue(setTextStub.calledWith('foo-bar'));
   });
+
+  test('shadowDom', async () => {
+    element.label = 'Some label';
+    await element.updateComplete;
+
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div id="container">
+        <div id="header">Some label</div>
+        <div id="body">
+          <gr-autocomplete
+            id="autocomplete"
+            threshold="0"
+            borderless=""
+          ></gr-autocomplete>
+          <div id="trigger">▼</div>
+        </div>
+      </div>
+    `);
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
index a3f128b..fe69a32 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
@@ -86,7 +86,7 @@
         reject(new Error('Unable to load blank script url.'));
         return;
       }
-
+      script.setAttribute('crossorigin', 'anonymous');
       script.setAttribute('src', src);
       script.onload = resolve;
       script.onerror = reject;
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 da13396..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 {appContext} 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-'});
-    appContext.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 7008db2..9bb112e 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
@@ -16,6 +16,7 @@
  */
 import {customElement, property} from 'lit/decorators';
 import {html, LitElement} from 'lit';
+import '../gr-tooltip-content/gr-tooltip-content';
 
 declare global {
   interface HTMLElementTagNameMap {
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts
similarity index 61%
rename from polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
rename to polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts
index e3e72d0..9b26894 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts
@@ -15,61 +15,71 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-limited-text.js';
-
-const basicFixture = fixtureFromElement('gr-limited-text');
+import '../../../test/common-test-setup-karma';
+import {query, queryAndAssert} from '../../../test/test-utils';
+import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
+import './gr-limited-text';
+import {GrLimitedText} from './gr-limited-text';
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-limited-text tests', () => {
-  let element;
+  let element: GrLimitedText;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture<GrLimitedText>(
+      html`<gr-limited-text></gr-limited-text>`
+    );
   });
 
   test('tooltip without title input', async () => {
     element.text = 'abc 123';
     await element.updateComplete;
-    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
+    assert.isNotOk(query(element, 'gr-tooltip-content'));
 
     element.limit = 10;
     await element.updateComplete;
-    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
+    assert.isNotOk(query(element, 'gr-tooltip-content'));
 
     element.limit = 3;
     await element.updateComplete;
-    assert.isOk(element.shadowRoot.querySelector('gr-tooltip-content'));
+    assert.isOk(query(element, 'gr-tooltip-content'));
     assert.equal(
-        element.shadowRoot.querySelector('gr-tooltip-content').title,
-        'abc 123');
+      queryAndAssert<GrTooltipContent>(element, 'gr-tooltip-content').title,
+      'abc 123'
+    );
 
     element.limit = 100;
     await element.updateComplete;
-    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
+    assert.isNotOk(query(element, 'gr-tooltip-content'));
 
-    element.limit = null;
+    element.limit = null as any;
     await element.updateComplete;
-    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
+    assert.isNotOk(query(element, 'gr-tooltip-content'));
   });
 
   test('with tooltip input', async () => {
     element.tooltip = 'abc 123';
     await element.updateComplete;
-    let tooltipContent = element.shadowRoot.querySelector('gr-tooltip-content');
+    let tooltipContent = queryAndAssert<GrTooltipContent>(
+      element,
+      'gr-tooltip-content'
+    );
     assert.isOk(tooltipContent);
     assert.equal(tooltipContent.title, 'abc 123');
 
     element.text = 'abc';
     await element.updateComplete;
-    tooltipContent = element.shadowRoot.querySelector('gr-tooltip-content');
+    tooltipContent = queryAndAssert<GrTooltipContent>(
+      element,
+      'gr-tooltip-content'
+    );
     assert.isOk(tooltipContent);
     assert.equal(tooltipContent.title, 'abc 123');
 
     element.text = 'abcdef';
     element.limit = 3;
     await element.updateComplete;
-    tooltipContent = element.shadowRoot.querySelector('gr-tooltip-content');
+    tooltipContent = queryAndAssert(element, 'gr-tooltip-content');
     assert.isOk(tooltipContent);
     assert.equal(tooltipContent.title, 'abcdef (abc 123)');
   });
@@ -84,4 +94,3 @@
     assert.equal(element.renderText(), 'foo bar');
   });
 });
-
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/gr-linked-text.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
index 2812b47..c283876 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
@@ -15,11 +15,11 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-linked-text_html';
 import {GrLinkTextParser, LinkTextParserConfig} from './link-text-parser';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, observe} from '@polymer/decorators';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {assertIsDefined} from '../../../utils/common-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -27,80 +27,105 @@
   }
 }
 
-export interface GrLinkedText {
-  $: {
-    output: HTMLSpanElement;
-  };
-}
-
 @customElement('gr-linked-text')
-export class GrLinkedText extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrLinkedText extends LitElement {
+  private outputElement?: HTMLSpanElement;
 
   @property({type: Boolean})
   removeZeroWidthSpace?: boolean;
 
-  // content default is null, because this.$.output.textContent is string|null
   @property({type: String})
-  content: string | null = null;
+  content = '';
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, attribute: true})
   pre = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, attribute: true})
   disabled = false;
 
+  @property({type: Boolean, attribute: true})
+  inline = false;
+
   @property({type: Object})
   config?: LinkTextParserConfig;
 
-  @observe('content')
-  _contentChanged(content: string | null) {
-    // In the case where the config may not be set (perhaps due to the
-    // request for it still being in flight), set the content anyway to
-    // prevent waiting on the config to display the text.
-    if (!this.config) {
-      return;
+  static override get styles() {
+    return css`
+      :host {
+        display: block;
+      }
+      :host([inline]) {
+        display: inline;
+      }
+      :host([pre]) ::slotted(span) {
+        white-space: var(--linked-text-white-space, pre-wrap);
+        word-wrap: var(--linked-text-word-wrap, break-word);
+      }
+    `;
+  }
+
+  override render() {
+    return html`<slot name="insert"></slot>`;
+  }
+
+  // NOTE: LinkTextParser dynamically creates HTML fragments based on backend
+  // configuration commentLinks. These commentLinks can contain arbitrary HTML
+  // fragments. This means that arbitrary HTML needs to be injected into the
+  // DOM-tree, where this HTML is is controlled on the server-side in the
+  // server-configuration rather than by arbitrary users.
+  // To enable this injection of 'unsafe' HTML, LinkTextParser generates
+  // HTML fragments. Lit does not support inserting html fragments directly
+  // into its DOM-tree as it controls the DOM-tree that it generates.
+  // Therefore, to get around this we create a single element that we slot into
+  // the Lit-owned DOM.  This element will not be part of this LitElement as
+  // it's slotted in and thus can be modified on the fly by handleParseResult.
+  override firstUpdated(_changedProperties: PropertyValues): void {
+    this.outputElement = document.createElement('span');
+    this.outputElement.id = 'output';
+    this.outputElement.slot = 'insert';
+    this.append(this.outputElement);
+  }
+
+  override updated(changedProperties: PropertyValues): void {
+    if (changedProperties.has('content') || changedProperties.has('config')) {
+      this._contentOrConfigChanged();
+    } else if (changedProperties.has('disabled')) {
+      this.styleLinks();
     }
-    this.$.output.textContent = content;
   }
 
   /**
    * Because either the source text or the linkification config has changed,
    * the content should be re-parsed.
+   * Private but used in tests.
    *
    * @param content The raw, un-linkified source string to parse.
    * @param config The server config specifying commentLink patterns
    */
-  @observe('content', 'config')
-  _contentOrConfigChanged(
-    content: string | null,
-    config?: LinkTextParserConfig
-  ) {
-    if (!config) {
+  _contentOrConfigChanged() {
+    if (!this.config) {
+      assertIsDefined(this.outputElement);
+      this.outputElement.textContent = this.content;
       return;
     }
 
-    // TODO(TS): mapCommentlinks always has value, remove
-    if (!GerritNav.mapCommentlinks) return;
-    config = GerritNav.mapCommentlinks(config);
-    const output = this.$.output;
-    output.textContent = '';
+    const config = GerritNav.mapCommentlinks(this.config);
+    assertIsDefined(this.outputElement);
+    this.outputElement.textContent = '';
     const parser = new GrLinkTextParser(
       config,
       (text: string | null, href: string | null, fragment?: DocumentFragment) =>
-        this._handleParseResult(text, href, fragment),
+        this.handleParseResult(text, href, fragment),
       this.removeZeroWidthSpace
     );
-    parser.parse(content);
+    parser.parse(this.content);
 
     // Ensure that external links originating from HTML commentlink configs
     // open in a new tab. @see Issue 5567
     // Ensure links to the same host originating from commentlink configs
     // open in the same tab. When target is not set - default is _self
     // @see Issue 4616
-    output.querySelectorAll('a').forEach(anchor => {
+    this.outputElement.querySelectorAll('a').forEach(anchor => {
       if (anchor.hostname === window.location.hostname) {
         anchor.removeAttribute('target');
       } else {
@@ -108,6 +133,30 @@
       }
       anchor.setAttribute('rel', 'noopener');
     });
+
+    this.styleLinks();
+  }
+
+  /**
+   * Styles the links based on whether gr-linked-text is disabled or not
+   */
+  private styleLinks() {
+    assertIsDefined(this.outputElement);
+    this.outputElement.querySelectorAll('a').forEach(anchor => {
+      anchor.setAttribute('style', this.computeLinkStyle());
+    });
+  }
+
+  private computeLinkStyle() {
+    if (this.disabled) {
+      return `
+        color: inherit;
+        text-decoration: none;
+        pointer-events: none;
+      `;
+    } else {
+      return 'color: var(--link-color)';
+    }
   }
 
   /**
@@ -119,12 +168,13 @@
    * - To attach an arbitrary fragment: when called with only the `fragment`
    *   argument, the fragment should be attached to the resulting DOM as is.
    */
-  private _handleParseResult(
+  private handleParseResult(
     text: string | null,
     href: string | null,
     fragment?: DocumentFragment
   ) {
-    const output = this.$.output;
+    assertIsDefined(this.outputElement);
+    const output = this.outputElement;
     if (href) {
       const a = document.createElement('a');
       a.setAttribute('href', href);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
deleted file mode 100644
index 0d44bc8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_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`
-  <style>
-    :host {
-      display: block;
-    }
-    :host([pre]) span {
-      white-space: var(--linked-text-white-space, pre-wrap);
-      word-wrap: var(--linked-text-word-wrap, break-word);
-    }
-    :host([disabled]) a {
-      color: inherit;
-      text-decoration: none;
-      pointer-events: none;
-    }
-    a {
-      color: var(--link-color);
-    }
-  </style>
-  <span id="output"></span>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
index b2cdba1..c31ac3c 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
@@ -18,35 +18,33 @@
 import '../../../test/common-test-setup-karma';
 import './gr-linked-text';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {fixture, html} from '@open-wc/testing-helpers';
 import {GrLinkedText} from './gr-linked-text';
 import {CommentLinks} from '../../../types/common';
 import {queryAndAssert} from '../../../test/test-utils';
 
-const basicFixture = fixtureFromTemplate(html`
-  <gr-linked-text>
-    <div id="output"></div>
-  </gr-linked-text>
-`);
-
 suite('gr-linked-text tests', () => {
   let element: GrLinkedText;
 
   let originalCanonicalPath: string | undefined;
 
-  setup(() => {
+  setup(async () => {
     originalCanonicalPath = window.CANONICAL_PATH;
-    element = basicFixture.instantiate() as GrLinkedText;
+    element = await fixture<GrLinkedText>(html`
+      <gr-linked-text>
+        <div id="output"></div>
+      </gr-linked-text>
+    `);
 
     sinon.stub(GerritNav, 'mapCommentlinks').value((x: CommentLinks) => x);
     element.config = {
       ph: {
         match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
-        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
+        link: 'https://issues.gerritcodereview.com/issues/$2',
       },
       prefixsameinlinkandpattern: {
         match: '([Hh][Tt][Tt][Pp]example)\\s*#?(\\d+)',
-        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
+        link: 'https://issues.gerritcodereview.com/issues/$2',
       },
       changeid: {
         match: '(I[0-9a-f]{8,40})',
@@ -85,11 +83,13 @@
     window.CANONICAL_PATH = originalCanonicalPath;
   });
 
-  test('URL pattern was parsed and linked.', () => {
+  test('URL pattern was parsed and linked.', async () => {
     // Regular inline link.
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    const url = 'https://issues.gerritcodereview.com/issues/40003757';
     element.content = url;
-    const linkEl = queryAndAssert(element, '#output')
+    await element.updateComplete;
+
+    const linkEl = queryAndAssert(element, 'span#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.rel, 'noopener');
@@ -97,47 +97,52 @@
     assert.equal(linkEl.textContent, url);
   });
 
-  test('Bug pattern was parsed and linked', () => {
+  test('Bug pattern was parsed and linked', async () => {
     // "Issue/Bug" pattern.
-    element.content = 'Issue 3650';
+    element.content = 'Issue 40003757';
+    await element.updateComplete;
 
-    let linkEl = queryAndAssert(element, '#output')
+    let linkEl = queryAndAssert(element, 'span#output')
       .childNodes[0] as HTMLAnchorElement;
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    const url = 'https://issues.gerritcodereview.com/issues/40003757';
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'Issue 3650');
+    assert.equal(linkEl.textContent, 'Issue 40003757');
 
-    element.content = 'Bug 3650';
-    linkEl = queryAndAssert(element, '#output')
+    element.content = 'Bug 40003757';
+    await element.updateComplete;
+
+    linkEl = queryAndAssert(element, 'span#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.rel, 'noopener');
     assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'Bug 3650');
+    assert.equal(linkEl.textContent, 'Bug 40003757');
   });
 
-  test('Pattern with same prefix as link was correctly parsed', () => {
+  test('Pattern with same prefix as link was correctly parsed', async () => {
     // Pattern starts with the same prefix (`http`) as the url.
-    element.content = 'httpexample 3650';
+    element.content = 'httpexample 40003757';
+    await element.updateComplete;
 
-    assert.equal(queryAndAssert(element, '#output').childNodes.length, 1);
-    const linkEl = queryAndAssert(element, '#output')
+    assert.equal(queryAndAssert(element, 'span#output').childNodes.length, 1);
+    const linkEl = queryAndAssert(element, 'span#output')
       .childNodes[0] as HTMLAnchorElement;
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    const url = 'https://issues.gerritcodereview.com/issues/40003757';
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'httpexample 3650');
+    assert.equal(linkEl.textContent, 'httpexample 40003757');
   });
 
-  test('Change-Id pattern was parsed and linked', () => {
+  test('Change-Id pattern was parsed and linked', async () => {
     // "Change-Id:" pattern.
     const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
     const prefix = 'Change-Id: ';
     element.content = prefix + changeID;
+    await element.updateComplete;
 
-    const textNode = queryAndAssert(element, '#output').childNodes[0];
-    const linkEl = queryAndAssert(element, '#output')
+    const textNode = queryAndAssert(element, 'span#output').childNodes[0];
+    const linkEl = queryAndAssert(element, 'span#output')
       .childNodes[1] as HTMLAnchorElement;
     assert.equal(textNode.textContent, prefix);
     const url = '/q/' + changeID;
@@ -147,16 +152,17 @@
     assert.equal(linkEl.textContent, changeID);
   });
 
-  test('Change-Id pattern was parsed and linked with base url', () => {
+  test('Change-Id pattern was parsed and linked with base url', async () => {
     window.CANONICAL_PATH = '/r';
 
     // "Change-Id:" pattern.
     const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
     const prefix = 'Change-Id: ';
     element.content = prefix + changeID;
+    await element.updateComplete;
 
-    const textNode = queryAndAssert(element, '#output').childNodes[0];
-    const linkEl = queryAndAssert(element, '#output')
+    const textNode = queryAndAssert(element, 'span#output').childNodes[0];
+    const linkEl = queryAndAssert(element, 'span#output')
       .childNodes[1] as HTMLAnchorElement;
     assert.equal(textNode.textContent, prefix);
     const url = '/r/q/' + changeID;
@@ -166,45 +172,48 @@
     assert.equal(linkEl.textContent, changeID);
   });
 
-  test('Multiple matches', () => {
-    element.content = 'Issue 3650\nIssue 3450';
-    const linkEl1 = queryAndAssert(element, '#output')
+  test('Multiple matches', async () => {
+    element.content = 'Issue 40003757\nIssue 40003562';
+    await element.updateComplete;
+
+    const linkEl1 = queryAndAssert(element, 'span#output')
       .childNodes[0] as HTMLAnchorElement;
-    const linkEl2 = queryAndAssert(element, '#output')
+    const linkEl2 = queryAndAssert(element, 'span#output')
       .childNodes[2] as HTMLAnchorElement;
 
     assert.equal(linkEl1.target, '_blank');
     assert.equal(
       linkEl1.href,
-      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650'
+      'https://issues.gerritcodereview.com/issues/40003757'
     );
-    assert.equal(linkEl1.textContent, 'Issue 3650');
+    assert.equal(linkEl1.textContent, 'Issue 40003757');
 
     assert.equal(linkEl2.target, '_blank');
     assert.equal(
       linkEl2.href,
-      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450'
+      'https://issues.gerritcodereview.com/issues/40003562'
     );
-    assert.equal(linkEl2.textContent, 'Issue 3450');
+    assert.equal(linkEl2.textContent, 'Issue 40003562');
   });
 
-  test('Change-Id pattern parsed before bug pattern', () => {
+  test('Change-Id pattern parsed before bug pattern', async () => {
     // "Change-Id:" pattern.
     const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
     const prefix = 'Change-Id: ';
 
     // "Issue/Bug" pattern.
-    const bug = 'Issue 3650';
+    const bug = 'Issue 40003757';
 
     const changeUrl = '/q/' + changeID;
-    const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    const bugUrl = 'https://issues.gerritcodereview.com/issues/40003757';
 
     element.content = prefix + changeID + bug;
+    await element.updateComplete;
 
-    const textNode = queryAndAssert(element, '#output').childNodes[0];
-    const changeLinkEl = queryAndAssert(element, '#output')
+    const textNode = queryAndAssert(element, 'span#output').childNodes[0];
+    const changeLinkEl = queryAndAssert(element, 'span#output')
       .childNodes[1] as HTMLAnchorElement;
-    const bugLinkEl = queryAndAssert(element, '#output')
+    const bugLinkEl = queryAndAssert(element, 'span#output')
       .childNodes[2] as HTMLAnchorElement;
 
     assert.equal(textNode.textContent, prefix);
@@ -215,12 +224,14 @@
 
     assert.equal(bugLinkEl.target, '_blank');
     assert.equal(bugLinkEl.href, bugUrl);
-    assert.equal(bugLinkEl.textContent, 'Issue 3650');
+    assert.equal(bugLinkEl.textContent, 'Issue 40003757');
   });
 
-  test('html field in link config', () => {
+  test('html field in link config', async () => {
     element.content = 'google:do a barrel roll';
-    const linkEl = queryAndAssert(element, '#output')
+    await element.updateComplete;
+
+    const linkEl = queryAndAssert(element, 'span#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.equal(
       linkEl.getAttribute('href'),
@@ -229,155 +240,192 @@
     assert.equal(linkEl.textContent, 'do a barrel roll');
   });
 
-  test('removing hash from links', () => {
+  test('removing hash from links', async () => {
     element.content = 'hash:foo';
-    const linkEl = queryAndAssert(element, '#output')
+    await element.updateComplete;
+
+    const linkEl = queryAndAssert(element, 'span#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
-  test('html with base url', () => {
+  test('html with base url', async () => {
     window.CANONICAL_PATH = '/r';
 
     element.content = 'test foo';
-    const linkEl = queryAndAssert(element, '#output')
+    await element.updateComplete;
+
+    const linkEl = queryAndAssert(element, 'span#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
-  test('a is not at start', () => {
+  test('a is not at start', async () => {
     window.CANONICAL_PATH = '/r';
 
     element.content = 'a test foo';
-    const linkEl = queryAndAssert(element, '#output')
+    await element.updateComplete;
+
+    const linkEl = queryAndAssert(element, 'span#output')
       .childNodes[1] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
-  test('hash html with base url', () => {
+  test('hash html with base url', async () => {
     window.CANONICAL_PATH = '/r';
 
     element.content = 'hash:foo';
-    const linkEl = queryAndAssert(element, '#output')
+    await element.updateComplete;
+
+    const linkEl = queryAndAssert(element, 'span#output')
       .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
-  test('disabled config', () => {
+  test('disabled config', async () => {
     element.content = 'foo:baz';
-    assert.equal(queryAndAssert(element, '#output').innerHTML, 'foo:baz');
+    await element.updateComplete;
+
+    assert.equal(queryAndAssert(element, 'span#output').innerHTML, 'foo:baz');
   });
 
-  test('R=email labels link correctly', () => {
+  test('R=email labels link correctly', async () => {
     element.removeZeroWidthSpace = true;
     element.content = 'R=\u200Btest@google.com';
+    await element.updateComplete;
+
     assert.equal(
-      queryAndAssert(element, '#output').textContent,
+      queryAndAssert(element, 'span#output').textContent,
       'R=test@google.com'
     );
     assert.equal(
-      queryAndAssert(element, '#output').innerHTML.match(/(R=<a)/g)!.length,
+      queryAndAssert(element, 'span#output').innerHTML.match(/(R=<a)/g)!.length,
       1
     );
   });
 
-  test('CC=email labels link correctly', () => {
+  test('CC=email labels link correctly', async () => {
     element.removeZeroWidthSpace = true;
     element.content = 'CC=\u200Btest@google.com';
+    await element.updateComplete;
+
     assert.equal(
-      queryAndAssert(element, '#output').textContent,
+      queryAndAssert(element, 'span#output').textContent,
       'CC=test@google.com'
     );
     assert.equal(
-      queryAndAssert(element, '#output').innerHTML.match(/(CC=<a)/g)!.length,
+      queryAndAssert(element, 'span#output').innerHTML.match(/(CC=<a)/g)!
+        .length,
       1
     );
   });
 
-  test('only {http,https,mailto} protocols are linkified', () => {
+  test('only {http,https,mailto} protocols are linkified', async () => {
     element.content = 'xx mailto:test@google.com yy';
-    let links = queryAndAssert(element, '#output').querySelectorAll('a');
+    await element.updateComplete;
+
+    let links = queryAndAssert(element, 'span#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
     assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
     element.content = 'xx http://google.com yy';
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
+    await element.updateComplete;
+
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'http://google.com');
     assert.equal(links[0].innerHTML, 'http://google.com');
 
     element.content = 'xx https://google.com yy';
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
+    await element.updateComplete;
+
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     element.content = 'xx ssh://google.com yy';
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
+    await element.updateComplete;
+
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
     assert.equal(links.length, 0);
 
     element.content = 'xx ftp://google.com yy';
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
+    await element.updateComplete;
+
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
     assert.equal(links.length, 0);
   });
 
-  test('links without leading whitespace are linkified', () => {
+  test('links without leading whitespace are linkified', async () => {
     element.content = 'xx abcmailto:test@google.com yy';
+    await element.updateComplete;
+
     assert.equal(
-      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
       'xx abc'
     );
-    let links = queryAndAssert(element, '#output').querySelectorAll('a');
+    let links = queryAndAssert(element, 'span#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
     assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
     element.content = 'xx defhttp://google.com yy';
+    await element.updateComplete;
+
     assert.equal(
-      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
       'xx def'
     );
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'http://google.com');
     assert.equal(links[0].innerHTML, 'http://google.com');
 
     element.content = 'xx qwehttps://google.com yy';
+    await element.updateComplete;
+
     assert.equal(
-      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
       'xx qwe'
     );
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     // Non-latin character
     element.content = 'xx абвhttps://google.com yy';
+    await element.updateComplete;
+
     assert.equal(
-      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      queryAndAssert(element, 'span#output').innerHTML.substr(0, 6),
       'xx абв'
     );
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     element.content = 'xx ssh://google.com yy';
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
+    await element.updateComplete;
+
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
     assert.equal(links.length, 0);
 
     element.content = 'xx ftp://google.com yy';
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
+    await element.updateComplete;
+
+    links = queryAndAssert(element, 'span#output').querySelectorAll('a');
     assert.equal(links.length, 0);
   });
 
-  test('overlapping links', () => {
+  test('overlapping links', async () => {
     element.config = {
       b1: {
         match: '(B:\\s*)(\\d+)',
@@ -389,7 +437,9 @@
       },
     };
     element.content = '- B: 123, 45';
-    const links = element.root!.querySelectorAll('a');
+    await element.updateComplete;
+
+    const links = element.querySelectorAll('a');
 
     assert.equal(links.length, 2);
     assert.equal(
@@ -404,11 +454,11 @@
     assert.equal(links[1].textContent, '45');
   });
 
-  test('_contentOrConfigChanged called with config', () => {
-    const contentStub = sinon.stub(element, '_contentChanged');
+  test('_contentOrConfigChanged called with config', async () => {
     const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
     element.content = 'some text';
-    assert.isTrue(contentStub.called);
+    await element.updateComplete;
+
     assert.isTrue(contentConfigStub.called);
   });
 });
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..2f9c016 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
@@ -320,7 +319,7 @@
         const prefixText = result[1];
         if (prefixText.length > 0) {
           // Fix for simple cases from
-          // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
+          // https://issues.gerritcodereview.com/issues/40011392
           // When leading whitespace is missed before link,
           // linkify add this text before link as a schema name to href.
           // We suppose, that prefixText just a single word
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 4f02897..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
@@ -16,15 +16,15 @@
  */
 import '@polymer/iron-input/iron-input';
 import '@polymer/iron-icon/iron-icon';
-import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-list-view_html';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
-import {property, customElement} from '@polymer/decorators';
 import {fireEvent} from '../../../utils/event-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
 
 const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
@@ -35,11 +35,7 @@
 }
 
 @customElement('gr-list-view')
-export class GrListView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrListView extends LitElement {
   @property({type: Boolean})
   createNew?: boolean;
 
@@ -49,7 +45,7 @@
   @property({type: Number})
   itemsPerPage = 25;
 
-  @property({type: String, observer: '_filterChanged'})
+  @property({type: String})
   filter?: string;
 
   @property({type: Number})
@@ -68,37 +64,149 @@
     super.disconnectedCallback();
   }
 
-  _filterChanged(newFilter?: string, oldFilter?: string) {
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        #filter {
+          max-width: 25em;
+        }
+        #filter:focus {
+          outline: none;
+        }
+        #topContainer {
+          align-items: center;
+          display: flex;
+          height: 3rem;
+          justify-content: space-between;
+          margin: 0 var(--spacing-l);
+        }
+        #createNewContainer:not(.show) {
+          display: none;
+        }
+        a {
+          color: var(--primary-text-color);
+          text-decoration: none;
+        }
+        a:hover {
+          text-decoration: underline;
+        }
+        nav {
+          align-items: center;
+          display: flex;
+          height: 3rem;
+          justify-content: flex-end;
+          margin-right: 20px;
+        }
+        nav,
+        iron-icon {
+          color: var(--deemphasized-text-color);
+        }
+        iron-icon {
+          height: 1.85rem;
+          margin-left: 16px;
+          width: 1.85rem;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div id="topContainer">
+        <div class="filterContainer">
+          <label>Filter:</label>
+          <iron-input
+            .bindValue=${this.filter}
+            @bind-value-changed=${this.handleFilterBindValueChanged}
+          >
+            <input type="text" id="filter" />
+          </iron-input>
+        </div>
+        <div id="createNewContainer" class=${this.createNew ? 'show' : ''}>
+          <gr-button
+            id="createNew"
+            primary
+            link
+            @click=${() => this.createNewItem()}
+          >
+            Create New
+          </gr-button>
+        </div>
+      </div>
+      <slot></slot>
+      <nav>
+        Page ${this.computePage(this.offset, this.itemsPerPage)}
+        <a
+          id="prevArrow"
+          href=${this.computeNavLink(
+            this.offset,
+            -1,
+            this.itemsPerPage,
+            this.filter,
+            this.path
+          )}
+          ?hidden=${this.loading || this.offset === 0}
+        >
+          <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+        </a>
+        <a
+          id="nextArrow"
+          href=${this.computeNavLink(
+            this.offset,
+            1,
+            this.itemsPerPage,
+            this.filter,
+            this.path
+          )}
+          ?hidden=${this.hideNextArrow(this.loading, this.items)}
+        >
+          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+        </a>
+      </nav>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    // We have to do this for the tests.
+    if (changedProperties.has('filter')) {
+      this.filterChanged(
+        this.filter,
+        changedProperties.get('filter') as string
+      );
+    }
+  }
+
+  private filterChanged(newFilter?: string, oldFilter?: string) {
     // newFilter can be empty string and then !newFilter === true
     if (!newFilter && !oldFilter) {
       return;
     }
-
-    this._debounceReload(newFilter);
+    this.debounceReload(newFilter);
   }
 
-  _debounceReload(filter?: string) {
+  // private but used in test
+  debounceReload(filter?: string) {
     this.reloadTask = debounce(
       this.reloadTask,
       () => {
-        if (this.path) {
-          if (filter) {
-            return page.show(
-              `${this.path}/q/filter:` + encodeURL(filter, false)
-            );
-          }
-          return page.show(this.path);
+        if (!this.isConnected || !this.path) return;
+        if (filter) {
+          page.show(`${this.path}/q/filter:${encodeURL(filter, false)}`);
+          return;
         }
+        page.show(this.path);
       },
       REQUEST_DEBOUNCE_INTERVAL_MS
     );
   }
 
-  _createNewItem() {
+  private createNewItem() {
     fireEvent(this, 'create-clicked');
   }
 
-  _computeNavLink(
+  // private but used in test
+  computeNavLink(
     offset: number,
     direction: number,
     itemsPerPage: number,
@@ -118,15 +226,8 @@
     return href;
   }
 
-  _computeCreateClass(createNew?: boolean) {
-    return createNew ? 'show' : '';
-  }
-
-  _hidePrevArrow(loading?: boolean, offset?: number) {
-    return loading || offset === 0;
-  }
-
-  _hideNextArrow(loading?: boolean, items?: unknown[]) {
+  // private but used in test
+  hideNextArrow(loading?: boolean, items?: unknown[]) {
     if (loading || !items || !items.length) {
       return true;
     }
@@ -137,7 +238,12 @@
   // TODO: fix offset (including itemsPerPage)
   // to either support a decimal or make it go to the nearest
   // whole number (e.g 3).
-  _computePage(offset: number, itemsPerPage: number) {
+  // private but used in test
+  computePage(offset: number, itemsPerPage: number) {
     return offset / itemsPerPage + 1;
   }
+
+  private handleFilterBindValueChanged(e: BindValueChangeEvent) {
+    this.filter = e.detail.value;
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.ts
deleted file mode 100644
index 75ee667..0000000
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.ts
+++ /dev/null
@@ -1,99 +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">
-    #filter {
-      max-width: 25em;
-    }
-    #filter:focus {
-      outline: none;
-    }
-    #topContainer {
-      align-items: center;
-      display: flex;
-      height: 3rem;
-      justify-content: space-between;
-      margin: 0 var(--spacing-l);
-    }
-    #createNewContainer:not(.show) {
-      display: none;
-    }
-    a {
-      color: var(--primary-text-color);
-      text-decoration: none;
-    }
-    a:hover {
-      text-decoration: underline;
-    }
-    nav {
-      align-items: center;
-      display: flex;
-      height: 3rem;
-      justify-content: flex-end;
-      margin-right: 20px;
-    }
-    nav,
-    iron-icon {
-      color: var(--deemphasized-text-color);
-    }
-    iron-icon {
-      height: 1.85rem;
-      margin-left: 16px;
-      width: 1.85rem;
-    }
-  </style>
-  <div id="topContainer">
-    <div class="filterContainer">
-      <label>Filter:</label>
-      <iron-input type="text" bind-value="{{filter}}">
-        <input
-          is="iron-input"
-          type="text"
-          id="filter"
-          bind-value="{{filter}}"
-        />
-      </iron-input>
-    </div>
-    <div id="createNewContainer" class$="[[_computeCreateClass(createNew)]]">
-      <gr-button primary="" link="" id="createNew" on-click="_createNewItem">
-        Create New
-      </gr-button>
-    </div>
-  </div>
-  <slot></slot>
-  <nav>
-    Page [[_computePage(offset, itemsPerPage)]]
-    <a
-      id="prevArrow"
-      href$="[[_computeNavLink(offset, -1, itemsPerPage, filter, path)]]"
-      hidden$="[[_hidePrevArrow(loading, offset)]]"
-      hidden=""
-    >
-      <iron-icon icon="gr-icons:chevron-left"></iron-icon>
-    </a>
-    <a
-      id="nextArrow"
-      href$="[[_computeNavLink(offset, 1, itemsPerPage, filter, path)]]"
-      hidden$="[[_hideNextArrow(loading, items)]]"
-      hidden=""
-    >
-      <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-    </a>
-  </nav>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
deleted file mode 100644
index 066c53e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
+++ /dev/null
@@ -1,147 +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-list-view.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-list-view');
-
-suite('gr-list-view tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_computeNavLink', () => {
-    const offset = 25;
-    const projectsPerPage = 25;
-    let filter = 'test';
-    const path = '/admin/projects';
-
-    stubBaseUrl('');
-
-    assert.equal(
-        element._computeNavLink(offset, 1, projectsPerPage, filter, path),
-        '/admin/projects/q/filter:test,50');
-
-    assert.equal(
-        element._computeNavLink(offset, -1, projectsPerPage, filter, path),
-        '/admin/projects/q/filter:test');
-
-    assert.equal(
-        element._computeNavLink(offset, 1, projectsPerPage, null, path),
-        '/admin/projects,50');
-
-    assert.equal(
-        element._computeNavLink(offset, -1, projectsPerPage, null, path),
-        '/admin/projects');
-
-    filter = 'plugins/';
-    assert.equal(
-        element._computeNavLink(offset, 1, projectsPerPage, filter, path),
-        '/admin/projects/q/filter:plugins%252F,50');
-  });
-
-  test('_onValueChange', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    element.path = '/admin/projects';
-    sinon.stub(page, 'show').callsFake(resolve);
-
-    element.filter = 'test';
-
-    const url = await promise;
-    assert.equal(url, '/admin/projects/q/filter:test');
-  });
-
-  test('_filterChanged not reload when swap between falsy values', () => {
-    sinon.stub(element, '_debounceReload');
-    element.filter = null;
-    element.filter = undefined;
-    element.filter = '';
-    assert.isFalse(element._debounceReload.called);
-  });
-
-  test('next button', () => {
-    element.itemsPerPage = 25;
-    let projects = new Array(26);
-    flush();
-
-    let loading;
-    assert.isFalse(element._hideNextArrow(loading, projects));
-    loading = true;
-    assert.isTrue(element._hideNextArrow(loading, projects));
-    loading = false;
-    assert.isFalse(element._hideNextArrow(loading, projects));
-    element._projects = [];
-    assert.isTrue(element._hideNextArrow(loading, element._projects));
-    projects = new Array(4);
-    assert.isTrue(element._hideNextArrow(loading, projects));
-  });
-
-  test('prev button', () => {
-    assert.isTrue(element._hidePrevArrow(true, 0));
-    flush(() => {
-      let offset = 0;
-      assert.isTrue(element._hidePrevArrow(false, offset));
-      offset = 5;
-      assert.isFalse(element._hidePrevArrow(false, offset));
-    });
-  });
-
-  test('createNew link appears correctly', () => {
-    assert.isFalse(element.shadowRoot
-        .querySelector('#createNewContainer').classList
-        .contains('show'));
-    element.createNew = true;
-    flush();
-    assert.isTrue(element.shadowRoot
-        .querySelector('#createNewContainer').classList
-        .contains('show'));
-  });
-
-  test('fires create clicked event when button tapped', () => {
-    const clickHandler = sinon.stub();
-    element.addEventListener('create-clicked', clickHandler);
-    element.createNew = true;
-    flush();
-    MockInteractions.tap(element.shadowRoot.querySelector('#createNew'));
-    assert.isTrue(clickHandler.called);
-  });
-
-  test('next/prev links change when path changes', () => {
-    const BRANCHES_PATH = '/path/to/branches';
-    const TAGS_PATH = '/path/to/tags';
-    sinon.stub(element, '_computeNavLink');
-    element.offset = 0;
-    element.itemsPerPage = 25;
-    element.filter = '';
-    element.path = BRANCHES_PATH;
-    assert.equal(element._computeNavLink.lastCall.args[4], BRANCHES_PATH);
-    element.path = TAGS_PATH;
-    assert.equal(element._computeNavLink.lastCall.args[4], TAGS_PATH);
-  });
-
-  test('_computePage', () => {
-    assert.equal(element._computePage(0, 25), 1);
-    assert.equal(element._computePage(50, 25), 3);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
new file mode 100644
index 0000000..29cbb1a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
@@ -0,0 +1,182 @@
+/**
+ * @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-list-view';
+import {GrListView} from './gr-list-view';
+import {page} from '../../../utils/page-wrapper-utils';
+import {queryAndAssert, stubBaseUrl} from '../../../test/test-utils';
+import {GrButton} from '../gr-button/gr-button';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-list-view');
+
+suite('gr-list-view tests', () => {
+  let element: GrListView;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await element.updateComplete;
+  });
+
+  test('computeNavLink', () => {
+    const offset = 25;
+    const projectsPerPage = 25;
+    let filter = 'test';
+    const path = '/admin/projects';
+
+    stubBaseUrl('');
+
+    assert.equal(
+      element.computeNavLink(offset, 1, projectsPerPage, filter, path),
+      '/admin/projects/q/filter:test,50'
+    );
+
+    assert.equal(
+      element.computeNavLink(offset, -1, projectsPerPage, filter, path),
+      '/admin/projects/q/filter:test'
+    );
+
+    assert.equal(
+      element.computeNavLink(offset, 1, projectsPerPage, undefined, path),
+      '/admin/projects,50'
+    );
+
+    assert.equal(
+      element.computeNavLink(offset, -1, projectsPerPage, undefined, path),
+      '/admin/projects'
+    );
+
+    filter = 'plugins/';
+    assert.equal(
+      element.computeNavLink(offset, 1, projectsPerPage, filter, path),
+      '/admin/projects/q/filter:plugins%252F,50'
+    );
+  });
+
+  test('_onValueChange', async () => {
+    let resolve: (url: string) => void;
+    const promise = new Promise(r => (resolve = r));
+    element.path = '/admin/projects';
+    sinon.stub(page, 'show').callsFake(r => resolve(r));
+
+    element.filter = 'test';
+    await element.updateComplete;
+
+    const url = await promise;
+    assert.equal(url, '/admin/projects/q/filter:test');
+  });
+
+  test('_filterChanged not reload when swap between falsy values', () => {
+    const debounceReloadStub = sinon.stub(element, 'debounceReload');
+    element.filter = undefined;
+    element.filter = '';
+    assert.isFalse(debounceReloadStub.called);
+  });
+
+  test('next button', async () => {
+    element.itemsPerPage = 25;
+    let projects = new Array(26);
+    await element.updateComplete;
+
+    let loading;
+    assert.isFalse(element.hideNextArrow(loading, projects));
+    loading = true;
+    assert.isTrue(element.hideNextArrow(loading, projects));
+    loading = false;
+    assert.isFalse(element.hideNextArrow(loading, projects));
+    projects = [];
+    assert.isTrue(element.hideNextArrow(loading, projects));
+    projects = new Array(4);
+    assert.isTrue(element.hideNextArrow(loading, projects));
+  });
+
+  test('prev button', async () => {
+    element.loading = true;
+    element.offset = 0;
+    await element.updateComplete;
+    assert.isTrue(
+      queryAndAssert<HTMLAnchorElement>(element, '#prevArrow').hasAttribute(
+        'hidden'
+      )
+    );
+
+    element.loading = false;
+    element.offset = 0;
+    await element.updateComplete;
+    assert.isTrue(
+      queryAndAssert<HTMLAnchorElement>(element, '#prevArrow').hasAttribute(
+        'hidden'
+      )
+    );
+
+    element.loading = false;
+    element.offset = 5;
+    await element.updateComplete;
+    assert.isFalse(
+      queryAndAssert<HTMLAnchorElement>(element, '#prevArrow').hasAttribute(
+        'hidden'
+      )
+    );
+  });
+
+  test('createNew link appears correctly', async () => {
+    assert.isFalse(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#createNewContainer'
+      ).classList.contains('show')
+    );
+    element.createNew = true;
+    await element.updateComplete;
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#createNewContainer'
+      ).classList.contains('show')
+    );
+  });
+
+  test('fires create clicked event when button tapped', async () => {
+    const clickHandler = sinon.stub();
+    element.addEventListener('create-clicked', clickHandler);
+    element.createNew = true;
+    await element.updateComplete;
+    MockInteractions.tap(queryAndAssert<GrButton>(element, '#createNew'));
+    assert.isTrue(clickHandler.called);
+  });
+
+  test('next/prev links change when path changes', async () => {
+    const BRANCHES_PATH = '/path/to/branches';
+    const TAGS_PATH = '/path/to/tags';
+    const computeNavLinkStub = sinon.stub(element, 'computeNavLink');
+    element.offset = 0;
+    element.itemsPerPage = 25;
+    element.filter = '';
+    element.path = BRANCHES_PATH;
+    await element.updateComplete;
+    assert.equal(computeNavLinkStub.lastCall.args[4], BRANCHES_PATH);
+    element.path = TAGS_PATH;
+    await element.updateComplete;
+    assert.equal(computeNavLinkStub.lastCall.args[4], TAGS_PATH);
+  });
+
+  test('computePage', () => {
+    assert.equal(element.computePage(0, 25), 1);
+    assert.equal(element.computePage(50, 25), 3);
+  });
+});
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 3d6b5ed..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 {
@@ -61,9 +64,11 @@
    * @event fullscreen-overlay-opened
    */
 
-  private fullScreenOpen = false;
+  // private but used in test
+  fullScreenOpen = false;
 
-  private _boundHandleClose: () => void = () => super.close();
+  // private but used in test
+  _boundHandleClose: () => void = () => super.close();
 
   private focusableNodes?: Node[];
 
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
similarity index 75%
rename from polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
rename to polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
index 72c3399..1a82a8e 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
@@ -15,21 +15,22 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-overlay.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import '../../../test/common-test-setup-karma';
+import './gr-overlay';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrOverlay} from './gr-overlay';
 
 const basicFixture = fixtureFromTemplate(html`
-<gr-overlay>
-      <div>content</div>
-    </gr-overlay>
+  <gr-overlay>
+    <div>content</div>
+  </gr-overlay>
 `);
 
 suite('gr-overlay tests', () => {
-  let element;
+  let element: GrOverlay;
 
   setup(() => {
-    element = basicFixture.instantiate();
+    element = basicFixture.instantiate() as GrOverlay;
   });
 
   test('popstate listener is attached on open and removed on close', () => {
@@ -38,17 +39,21 @@
     element.open();
     assert.isTrue(addEventListenerStub.called);
     assert.equal(addEventListenerStub.lastCall.args[0], 'popstate');
-    assert.equal(addEventListenerStub.lastCall.args[1],
-        element._boundHandleClose);
+    assert.equal(
+      addEventListenerStub.lastCall.args[1],
+      element._boundHandleClose
+    );
     element._overlayClosed();
     assert.isTrue(removeEventListenerStub.called);
     assert.equal(removeEventListenerStub.lastCall.args[0], 'popstate');
-    assert.equal(removeEventListenerStub.lastCall.args[1],
-        element._boundHandleClose);
+    assert.equal(
+      removeEventListenerStub.lastCall.args[1],
+      element._boundHandleClose
+    );
   });
 
   test('events are fired on fullscreen view', async () => {
-    sinon.stub(element, '_isMobile').returns(true);
+    const isMobileStub = sinon.stub(element, '_isMobile').returns(true as any);
     const openHandler = sinon.stub();
     const closeHandler = sinon.stub();
     element.addEventListener('fullscreen-overlay-opened', openHandler);
@@ -56,7 +61,7 @@
 
     await element.open();
 
-    assert.isTrue(element._isMobile.called);
+    assert.isTrue(isMobileStub.called);
     assert.isTrue(element.fullScreenOpen);
     assert.isTrue(openHandler.called);
 
@@ -66,7 +71,7 @@
   });
 
   test('events are not fired on desktop view', async () => {
-    sinon.stub(element, '_isMobile').returns(false);
+    const isMobileStub = sinon.stub(element, '_isMobile').returns(false as any);
     const openHandler = sinon.stub();
     const closeHandler = sinon.stub();
     element.addEventListener('fullscreen-overlay-opened', openHandler);
@@ -74,7 +79,7 @@
 
     await element.open();
 
-    assert.isTrue(element._isMobile.called);
+    assert.isTrue(isMobileStub.called);
     assert.isFalse(element.fullScreenOpen);
     assert.isFalse(openHandler.called);
 
@@ -83,4 +88,3 @@
     assert.isFalse(closeHandler.called);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
index 423a1a8..211f6dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
@@ -14,22 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-page-nav_html';
-import {customElement, property} from '@polymer/decorators';
 
-/**
- * Augment the interface on top of PolymerElement
- * for gr-page-nav.
- */
-export interface GrPageNav {
-  $: {
-    // Note: this is needed to access $.nav
-    // with dotted property access
-    nav: HTMLElement;
-  };
-}
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, query, state} from 'lit/decorators';
+import {assertIsDefined} from '../../../utils/common-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -38,19 +27,17 @@
 }
 
 @customElement('gr-page-nav')
-export class GrPageNav extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrPageNav extends LitElement {
+  @query('nav') private nav?: HTMLElement;
 
-  @property({type: Number})
-  _headerHeight?: number;
+  // private but used in test
+  @state() headerHeight?: number;
 
   private readonly bodyScrollHandler: () => void;
 
   constructor() {
     super();
-    this.bodyScrollHandler = () => this._handleBodyScroll();
+    this.bodyScrollHandler = () => this.handleBodyScroll();
   }
 
   override connectedCallback() {
@@ -63,40 +50,77 @@
     super.disconnectedCallback();
   }
 
-  _handleBodyScroll() {
-    if (this._headerHeight === undefined) {
-      let top = this._getOffsetTop(this);
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        nav {
+          background-color: var(--table-header-background-color);
+          border: 1px solid var(--border-color);
+          border-top: none;
+          height: 100%;
+          position: absolute;
+          top: 0;
+          width: 14em;
+        }
+        nav.pinned {
+          position: fixed;
+        }
+        @media only screen and (max-width: 53em) {
+          nav {
+            display: none;
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <nav aria-label="Sidebar">
+        <slot></slot>
+      </nav>
+    `;
+  }
+
+  // private but used in test
+  handleBodyScroll() {
+    assertIsDefined(this.nav, 'nav');
+    if (this.headerHeight === undefined) {
+      let top = this.getOffsetTop(this);
       // TODO(TS): Element doesn't have offsetParent,
       // while `offsetParent` are returning Element not HTMLElement
       for (
         let offsetParent = this.offsetParent as HTMLElement | undefined;
         offsetParent;
-        offsetParent = this._getOffsetParent(offsetParent)
+        offsetParent = this.getOffsetParent(offsetParent)
       ) {
-        top += this._getOffsetTop(offsetParent);
+        top += this.getOffsetTop(offsetParent);
       }
-      this._headerHeight = top;
+      this.headerHeight = top;
     }
 
-    this.$.nav.classList.toggle(
+    this.nav.classList.toggle(
       'pinned',
-      this._getScrollY() >= (this._headerHeight || 0)
+      this.getScrollY() >= (this.headerHeight || 0)
     );
   }
 
   /* Functions used for test purposes */
-  _getOffsetParent(element?: HTMLElement) {
+  private getOffsetParent(element?: HTMLElement) {
     if (!element || !('offsetParent' in element)) {
       return undefined;
     }
     return element.offsetParent as HTMLElement;
   }
 
-  _getOffsetTop(element: HTMLElement) {
+  // private but used in test
+  getOffsetTop(element: HTMLElement) {
     return element.offsetTop;
   }
 
-  _getScrollY() {
+  // private but used in test
+  getScrollY() {
     return window.scrollY;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts
deleted file mode 100644
index a9d9216..0000000
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts
+++ /dev/null
@@ -1,42 +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">
-    #nav {
-      background-color: var(--table-header-background-color);
-      border: 1px solid var(--border-color);
-      border-top: none;
-      height: 100%;
-      position: absolute;
-      top: 0;
-      width: 14em;
-    }
-    #nav.pinned {
-      position: fixed;
-    }
-    @media only screen and (max-width: 53em) {
-      #nav {
-        display: none;
-      }
-    }
-  </style>
-  <nav id="nav" aria-label="Sidebar">
-    <slot></slot>
-  </nav>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js
deleted file mode 100644
index 2960a1f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js
+++ /dev/null
@@ -1,71 +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-page-nav.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-page-nav>
-      <ul>
-        <li>item</li>
-      </ul>
-    </gr-page-nav>
-`);
-
-suite('gr-page-nav tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    flush();
-  });
-
-  test('header is not pinned just below top', () => {
-    sinon.stub(element, '_getOffsetParent').callsFake(() => 0);
-    sinon.stub(element, '_getOffsetTop').callsFake(() => 10);
-    sinon.stub(element, '_getScrollY').callsFake(() => 5);
-    element._handleBodyScroll();
-    assert.isFalse(element.$.nav.classList.contains('pinned'));
-  });
-
-  test('header is pinned when scroll down the page', () => {
-    sinon.stub(element, '_getOffsetParent').callsFake(() => 0);
-    sinon.stub(element, '_getOffsetTop').callsFake(() => 10);
-    sinon.stub(element, '_getScrollY').callsFake(() => 25);
-    window.scrollY = 100;
-    element._handleBodyScroll();
-    assert.isTrue(element.$.nav.classList.contains('pinned'));
-  });
-
-  test('header is not pinned just below top with header set', () => {
-    element._headerHeight = 20;
-    sinon.stub(element, '_getScrollY').callsFake(() => 15);
-    window.scrollY = 100;
-    element._handleBodyScroll();
-    assert.isFalse(element.$.nav.classList.contains('pinned'));
-  });
-
-  test('header is pinned when scroll down the page with header set', () => {
-    element._headerHeight = 20;
-    sinon.stub(element, '_getScrollY').callsFake(() => 25);
-    window.scrollY = 100;
-    element._handleBodyScroll();
-    assert.isTrue(element.$.nav.classList.contains('pinned'));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts
new file mode 100644
index 0000000..f62dae2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts
@@ -0,0 +1,65 @@
+/**
+ * @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-page-nav';
+import {GrPageNav} from './gr-page-nav';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {queryAndAssert} from '../../../test/test-utils';
+
+suite('gr-page-nav tests', () => {
+  let element: GrPageNav;
+
+  setup(async () => {
+    element = await fixture<GrPageNav>(html`
+      <gr-page-nav>
+        <ul>
+          <li>item</li>
+        </ul>
+      </gr-page-nav>
+    `);
+    await element.updateComplete;
+  });
+
+  test('header is not pinned just below top', () => {
+    sinon.stub(element, 'getOffsetTop').callsFake(() => 10);
+    sinon.stub(element, 'getScrollY').callsFake(() => 5);
+    element.handleBodyScroll();
+    assert.isFalse(queryAndAssert(element, 'nav').classList.contains('pinned'));
+  });
+
+  test('header is pinned when scroll down the page', () => {
+    sinon.stub(element, 'getOffsetTop').callsFake(() => 10);
+    sinon.stub(element, 'getScrollY').callsFake(() => 25);
+    element.handleBodyScroll();
+    assert.isTrue(queryAndAssert(element, 'nav').classList.contains('pinned'));
+  });
+
+  test('header is not pinned just below top with header set', () => {
+    element.headerHeight = 20;
+    sinon.stub(element, 'getScrollY').callsFake(() => 15);
+    element.handleBodyScroll();
+    assert.isFalse(queryAndAssert(element, 'nav').classList.contains('pinned'));
+  });
+
+  test('header is pinned when scroll down the page with header set', () => {
+    element.headerHeight = 20;
+    sinon.stub(element, 'getScrollY').callsFake(() => 25);
+    element.handleBodyScroll();
+    assert.isTrue(queryAndAssert(element, 'nav').classList.contains('pinned'));
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
index bc60520..90ce8bb 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
@@ -15,13 +15,9 @@
  * limitations under the License.
  */
 import '@polymer/iron-icon/iron-icon';
-import '../../../styles/shared-styles';
 import '../gr-icons/gr-icons';
 import '../gr-labeled-autocomplete/gr-labeled-autocomplete';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo-branch-picker_html';
 import {singleDecodeURL} from '../../../utils/url-util';
-import {customElement, property} from '@polymer/decorators';
 import {AutocompleteQuery} from '../gr-autocomplete/gr-autocomplete';
 import {
   BranchName,
@@ -30,59 +26,107 @@
   BranchInfo,
 } from '../../../types/common';
 import {GrLabeledAutocomplete} from '../gr-labeled-autocomplete/gr-labeled-autocomplete';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state, query} from 'lit/decorators';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
+import {BindValueChangeEvent} from '../../../types/events';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
 
-export interface GrRepoBranchPicker {
-  $: {
-    repoInput: GrLabeledAutocomplete;
-    branchInput: GrLabeledAutocomplete;
-  };
-}
 @customElement('gr-repo-branch-picker')
-export class GrRepoBranchPicker extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrRepoBranchPicker extends LitElement {
+  @query('#repoInput') protected repoInput?: GrLabeledAutocomplete;
 
-  @property({type: String, notify: true, observer: '_repoChanged'})
+  @query('#branchInput') protected branchInput?: GrLabeledAutocomplete;
+
+  @property({type: String})
   repo?: RepoName;
 
-  @property({type: String, notify: true})
+  @property({type: String})
   branch?: BranchName;
 
-  @property({type: Boolean})
-  _branchDisabled = false;
+  @state() private branchDisabled = false;
 
-  @property({type: Object})
-  _query: AutocompleteQuery = () => Promise.resolve([]);
+  private readonly query: AutocompleteQuery = () => Promise.resolve([]);
 
-  @property({type: Object})
-  _repoQuery: AutocompleteQuery = () => Promise.resolve([]);
+  private readonly repoQuery: AutocompleteQuery = () => Promise.resolve([]);
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this._query = input => this._getRepoBranchesSuggestions(input);
-    this._repoQuery = input => this._getRepoSuggestions(input);
+    this.query = input => this.getRepoBranchesSuggestions(input);
+    this.repoQuery = input => this.getRepoSuggestions(input);
   }
 
   override connectedCallback() {
     super.connectedCallback();
     if (this.repo) {
-      this.$.repoInput.setText(this.repo);
+      assertIsDefined(this.repoInput, 'repoInput');
+      this.repoInput.setText(this.repo);
+    }
+    this.branchDisabled = !this.repo;
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        gr-labeled-autocomplete,
+        iron-icon {
+          display: inline-block;
+        }
+        iron-icon {
+          margin-bottom: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div>
+        <gr-labeled-autocomplete
+          id="repoInput"
+          label="Repository"
+          placeholder="Select repo"
+          .query=${this.repoQuery}
+          @commit=${(e: CustomEvent<{value: string}>) => {
+            this.repoCommitted(e);
+          }}
+        >
+        </gr-labeled-autocomplete>
+        <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+        <gr-labeled-autocomplete
+          id="branchInput"
+          label="Branch"
+          placeholder="Select branch"
+          ?disabled=${this.branchDisabled}
+          .query=${this.query}
+          @commit=${(e: CustomEvent<{value: string}>) => {
+            this.branchCommitted(e);
+          }}
+        >
+        </gr-labeled-autocomplete>
+      </div>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('repo')) {
+      this.repoChanged();
     }
   }
 
-  override ready() {
-    super.ready();
-    this._branchDisabled = !this.repo;
-  }
-
-  _getRepoBranchesSuggestions(input: string) {
+  // private but used in test
+  getRepoBranchesSuggestions(input: string) {
     if (!this.repo) {
       return Promise.resolve([]);
     }
@@ -91,16 +135,32 @@
     }
     return this.restApiService
       .getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
-      .then(res => this._branchResponseToSuggestions(res));
+      .then(res => this.branchResponseToSuggestions(res));
   }
 
-  _getRepoSuggestions(input: string) {
+  private branchResponseToSuggestions(res: BranchInfo[] | undefined) {
+    if (!res) return [];
+    return res
+      .filter(branchInfo => branchInfo.ref !== 'HEAD')
+      .map(branchInfo => {
+        let branch;
+        if (branchInfo.ref.startsWith(REF_PREFIX)) {
+          branch = branchInfo.ref.substring(REF_PREFIX.length);
+        } else {
+          branch = branchInfo.ref;
+        }
+        return {name: branch, value: branch};
+      });
+  }
+
+  // private but used in test
+  getRepoSuggestions(input: string) {
     return this.restApiService
       .getRepos(input, SUGGESTIONS_LIMIT)
-      .then(res => this._repoResponseToSuggestions(res));
+      .then(res => this.repoResponseToSuggestions(res));
   }
 
-  _repoResponseToSuggestions(res: ProjectInfoWithName[] | undefined) {
+  private repoResponseToSuggestions(res: ProjectInfoWithName[] | undefined) {
     if (!res) return [];
     return res.map(repo => {
       return {
@@ -110,34 +170,28 @@
     });
   }
 
-  _branchResponseToSuggestions(res: BranchInfo[] | undefined) {
-    if (!res) return [];
-    return res.map(branchInfo => {
-      let branch;
-      if (branchInfo.ref.startsWith(REF_PREFIX)) {
-        branch = branchInfo.ref.substring(REF_PREFIX.length);
-      } else {
-        branch = branchInfo.ref;
-      }
-      return {name: branch, value: branch};
-    });
-  }
-
-  _repoCommitted(e: CustomEvent<{value: string}>) {
+  private repoCommitted(e: CustomEvent<{value: string}>) {
     this.repo = e.detail.value as RepoName;
+    fire(this, 'repo-changed', {value: e.detail.value});
   }
 
-  _branchCommitted(e: CustomEvent<{value: string}>) {
+  private branchCommitted(e: CustomEvent<{value: string}>) {
     this.branch = e.detail.value as BranchName;
+    fire(this, 'branch-changed', {value: e.detail.value});
   }
 
-  _repoChanged() {
-    this.$.branchInput.clear();
-    this._branchDisabled = !this.repo;
+  private repoChanged() {
+    assertIsDefined(this.branchInput, 'branchInput');
+    this.branchInput.clear();
+    this.branchDisabled = !this.repo;
   }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'branch-changed': BindValueChangeEvent;
+    'repo-changed': BindValueChangeEvent;
+  }
   interface HTMLElementTagNameMap {
     'gr-repo-branch-picker': GrRepoBranchPicker;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts
deleted file mode 100644
index 3e551b6..0000000
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts
+++ /dev/null
@@ -1,52 +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;
-    }
-    gr-labeled-autocomplete,
-    iron-icon {
-      display: inline-block;
-    }
-    iron-icon {
-      margin-bottom: var(--spacing-l);
-    }
-  </style>
-  <div>
-    <gr-labeled-autocomplete
-      id="repoInput"
-      label="Repository"
-      placeholder="Select repo"
-      on-commit="_repoCommitted"
-      query="[[_repoQuery]]"
-    >
-    </gr-labeled-autocomplete>
-    <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-    <gr-labeled-autocomplete
-      id="branchInput"
-      label="Branch"
-      placeholder="Select branch"
-      disabled="[[_branchDisabled]]"
-      on-commit="_branchCommitted"
-      query="[[_query]]"
-    >
-    </gr-labeled-autocomplete>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
index 31efa73..4c53a03 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
@@ -26,11 +26,12 @@
 suite('gr-repo-branch-picker tests', () => {
   let element: GrRepoBranchPicker;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
-  suite('_getRepoSuggestions', () => {
+  suite('getRepoSuggestions', () => {
     let getReposStub: sinon.SinonStub;
     setup(() => {
       getReposStub = stubRestApi('getRepos').returns(
@@ -57,7 +58,7 @@
 
     test('converts to suggestion objects', async () => {
       const input = 'plugins/avatars';
-      const suggestions = await element._getRepoSuggestions(input);
+      const suggestions = await element.getRepoSuggestions(input);
       assert.isTrue(getReposStub.calledWith(input));
       const unencodedNames = [
         'plugins/avatars-external',
@@ -76,13 +77,14 @@
     });
   });
 
-  suite('_getRepoBranchesSuggestions', () => {
+  suite('getRepoBranchesSuggestions', () => {
     let getRepoBranchesStub: sinon.SinonStub;
     setup(() => {
       getRepoBranchesStub = stubRestApi('getRepoBranches').returns(
         Promise.resolve([
-          {ref: 'refs/heads/stable-2.10' as GitRef, revision: '123'},
-          {ref: 'refs/heads/stable-2.11' as GitRef, revision: '1234'},
+          {ref: 'HEAD' as GitRef, revision: 'main'},
+          {ref: 'refs/heads/stable-2.10' as GitRef, revision: '123af'},
+          {ref: 'refs/heads/stable-2.11' as GitRef, revision: '1234b'},
           {ref: 'refs/heads/stable-2.12' as GitRef, revision: '12345'},
           {ref: 'refs/heads/stable-2.13' as GitRef, revision: '123456'},
           {ref: 'refs/heads/stable-2.14' as GitRef, revision: '1234567'},
@@ -95,9 +97,7 @@
       const repo = 'gerrit';
       const branchInput = 'stable-2.1';
       element.repo = repo as RepoName;
-      const suggestions = await element._getRepoBranchesSuggestions(
-        branchInput
-      );
+      const suggestions = await element.getRepoBranchesSuggestions(branchInput);
       assert.isTrue(getRepoBranchesStub.calledWith(branchInput, repo, 15));
       const refNames = [
         'stable-2.10',
@@ -121,16 +121,16 @@
       const repo = 'gerrit' as RepoName;
       const branchInput = 'refs/heads/stable-2.1';
       element.repo = repo;
-      return element._getRepoBranchesSuggestions(branchInput).then(() => {
+      return element.getRepoBranchesSuggestions(branchInput).then(() => {
         assert.isTrue(getRepoBranchesStub.calledWith('stable-2.1', repo, 15));
       });
     });
 
     test('does not query when repo is unset', async () => {
-      await element._getRepoBranchesSuggestions('');
+      await element.getRepoBranchesSuggestions('');
       assert.isFalse(getRepoBranchesStub.called);
       element.repo = 'gerrit' as RepoName;
-      await element._getRepoBranchesSuggestions('');
+      await element.getRepoBranchesSuggestions('');
       assert.isTrue(getRepoBranchesStub.called);
     });
   });
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 8db6606..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
@@ -31,6 +31,8 @@
 import {fireNetworkError, fireServerError} from '../../../../utils/event-util';
 import {FetchRequest} from '../../../../types/types';
 import {ErrorCallback} from '../../../../api/rest';
+import {Scheduler, Task} from '../../../../services/scheduler/scheduler';
+import {RetryError} from '../../../../services/scheduler/retry-scheduler';
 
 export const JSON_PREFIX = ")]}'";
 
@@ -236,20 +238,45 @@
   constructor(
     private readonly _cache: SiteBasedCache,
     private readonly _auth: AuthService,
-    private readonly _fetchPromisesCache: FetchPromisesCache
+    private readonly _fetchPromisesCache: FetchPromisesCache,
+    private readonly readScheduler: Scheduler<Response>,
+    private readonly writeScheduler: Scheduler<Response>
   ) {}
 
+  private schedule(method: string, task: Task<Response>) {
+    if (method === 'PUT' || method === 'POST' || method === 'DELETE') {
+      return this.writeScheduler.schedule(task);
+    } else {
+      return this.readScheduler.schedule(task);
+    }
+  }
+
   /**
    * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
    * with timing and logging.
 s   */
   fetch(req: FetchRequest): Promise<Response> {
+    const method =
+      req.fetchOptions && req.fetchOptions.method
+        ? req.fetchOptions.method
+        : 'GET';
     const start = Date.now();
-    const xhr = this._auth.fetch(req.url, req.fetchOptions);
+    const task = async () => {
+      const res = await this._auth.fetch(req.url, req.fetchOptions);
+      if (!res.ok && res.status === 429) throw new RetryError<Response>(res);
+      return res;
+    };
+
+    const xhr = this.schedule(method, task).catch((err: unknown) => {
+      if (err instanceof RetryError) {
+        return err.payload;
+      } else {
+        throw err;
+      }
+    });
 
     // Log the call after it completes.
     xhr.then(res => this._logCall(req, start, res ? res.status : null));
-
     // Return the XHR directly (without the log).
     return xhr;
   }
@@ -277,7 +304,7 @@
     const elapsed = endTime - startTime;
     const startAt = new Date(startTime);
     const endAt = new Date(endTime);
-    console.info(
+    console.debug(
       [
         'HTTP',
         status,
@@ -489,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.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
deleted file mode 100644
index 70bd369..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
+++ /dev/null
@@ -1,155 +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 {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './gr-rest-api-helper.js';
-import {appContext} from '../../../../services/app-context.js';
-import {stubAuth} from '../../../../test/test-utils.js';
-
-suite('gr-rest-api-helper tests', () => {
-  let helper;
-
-  let cache;
-  let fetchPromisesCache;
-  let originalCanonicalPath;
-  let authFetchStub;
-
-  setup(() => {
-    cache = new SiteBasedCache();
-    fetchPromisesCache = new FetchPromisesCache();
-
-    originalCanonicalPath = window.CANONICAL_PATH;
-    window.CANONICAL_PATH = 'testhelper';
-
-    const mockRestApiInterface = {
-      fire: sinon.stub(),
-    };
-
-    const testJSON = ')]}\'\n{"hello": "bonjour"}';
-    authFetchStub = stubAuth('fetch').returns(Promise.resolve({
-      ok: true,
-      text() {
-        return Promise.resolve(testJSON);
-      },
-    }));
-
-    helper = new GrRestApiHelper(cache, appContext.authService,
-        fetchPromisesCache, mockRestApiInterface);
-  });
-
-  teardown(() => {
-    window.CANONICAL_PATH = originalCanonicalPath;
-  });
-
-  suite('fetchJSON()', () => {
-    test('Sets header to accept application/json', () => {
-      helper.fetchJSON({url: '/dummy/url'});
-      assert.isTrue(authFetchStub.called);
-      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-          'application/json');
-    });
-
-    test('Use header option accept when provided', () => {
-      const headers = new Headers();
-      headers.append('Accept', '*/*');
-      const fetchOptions = {headers};
-      helper.fetchJSON({url: '/dummy/url', fetchOptions});
-      assert.isTrue(authFetchStub.called);
-      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-          '*/*');
-    });
-  });
-
-  test('JSON prefix is properly removed',
-      () => helper.fetchJSON({url: '/dummy/url'}).then(obj => {
-        assert.deepEqual(obj, {hello: 'bonjour'});
-      })
-  );
-
-  test('cached results', () => {
-    let n = 0;
-    sinon.stub(helper, 'fetchJSON').callsFake(() => Promise.resolve(++n));
-    const promises = [];
-    promises.push(helper.fetchCacheURL('/foo'));
-    promises.push(helper.fetchCacheURL('/foo'));
-    promises.push(helper.fetchCacheURL('/foo'));
-
-    return Promise.all(promises).then(results => {
-      assert.deepEqual(results, [1, 1, 1]);
-      return helper.fetchCacheURL('/foo').then(foo => {
-        assert.equal(foo, 1);
-      });
-    });
-  });
-
-  test('cached promise', () => {
-    const promise = Promise.reject(new Error('foo'));
-    cache.set('/foo', promise);
-    return helper.fetchCacheURL({url: '/foo'}).catch(p => {
-      assert.equal(p.message, 'foo');
-    });
-  });
-
-  test('cache invalidation', () => {
-    cache.set('/foo/bar', 1);
-    cache.set('/bar', 2);
-    fetchPromisesCache.set('/foo/bar', 3);
-    fetchPromisesCache.set('/bar', 4);
-    helper.invalidateFetchPromisesPrefix('/foo/');
-    assert.isFalse(cache.has('/foo/bar'));
-    assert.isTrue(cache.has('/bar'));
-    assert.isUndefined(fetchPromisesCache.get('/foo/bar'));
-    assert.strictEqual(4, fetchPromisesCache.get('/bar'));
-  });
-
-  test('params are properly encoded', () => {
-    let url = helper.urlWithParams('/path/', {
-      sp: 'hola',
-      gr: 'guten tag',
-      noval: null,
-    });
-    assert.equal(url,
-        window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
-
-    url = helper.urlWithParams('/path/', {
-      sp: 'hola',
-      en: ['hey', 'hi'],
-    });
-    assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
-
-    // Order must be maintained with array params.
-    url = helper.urlWithParams('/path/', {
-      l: ['c', 'b', 'a'],
-    });
-    assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
-  });
-
-  test('request callbacks can be canceled', () => {
-    let cancelCalled = false;
-    authFetchStub.returns(Promise.resolve({
-      body: {
-        cancel() { cancelCalled = true; },
-      },
-    }));
-    const cancelCondition = () => true;
-    return helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(obj => {
-      assert.isUndefined(obj);
-      assert.isTrue(cancelCalled);
-    });
-  });
-});
-
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
new file mode 100644
index 0000000..ef90764
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
@@ -0,0 +1,313 @@
+/**
+ * @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 {
+  SiteBasedCache,
+  FetchPromisesCache,
+  GrRestApiHelper,
+} from './gr-rest-api-helper';
+import {getAppContext} from '../../../../services/app-context';
+import {stubAuth} from '../../../../test/test-utils';
+import {FakeScheduler} from '../../../../services/scheduler/fake-scheduler';
+import {RetryScheduler} from '../../../../services/scheduler/retry-scheduler';
+import {ParsedJSON} from '../../../../types/common';
+import {HttpMethod} from '../../../../api/rest-api';
+import {SinonFakeTimers} from 'sinon';
+
+function makeParsedJSON<T>(val: T): ParsedJSON {
+  return val as unknown as ParsedJSON;
+}
+
+suite('gr-rest-api-helper tests', () => {
+  let clock: SinonFakeTimers;
+  let helper: GrRestApiHelper;
+
+  let cache: SiteBasedCache;
+  let fetchPromisesCache: FetchPromisesCache;
+  let originalCanonicalPath: string | undefined;
+  let authFetchStub: sinon.SinonStub;
+  let readScheduler: FakeScheduler<Response>;
+  let writeScheduler: FakeScheduler<Response>;
+
+  setup(() => {
+    clock = sinon.useFakeTimers();
+    cache = new SiteBasedCache();
+    fetchPromisesCache = new FetchPromisesCache();
+
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = 'testhelper';
+
+    const testJSON = ')]}\'\n{"hello": "bonjour"}';
+    authFetchStub = stubAuth('fetch').returns(
+      Promise.resolve({
+        ...new Response(),
+        ok: true,
+        text() {
+          return Promise.resolve(testJSON);
+        },
+      })
+    );
+
+    readScheduler = new FakeScheduler<Response>();
+    writeScheduler = new FakeScheduler<Response>();
+
+    helper = new GrRestApiHelper(
+      cache,
+      getAppContext().authService,
+      fetchPromisesCache,
+      readScheduler,
+      writeScheduler
+    );
+  });
+
+  teardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  async function assertReadRequest() {
+    assert.equal(readScheduler.scheduled.length, 1);
+    await readScheduler.resolve();
+    await flush();
+  }
+
+  async function assertWriteRequest() {
+    assert.equal(writeScheduler.scheduled.length, 1);
+    await writeScheduler.resolve();
+    await flush();
+  }
+
+  suite('send()', () => {
+    setup(() => {
+      authFetchStub.returns(
+        Promise.resolve({
+          ...new Response(),
+          ok: true,
+          text() {
+            return Promise.resolve('Yay');
+          },
+        })
+      );
+    });
+
+    test('GET are sent to readScheduler', async () => {
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+      });
+      assert.equal(writeScheduler.scheduled.length, 0);
+      await assertReadRequest();
+      const res: Response = await promise;
+      assert.equal(await res.text(), 'Yay');
+    });
+
+    test('PUT are sent to writeScheduler', async () => {
+      const promise = helper.send({
+        method: HttpMethod.PUT,
+        url: '/dummy/url',
+        parseResponse: false,
+      });
+      assert.equal(readScheduler.scheduled.length, 0);
+      await assertWriteRequest();
+      const res: Response = await promise;
+      assert.equal(await res.text(), 'Yay');
+    });
+  });
+
+  suite('fetchJSON()', () => {
+    test('Sets header to accept application/json', async () => {
+      helper.fetchJSON({url: '/dummy/url'});
+      assert.isFalse(authFetchStub.called);
+      await assertReadRequest();
+      assert.isTrue(authFetchStub.called);
+      assert.equal(
+        authFetchStub.lastCall.args[1].headers.get('Accept'),
+        'application/json'
+      );
+    });
+
+    test('Use header option accept when provided', async () => {
+      const headers = new Headers();
+      headers.append('Accept', '*/*');
+      const fetchOptions = {headers};
+      helper.fetchJSON({url: '/dummy/url', fetchOptions});
+      assert.isFalse(authFetchStub.called);
+      await assertReadRequest();
+      assert.isTrue(authFetchStub.called);
+      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'), '*/*');
+    });
+
+    test('JSON prefix is properly removed', async () => {
+      const promise = helper.fetchJSON({url: '/dummy/url'});
+      await assertReadRequest();
+      const obj = await promise;
+      assert.deepEqual(obj, makeParsedJSON({hello: 'bonjour'}));
+    });
+  });
+
+  test('cached results', () => {
+    let n = 0;
+    sinon
+      .stub(helper, 'fetchJSON')
+      .callsFake(() => Promise.resolve(makeParsedJSON(++n)));
+    const promises = [];
+    promises.push(helper.fetchCacheURL({url: '/foo'}));
+    promises.push(helper.fetchCacheURL({url: '/foo'}));
+    promises.push(helper.fetchCacheURL({url: '/foo'}));
+
+    return Promise.all(promises).then(results => {
+      assert.deepEqual(results, [
+        makeParsedJSON(1),
+        makeParsedJSON(1),
+        makeParsedJSON(1),
+      ]);
+      return helper.fetchCacheURL({url: '/foo'}).then(foo => {
+        assert.equal(foo, makeParsedJSON(1));
+      });
+    });
+  });
+
+  test('cache invalidation', async () => {
+    cache.set('/foo/bar', makeParsedJSON(1));
+    cache.set('/bar', makeParsedJSON(2));
+    fetchPromisesCache.set('/foo/bar', Promise.resolve(makeParsedJSON(3)));
+    fetchPromisesCache.set('/bar', Promise.resolve(makeParsedJSON(4)));
+    helper.invalidateFetchPromisesPrefix('/foo/');
+    assert.isFalse(cache.has('/foo/bar'));
+    assert.isTrue(cache.has('/bar'));
+    assert.isUndefined(fetchPromisesCache.get('/foo/bar'));
+    assert.strictEqual(makeParsedJSON(4), await fetchPromisesCache.get('/bar'));
+  });
+
+  test('params are properly encoded', () => {
+    let url = helper.urlWithParams('/path/', {
+      sp: 'hola',
+      gr: 'guten tag',
+      noval: null,
+    });
+    assert.equal(
+      url,
+      `${window.CANONICAL_PATH}/path/?sp=hola&gr=guten%20tag&noval`
+    );
+
+    url = helper.urlWithParams('/path/', {
+      sp: 'hola',
+      en: ['hey', 'hi'],
+    });
+    assert.equal(url, `${window.CANONICAL_PATH}/path/?sp=hola&en=hey&en=hi`);
+
+    // Order must be maintained with array params.
+    url = helper.urlWithParams('/path/', {
+      l: ['c', 'b', 'a'],
+    });
+    assert.equal(url, `${window.CANONICAL_PATH}/path/?l=c&l=b&l=a`);
+  });
+
+  test('request callbacks can be canceled', async () => {
+    let cancelCalled = false;
+    authFetchStub.returns(
+      Promise.resolve({
+        body: {
+          cancel() {
+            cancelCalled = true;
+          },
+        },
+      })
+    );
+    const cancelCondition = () => true;
+    const promise = helper.fetchJSON({url: '/dummy/url', cancelCondition});
+    await assertReadRequest();
+    const obj = await promise;
+    assert.isUndefined(obj);
+    assert.isTrue(cancelCalled);
+  });
+
+  suite('429 errors', () => {
+    setup(() => {
+      authFetchStub.returns(
+        Promise.resolve({
+          ...new Response(),
+          status: 429,
+          ok: false,
+        })
+      );
+    });
+
+    test('still call errFn when not retried', async () => {
+      const errFn = sinon.stub();
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+        errFn,
+      });
+      await assertReadRequest();
+
+      // But we expect the result from the network to return a 429 error when
+      // it's no longer being retried.
+      await promise;
+      assert.isTrue(errFn.called);
+    });
+
+    test('still pass through correctly when not retried', async () => {
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+      });
+      await assertReadRequest();
+
+      // 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;
+      assert.equal(res.status, 429);
+    });
+
+    test('are retried', async () => {
+      helper = new GrRestApiHelper(
+        cache,
+        getAppContext().authService,
+        fetchPromisesCache,
+        new RetryScheduler<Response>(readScheduler, 1, 50),
+        writeScheduler
+      );
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+      });
+      await assertReadRequest();
+      authFetchStub.returns(
+        Promise.resolve({
+          ...new Response(),
+          ok: true,
+          text() {
+            return Promise.resolve('Yay');
+          },
+        })
+      );
+      // Flush the retry scheduler
+      clock.tick(50);
+      await flush();
+      // We expect a retry.
+      await assertReadRequest();
+      const res: Response = await promise;
+      assert.equal(await res.text(), 'Yay');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
index a603ec6..da5d331 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
@@ -276,8 +276,8 @@
   }
 
   static parse(
-    change: ChangeViewChangeInfo | undefined | null
-  ): ParsedChangeInfo | undefined | null {
+    change: ChangeViewChangeInfo | undefined
+  ): ParsedChangeInfo | undefined {
     // TODO(TS): The !change condition should be removed when all files are converted to TS
     if (!change || !isChangeInfoParserInput(change)) {
       return change;
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
index 571272d..725e11a 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
@@ -34,7 +34,7 @@
   }
 
   @property({type: String, notify: true})
-  bindValue?: string | number;
+  bindValue?: string | number | boolean;
 
   get nativeSelect() {
     // gr-select is not a shadow component
@@ -52,7 +52,7 @@
       this.nativeSelect.value = String(this.bindValue);
       // Async needed for firefox to populate value. It was trying to do it
       // before options from a dom-repeat were rendered previously.
-      // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
+      // See https://issues.gerritcodereview.com/issues/40007948
       setTimeout(() => {
         this.nativeSelect.value = String(this.bindValue);
       }, 1);
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
index 245d7df..80b9f65 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
@@ -17,30 +17,22 @@
 
 import '../../../test/common-test-setup-karma';
 import './gr-select';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {fixture, html} from '@open-wc/testing-helpers';
 import {GrSelect} from './gr-select';
 
-const basicFixture = fixtureFromTemplate(html`
-  <gr-select>
-    <select>
-      <option value="1">One</option>
-      <option value="2">Two</option>
-      <option value="3">Three</option>
-    </select>
-  </gr-select>
-`);
-
-const noOptionsFixture = fixtureFromTemplate(html`
-  <gr-select>
-    <select></select>
-  </gr-select>
-`);
-
 suite('gr-select tests', () => {
   let element: GrSelect;
 
-  setup(() => {
-    element = basicFixture.instantiate() as GrSelect;
+  setup(async () => {
+    element = await fixture<GrSelect>(html`
+      <gr-select>
+        <select>
+          <option value="1">One</option>
+          <option value="2">Two</option>
+          <option value="3">Three</option>
+        </select>
+      </gr-select>
+    `);
   });
 
   test('bindValue must be set to the first option value', () => {
@@ -98,8 +90,12 @@
   suite('gr-select no options tests', () => {
     let element: GrSelect;
 
-    setup(() => {
-      element = noOptionsFixture.instantiate() as GrSelect;
+    setup(async () => {
+      element = await fixture<GrSelect>(html`
+        <gr-select>
+          <select></select>
+        </gr-select>
+      `);
     });
 
     test('bindValue must not be changed', () => {
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 7ed000b..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,14 +81,15 @@
     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>`;
   }
 
-  focusOnCopy() {
+  async focusOnCopy() {
+    await this.updateComplete;
     const copyClipboard = queryAndAssert<GrCopyClipboard>(
       this,
       'gr-copy-clipboard'
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
index a50b60b..1b1687b 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
@@ -33,12 +33,12 @@
     await flush();
   });
 
-  test('focusOnCopy', () => {
+  test('focusOnCopy', async () => {
     const focusStub = sinon.stub(
       queryAndAssert<GrCopyClipboard>(element, 'gr-copy-clipboard')!,
       'focusOnCopy'
     );
-    element.focusOnCopy();
+    await element.focusOnCopy();
     assert.isTrue(focusStub.called);
   });
 });
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 9e6b42a..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,12 +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 {appContext} from '../../../services/app-context';
-import {customElement, property} from '@polymer/decorators';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {getAppContext} from '../../../services/app-context';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {
   GrAutocompleteDropdown,
@@ -32,6 +27,13 @@
   ItemSelectedEvent,
 } from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {addShortcut, Key} from '../../../utils/dom-util';
+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,96 +65,64 @@
   match: string;
 }
 
-interface ValueChangeEvent {
-  value: string;
-}
-
-export interface GrTextarea {
-  $: {
-    textarea: IronAutogrowTextareaElement;
-    emojiSuggestions: GrAutocompleteDropdown;
-    caratSpan: HTMLSpanElement;
-    hiddenText: HTMLDivElement;
-  };
-}
-
 declare global {
   interface HTMLElementEventMap {
     'item-selected': CustomEvent<ItemSelectedEvent>;
-    'bind-value-changed': CustomEvent<ValueChangeEvent>;
   }
 }
 
 @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[] = [];
+  @state() suggestions: EmojiSuggestion[] = [];
 
-  @property({type: Number})
-  readonly _verticalOffset = 20;
-  // Offset makes dropdown appear below text.
-
-  reporting: ReportingService;
+  // Accessed in tests.
+  readonly reporting = getAppContext().reportingService;
 
   disableEnterKeyForSelectingEmoji = false;
 
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
 
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
   override disconnectedCallback() {
     super.disconnectedCallback();
     for (const cleanup of this.cleanups) cleanup();
@@ -161,42 +131,139 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.cleanups.push(
-      addShortcut(this, {key: Key.UP}, e => this._handleUpKey(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.DOWN}, e => this._handleDownKey(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.TAB}, e => this._handleTabKey(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this._handleEnterByKey(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, e => this._handleEscKey(e))
-    );
-  }
-
-  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() {
@@ -209,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)
     );
   }
 
@@ -296,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: CustomEvent<ValueChangeEvent>) {
+  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 (
@@ -337,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;
     }
 
@@ -347,85 +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 {
@@ -435,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 0585aec8..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
@@ -34,6 +34,11 @@
   @property({type: Boolean, attribute: 'has-tooltip', reflect: true})
   hasTooltip = false;
 
+  // A light tooltip will disappear immediately when the original hovered
+  // over content is no longer hovered over.
+  @property({type: Boolean, attribute: 'light-tooltip', reflect: true})
+  lightTooltip = false;
+
   @property({type: Boolean, attribute: 'position-below', reflect: true})
   positionBelow = false;
 
@@ -123,7 +128,7 @@
     this.addEventListener('mouseenter', this.showHandler);
   }
 
-  _handleShowTooltip() {
+  async _handleShowTooltip() {
     if (this.isTouchDevice) {
       return;
     }
@@ -145,22 +150,25 @@
     tooltip.text = this.originalTitle;
     tooltip.maxWidth = this.getAttribute('max-width') || '';
     tooltip.positionBelow = this.hasAttribute('position-below');
+    this.tooltip = tooltip;
 
     // Set visibility to hidden before appending to the DOM so that
     // calculations can be made based on the element’s size.
     tooltip.style.visibility = 'hidden';
     getRootElement().appendChild(tooltip);
+    await tooltip.updateComplete;
     this._positionTooltip(tooltip);
     tooltip.style.visibility = 'initial';
 
-    this.tooltip = tooltip;
     window.addEventListener('scroll', this.windowScrollHandler);
     this.addEventListener('mouseleave', this.hideHandler);
     this.addEventListener('click', this.hideHandler);
-    tooltip.addEventListener('mouseleave', this.hideHandler);
+    if (!this.lightTooltip) {
+      tooltip.addEventListener('mouseleave', this.hideHandler);
+    }
   }
 
-  _handleHideTooltip(e: Event | undefined) {
+  _handleHideTooltip(e?: Event) {
     if (this.isTouchDevice) {
       return;
     }
@@ -170,7 +178,8 @@
     // Do not hide if mouse left this or this.tooltip and came to this or
     // this.tooltip
     if (
-      (e as MouseEvent)?.relatedTarget === this.tooltip ||
+      (!this.lightTooltip &&
+        (e as MouseEvent)?.relatedTarget === this.tooltip) ||
       (e as MouseEvent)?.relatedTarget === this
     ) {
       return;
@@ -180,7 +189,9 @@
     this.removeEventListener('mouseleave', this.hideHandler);
     this.removeEventListener('click', this.hideHandler);
     this.setAttribute('title', this.originalTitle);
-    this.tooltip?.removeEventListener('mouseleave', this.hideHandler);
+    if (!this.lightTooltip) {
+      this.tooltip?.removeEventListener('mouseleave', this.hideHandler);
+    }
 
     if (this.tooltip?.parentNode) {
       this.tooltip.parentNode.removeChild(this.tooltip);
@@ -198,7 +209,7 @@
   }
 
   // private but used in tests.
-  async _positionTooltip(tooltip: GrTooltip | null) {
+  _positionTooltip(tooltip: GrTooltip | null) {
     if (tooltip === null) return;
     const rect = this.getBoundingClientRect();
     const boxRect = tooltip.getBoundingClientRect();
@@ -210,13 +221,9 @@
     const left = rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
     const right = parentRect.width - left - boxRect.width;
     if (left < 0) {
-      tooltip.updateStyles({
-        '--gr-tooltip-arrow-center-offset': `${left}px`,
-      });
+      tooltip.arrowCenterOffset = `${left}px`;
     } else if (right < 0) {
-      tooltip.updateStyles({
-        '--gr-tooltip-arrow-center-offset': `${-0.5 * right}px`,
-      });
+      tooltip.arrowCenterOffset = `${-0.5 * right}px`;
     }
     tooltip.style.left = `${Math.max(0, left)}px`;
 
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.js
deleted file mode 100644
index 8d3bbb0..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
+++ /dev/null
@@ -1,178 +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-tooltip-content.js';
-
-const basicFixture = fixtureFromElement('gr-tooltip-content');
-
-suite('gr-tooltip-content tests', () => {
-  let element;
-
-  function makeTooltip(tooltipRect, parentRect) {
-    return {
-      getBoundingClientRect() { return tooltipRect; },
-      updateStyles: sinon.stub(),
-      style: {left: 0, top: 0},
-      parentElement: {
-        getBoundingClientRect() { return parentRect; },
-      },
-    };
-  }
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    element.title = 'title';
-    await element.updateComplete;
-  });
-
-  test('icon is not visible by default', () => {
-    assert.isNotOk(element.shadowRoot.querySelector('iron-icon'));
-  });
-
-  test('icon is visible with showIcon property', async () => {
-    element.showIcon = true;
-    await element.updateComplete;
-    assert.isOk(element.shadowRoot.querySelector('iron-icon'));
-  });
-
-  test('position-below attribute is reflected', async () => {
-    assert.isFalse(element.hasAttribute('position-below'));
-    element.positionBelow = true;
-    await element.updateComplete;
-    assert.isTrue(element.hasAttribute('position-below'));
-  });
-
-  test('normal position', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 100, width: 200};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 50},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.isFalse(tooltip.updateStyles.called);
-    assert.equal(tooltip.style.left, '175px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('left side position', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 10, width: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '0px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('right side position', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 950, width: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '915px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('position to bottom', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 950, width: 50, height: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element.positionBelow = true;
-    element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '915px');
-    assert.equal(tooltip.style.top, '157.2px');
-  });
-
-  test('hides tooltip when detached', async () => {
-    sinon.stub(element, '_handleHideTooltip');
-    element.remove();
-    await element.updateComplete;
-    assert.isTrue(element._handleHideTooltip.called);
-  });
-
-  test('sets up listeners when has-tooltip is changed', async () => {
-    const addListenerStub = sinon.stub(element, 'addEventListener');
-    element.hasTooltip = true;
-    await element.updateComplete;
-    assert.isTrue(addListenerStub.called);
-  });
-
-  test('clean up listeners when has-tooltip changed to false', async () => {
-    const removeListenerStub = sinon.stub(element, 'removeEventListener');
-    element.hasTooltip = true;
-    await element.updateComplete;
-    element.hasTooltip = false;
-    await element.updateComplete;
-    assert.isTrue(removeListenerStub.called);
-  });
-
-  test('do not display tooltips on touch devices', async () => {
-    // On touch devices, tooltips should not be shown.
-    element.isTouchDevice = true;
-    await element.updateComplete;
-
-    // fire mouse-enter
-    element._handleShowTooltip();
-    await element.updateComplete;
-    assert.isNotOk(element.tooltip);
-
-    // fire mouse-enter
-    element._handleHideTooltip();
-    await element.updateComplete;
-    assert.isNotOk(element.tooltip);
-
-    // On other devices, tooltips should be shown.
-    element.isTouchDevice = false;
-
-    // fire mouse-enter
-    element._handleShowTooltip();
-    await element.updateComplete;
-    assert.isOk(element.tooltip);
-
-    // fire mouse-enter
-    element._handleHideTooltip();
-    await element.updateComplete;
-    assert.isNotOk(element.tooltip);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
new file mode 100644
index 0000000..de35c2d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
@@ -0,0 +1,183 @@
+/**
+ * @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-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: GrTooltipContent;
+
+  function makeTooltip(tooltipRect: DOMRect, parentRect: DOMRect) {
+    return {
+      arrowCenterOffset: '0',
+      getBoundingClientRect() {
+        return tooltipRect;
+      },
+      style: {left: '0', top: '0'},
+      parentElement: {
+        getBoundingClientRect() {
+          return parentRect;
+        },
+      },
+    };
+  }
+
+  setup(async () => {
+    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(query(element, 'iron-icon'));
+  });
+
+  test('icon is visible with showIcon property', async () => {
+    element.showIcon = true;
+    await element.updateComplete;
+    assert.isOk(query(element, 'iron-icon'));
+  });
+
+  test('position-below attribute is reflected', async () => {
+    assert.isFalse(element.hasAttribute('position-below'));
+    element.positionBelow = true;
+    await element.updateComplete;
+    assert.isTrue(element.hasAttribute('position-below'));
+  });
+
+  test('normal position', () => {
+    sinon
+      .stub(element, 'getBoundingClientRect')
+      .callsFake(() => ({top: 100, left: 100, width: 200} as DOMRect));
+    const tooltip = makeTooltip(
+      {height: 30, width: 50} as DOMRect,
+      {top: 0, left: 0, width: 1000} as DOMRect
+    ) as GrTooltip;
+
+    element._positionTooltip(tooltip);
+    assert.equal(tooltip.arrowCenterOffset, '0');
+    assert.equal(tooltip.style.left, '175px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('left side position', async () => {
+    sinon
+      .stub(element, 'getBoundingClientRect')
+      .callsFake(() => ({top: 100, left: 10, width: 50} as DOMRect));
+    const tooltip = makeTooltip(
+      {height: 30, width: 120} as DOMRect,
+      {top: 0, left: 0, width: 1000} as DOMRect
+    ) as GrTooltip;
+
+    element._positionTooltip(tooltip);
+    await element.updateComplete;
+    assert.isBelow(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '0px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('right side position', () => {
+    sinon
+      .stub(element, 'getBoundingClientRect')
+      .callsFake(() => ({top: 100, left: 950, width: 50} as DOMRect));
+    const tooltip = makeTooltip(
+      {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);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('position to bottom', () => {
+    sinon
+      .stub(element, 'getBoundingClientRect')
+      .callsFake(
+        () => ({top: 100, left: 950, width: 50, height: 50} as DOMRect)
+      );
+    const tooltip = makeTooltip(
+      {height: 30, width: 120} as DOMRect,
+      {top: 0, left: 0, width: 1000} as DOMRect
+    ) as GrTooltip;
+
+    element.positionBelow = true;
+    element._positionTooltip(tooltip);
+    assert.isAbove(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '157.2px');
+  });
+
+  test('hides tooltip when detached', async () => {
+    const handleHideTooltipStub = sinon.stub(element, '_handleHideTooltip');
+    element.remove();
+    await element.updateComplete;
+    assert.isTrue(handleHideTooltipStub.called);
+  });
+
+  test('sets up listeners when has-tooltip is changed', async () => {
+    const addListenerStub = sinon.stub(element, 'addEventListener');
+    element.hasTooltip = true;
+    await element.updateComplete;
+    assert.isTrue(addListenerStub.called);
+  });
+
+  test('clean up listeners when has-tooltip changed to false', async () => {
+    const removeListenerStub = sinon.stub(element, 'removeEventListener');
+    element.hasTooltip = true;
+    await element.updateComplete;
+    element.hasTooltip = false;
+    await element.updateComplete;
+    assert.isTrue(removeListenerStub.called);
+  });
+
+  test('do not display tooltips on touch devices', async () => {
+    // On touch devices, tooltips should not be shown.
+    element.isTouchDevice = true;
+    await element.updateComplete;
+
+    // fire mouse-enter
+    await element._handleShowTooltip();
+    await element.updateComplete;
+    assert.isNotOk(element.tooltip);
+
+    // fire mouse-enter
+    element._handleHideTooltip();
+    await element.updateComplete;
+    assert.isNotOk(element.tooltip);
+
+    // On other devices, tooltips should be shown.
+    element.isTouchDevice = false;
+
+    // fire mouse-enter
+    await element._handleShowTooltip();
+    await element.updateComplete;
+    assert.isOk(element.tooltip);
+
+    // fire mouse-enter
+    element._handleHideTooltip();
+    await element.updateComplete;
+    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 cab05b4..bd4efcd 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
@@ -14,14 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-tooltip_html';
-import {customElement, property, observe} from '@polymer/decorators';
 
-export interface GrTooltip {
-  $: {};
-}
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {styleMap} from 'lit/directives/style-map';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -30,22 +27,78 @@
 }
 
 @customElement('gr-tooltip')
-export class GrTooltip extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrTooltip extends LitElement {
   @property({type: String})
   text = '';
 
   @property({type: String})
   maxWidth = '';
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: String})
+  arrowCenterOffset = '0';
+
+  @property({type: Boolean, reflect: true, attribute: 'position-below'})
   positionBelow = false;
 
-  @observe('maxWidth')
-  _updateWidth(maxWidth: string) {
-    this.updateStyles({'--tooltip-max-width': maxWidth});
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          --gr-tooltip-arrow-size: 0.5em;
+
+          background-color: var(--tooltip-background-color);
+          box-shadow: var(--elevation-level-2);
+          color: var(--tooltip-text-color);
+          font-size: var(--font-size-small);
+          position: absolute;
+          z-index: 1000;
+        }
+        :host .tooltip {
+          padding: var(--spacing-m) var(--spacing-l);
+        }
+        :host .arrowPositionBelow,
+        :host([position-below]) .arrowPositionAbove {
+          display: none;
+        }
+        :host([position-below]) .arrowPositionBelow {
+          display: initial;
+        }
+        .arrow {
+          border-left: var(--gr-tooltip-arrow-size) solid transparent;
+          border-right: var(--gr-tooltip-arrow-size) solid transparent;
+          height: 0;
+          position: absolute;
+          left: calc(50% - var(--gr-tooltip-arrow-size));
+          width: 0;
+        }
+        .arrowPositionAbove {
+          border-top: var(--gr-tooltip-arrow-size) solid
+            var(--tooltip-background-color);
+          bottom: calc(-1 * var(--gr-tooltip-arrow-size));
+        }
+        .arrowPositionBelow {
+          border-bottom: var(--gr-tooltip-arrow-size) solid
+            var(--tooltip-background-color);
+          top: calc(-1 * var(--gr-tooltip-arrow-size));
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    this.style.maxWidth = this.maxWidth;
+
+    return html` <div class="tooltip">
+      <i
+        class="arrowPositionBelow arrow"
+        style=${styleMap({marginLeft: this.arrowCenterOffset})}
+      ></i>
+      ${this.text}
+      <i
+        class="arrowPositionAbove arrow"
+        style=${styleMap({marginLeft: this.arrowCenterOffset})}
+      ></i>
+    </div>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.ts
deleted file mode 100644
index d59a6c3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_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 {
-      --gr-tooltip-arrow-size: 0.5em;
-      --gr-tooltip-arrow-center-offset: 0;
-
-      background-color: var(--tooltip-background-color);
-      box-shadow: var(--elevation-level-2);
-      color: var(--tooltip-text-color);
-      font-size: var(--font-size-small);
-      position: absolute;
-      z-index: 1000;
-      max-width: var(--tooltip-max-width);
-    }
-    :host .tooltip {
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    :host .arrowPositionBelow,
-    :host([position-below]) .arrowPositionAbove {
-      display: none;
-    }
-    :host([position-below]) .arrowPositionBelow {
-      display: initial;
-    }
-    .arrow {
-      border-left: var(--gr-tooltip-arrow-size) solid transparent;
-      border-right: var(--gr-tooltip-arrow-size) solid transparent;
-      height: 0;
-      position: absolute;
-      left: calc(50% - var(--gr-tooltip-arrow-size));
-      margin-left: var(--gr-tooltip-arrow-center-offset);
-      width: 0;
-    }
-    .arrowPositionAbove {
-      border-top: var(--gr-tooltip-arrow-size) solid
-        var(--tooltip-background-color);
-      bottom: calc(-1 * var(--gr-tooltip-arrow-size));
-    }
-    .arrowPositionBelow {
-      border-bottom: var(--gr-tooltip-arrow-size) solid
-        var(--tooltip-background-color);
-      top: calc(-1 * var(--gr-tooltip-arrow-size));
-    }
-  </style>
-  <div class="tooltip">
-    <i class="arrowPositionBelow arrow"></i>
-    [[text]]
-    <i class="arrowPositionAbove arrow"></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 65ceab1..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
@@ -17,50 +17,45 @@
 
 import '../../../test/common-test-setup-karma';
 import './gr-tooltip';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {GrTooltip} from './gr-tooltip';
+import {queryAndAssert} from '../../../test/test-utils';
 
-const basicFixture = fixtureFromTemplate(html` <gr-tooltip> </gr-tooltip> `);
+const basicFixture = fixtureFromElement('gr-tooltip');
 
 suite('gr-tooltip tests', () => {
   let element: GrTooltip;
+
   setup(async () => {
-    element = basicFixture.instantiate() as GrTooltip;
-    await flush();
+    element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
-  test('max-width is respected if set', () => {
+  test('max-width is respected if set', async () => {
     element.text =
       'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
       ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
     element.maxWidth = '50px';
+    await element.updateComplete;
     assert.equal(getComputedStyle(element).width, '50px');
   });
 
-  test('the correct arrow is displayed', () => {
+  test('the correct arrow is displayed', async () => {
     assert.equal(
-      getComputedStyle(
-        element.shadowRoot!.querySelector('.arrowPositionBelow')!
-      ).display,
+      getComputedStyle(queryAndAssert(element, '.arrowPositionBelow')!).display,
       'none'
     );
     assert.notEqual(
-      getComputedStyle(
-        element.shadowRoot!.querySelector('.arrowPositionAbove')!
-      ).display,
+      getComputedStyle(queryAndAssert(element, '.arrowPositionAbove')!).display,
       'none'
     );
     element.positionBelow = true;
+    await element.updateComplete;
     assert.notEqual(
-      getComputedStyle(
-        element.shadowRoot!.querySelector('.arrowPositionBelow')!
-      ).display,
+      getComputedStyle(queryAndAssert(element, '.arrowPositionBelow')!).display,
       'none'
     );
     assert.equal(
-      getComputedStyle(
-        element.shadowRoot!.querySelector('.arrowPositionAbove')!
-      ).display,
+      getComputedStyle(queryAndAssert(element, '.arrowPositionAbove')!).display,
       'none'
     );
   });
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 9013088..c4aa413 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
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../gr-tooltip-content/gr-tooltip-content';
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators';
 import {
@@ -22,7 +23,7 @@
   isQuickLabelInfo,
   LabelInfo,
 } from '../../../api/rest-api';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {
   classForLabelStatus,
@@ -35,7 +36,9 @@
     'gr-vote-chip': GrVoteChip;
   }
 }
-
+/**
+ * @attr {Boolean} circle-shape - element has shape as circle
+ */
 @customElement('gr-vote-chip')
 export class GrVoteChip extends LitElement {
   @property({type: Object})
@@ -48,16 +51,31 @@
   @property({type: Boolean})
   more = false;
 
-  private readonly flagsService = appContext.flagsService;
+  /**
+   * If defined, vote-chip is shown with this value instead of the latest vote.
+   * This is useful for change log.
+   */
+  @property()
+  displayValue?: string;
+
+  @property({type: Boolean, attribute: 'tooltip-with-who-voted'})
+  tooltipWithWhoVoted = false;
+
+  private readonly flagsService = getAppContext().flagsService;
 
   static override get styles() {
     return [
       css`
+        :host([circle-shape]) .vote-chip {
+          border-radius: 50%;
+          border: none;
+          padding: 2px;
+        }
         .vote-chip.max {
           background-color: var(--vote-color-approved);
           padding: 2px;
         }
-        .vote-chip.max.more {
+        .more > .vote-chip.max {
           padding: 1px;
           border: 1px solid var(--vote-outline-recommended);
         }
@@ -65,7 +83,7 @@
           background-color: var(--vote-color-rejected);
           padding: 2px;
         }
-        .vote-chip.min.more {
+        .more > .vote-chip.min {
           padding: 1px;
           border: 1px solid var(--vote-outline-disliked);
         }
@@ -93,12 +111,13 @@
           padding: 1px;
           border-radius: var(--border-radius);
           line-height: var(--gr-vote-chip-width, 16px);
+          color: var(--vote-text-color);
         }
-        .vote-chip {
+        .more > .vote-chip {
           position: relative;
           z-index: 2;
         }
-        .chip-angle {
+        .more > .chip-angle {
           position: absolute;
           top: 2px;
           left: 2px;
@@ -107,6 +126,12 @@
         .container {
           position: relative;
         }
+        /* fix for firefox only */
+        @supports (-moz-appearance: none) {
+          .container.more {
+            display: inline-block;
+          }
+        }
       `,
     ];
   }
@@ -118,19 +143,24 @@
     const renderValue = this.renderValue();
     if (!renderValue) return;
 
-    return html`<span class="container">
-      <div class="vote-chip ${this.computeClass()} ${this.more ? 'more' : ''}">
-        ${renderValue}
-      </div>
+    return html`<gr-tooltip-content
+      class="container ${this.more ? 'more' : ''}"
+      title=${this.computeTooltip()}
+      has-tooltip
+    >
+      <div class="vote-chip ${this.computeClass()}">${renderValue}</div>
       ${this.more
         ? html`<div class="chip-angle ${this.computeClass()}">
             ${renderValue}
           </div>`
         : ''}
-    </span>`;
+    </gr-tooltip-content>`;
   }
 
   private renderValue() {
+    if (this.displayValue) {
+      return this.displayValue;
+    }
     if (!this.label) {
       return '';
     } else if (isDetailedLabelInfo(this.label)) {
@@ -139,9 +169,11 @@
       }
     } else if (isQuickLabelInfo(this.label)) {
       if (this.label.approved) {
-        return '👍️';
+        return '👍';
       } else if (this.label.rejected) {
-        return '👎️';
+        return '👎';
+      } else if (this.label.disliked || this.label.recommended) {
+        return valueString(this.label.value);
       }
     }
     return '';
@@ -150,15 +182,26 @@
   private computeClass() {
     if (!this.label) {
       return '';
-    } else if (isDetailedLabelInfo(this.label)) {
-      if (this.vote?.value) {
-        const status = getLabelStatus(this.label, this.vote.value);
-        return classForLabelStatus(status);
-      }
-    } else if (isQuickLabelInfo(this.label)) {
-      const status = getLabelStatus(this.label);
+    } else if (this.displayValue) {
+      const status = getLabelStatus(this.label, Number(this.displayValue));
+      return classForLabelStatus(status);
+    } else {
+      const status = getLabelStatus(this.label, this.vote?.value);
       return classForLabelStatus(status);
     }
-    return '';
+  }
+
+  private computeTooltip() {
+    if (!this.label || !isDetailedLabelInfo(this.label)) {
+      return '';
+    }
+    const voteDescription =
+      this.label.values?.[valueString(this.vote?.value)] ?? '';
+
+    if (this.tooltipWithWhoVoted && this.vote) {
+      return `${this.vote?.name}: ${voteDescription}`;
+    } else {
+      return voteDescription;
+    }
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip_test.ts b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip_test.ts
new file mode 100644
index 0000000..72ff8d7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip_test.ts
@@ -0,0 +1,120 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import {fixture} from '@open-wc/testing-helpers';
+import {html} from 'lit';
+import {getAppContext} from '../../../services/app-context';
+import './gr-vote-chip';
+import {GrVoteChip} from './gr-vote-chip';
+import {
+  createAccountWithIdNameAndEmail,
+  createApproval,
+  createDetailedLabelInfo,
+  createQuickLabelInfo,
+} from '../../../test/test-data-generators';
+import {ApprovalInfo} from '../../../api/rest-api';
+
+suite('gr-vote-chip tests', () => {
+  setup(() => {
+    sinon.stub(getAppContext().flagsService, 'isEnabled').returns(true);
+  });
+
+  suite('with QuickLabelInfo', () => {
+    let element: GrVoteChip;
+
+    setup(async () => {
+      const labelInfo = {
+        ...createQuickLabelInfo(),
+        approved: createAccountWithIdNameAndEmail(),
+      };
+      element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip .label=${labelInfo}></gr-vote-chip>`
+      );
+    });
+
+    test('renders', () => {
+      expect(element).shadowDom.to.equal(/* HTML */ ` <gr-tooltip-content
+        class="container"
+        has-tooltip=""
+        title=""
+      >
+        <div class="max vote-chip">👍</div>
+      </gr-tooltip-content>`);
+    });
+  });
+
+  suite('with DetailedLabelInfo', () => {
+    let element: GrVoteChip;
+    const labelInfo = createDetailedLabelInfo();
+    const vote: ApprovalInfo = {
+      ...createApproval(),
+      value: 2,
+    };
+
+    setup(async () => {
+      element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip .label=${labelInfo} .vote=${vote}></gr-vote-chip>`
+      );
+    });
+
+    test('renders', () => {
+      expect(element).shadowDom.to.equal(/* HTML */ ` <gr-tooltip-content
+        class="container"
+        has-tooltip=""
+        title=""
+      >
+        <div class="positive vote-chip">+2</div>
+      </gr-tooltip-content>`);
+    });
+
+    test('renders negative vote', async () => {
+      const vote: ApprovalInfo = {
+        ...createApproval,
+        value: -1,
+      };
+      element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip .label=${labelInfo} .vote=${vote}></gr-vote-chip>`
+      );
+      expect(element).shadowDom.to.equal(/* HTML */ ` <gr-tooltip-content
+        class="container"
+        has-tooltip=""
+        title="Wrong Style or Formatting"
+      >
+        <div class="min vote-chip">-1</div>
+      </gr-tooltip-content>`);
+    });
+
+    test('renders for more than 1 vote', async () => {
+      element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip
+          .label=${labelInfo}
+          .vote=${vote}
+          more
+        ></gr-vote-chip>`
+      );
+      expect(element).shadowDom.to.equal(/* HTML */ ` <gr-tooltip-content
+        class="container more"
+        has-tooltip=""
+        title=""
+      >
+        <div class="positive vote-chip">+2</div>
+        <div class="chip-angle positive">+2</div>
+      </gr-tooltip-content>`);
+    });
+  });
+});
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/elements/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
similarity index 85%
rename from polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
rename to polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index af9c9d4..77a5dfb 100644
--- a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -24,15 +24,16 @@
 import '@polymer/paper-listbox/paper-listbox';
 import '@polymer/paper-tooltip/paper-tooltip';
 import {of, EMPTY, Subject} from 'rxjs';
-import {switchMap, delay, takeUntil} from 'rxjs/operators';
+import {switchMap, delay} from 'rxjs/operators';
 
-import '../../shared/gr-button/gr-button';
+import '../../../elements/shared/gr-button/gr-button';
 import {pluralize} from '../../../utils/string-util';
 import {fire} from '../../../utils/event-util';
 import {DiffInfo} from '../../../types/diff';
 import {assertIsDefined} from '../../../utils/common-util';
 import {css, html, LitElement, TemplateResult} from 'lit';
 import {customElement, property} from 'lit/decorators';
+import {subscribe} from '../../../elements/lit/subscription-controller';
 
 import {
   ContextButtonType,
@@ -85,9 +86,7 @@
 
   @property({type: Object}) diff?: DiffInfo;
 
-  @property({type: Object}) section?: HTMLElement;
-
-  @property({type: Object}) contextGroups: GrDiffGroup[] = [];
+  @property({type: Object}) group?: GrDiffGroup;
 
   @property({type: String, reflect: true})
   showConfig: GrContextControlsShowConfig = 'both';
@@ -98,8 +97,6 @@
     linesToExpand: number;
   }>();
 
-  private disconnected$ = new Subject();
-
   static override styles = css`
     :host {
       display: flex;
@@ -208,10 +205,6 @@
     this.setupButtonHoverHandler();
   }
 
-  override disconnectedCallback() {
-    this.disconnected$.next();
-  }
-
   private showBoth() {
     return this.showConfig === 'both';
   }
@@ -224,9 +217,10 @@
     return this.showBoth() || this.showConfig === 'below';
   }
 
-  setupButtonHoverHandler() {
-    this.expandButtonsHover
-      .pipe(
+  private setupButtonHoverHandler() {
+    subscribe(
+      this,
+      this.expandButtonsHover.pipe(
         switchMap(e => {
           if (e.eventType === 'leave') {
             // cancel any previous delay
@@ -234,20 +228,23 @@
             return EMPTY;
           }
           return of(e).pipe(delay(500));
-        }),
-        takeUntil(this.disconnected$)
-      )
-      .subscribe(({buttonType, linesToExpand}) => {
+        })
+      ),
+      ({buttonType, linesToExpand}) => {
         fire(this, 'diff-context-button-hovered', {
           buttonType,
           linesToExpand,
         });
-      });
+      }
+    );
   }
 
   private numLines() {
-    const {leftStart, leftEnd} = this.contextRange();
-    return leftEnd - leftStart + 1;
+    assertIsDefined(this.group);
+    // In context groups, there is the same number of lines left and right
+    const left = this.group.lineRange.left;
+    // Both start and end inclusive, so we need to add 1.
+    return left.end_line - left.start_line + 1;
   }
 
   private createExpandAllButtonContainer() {
@@ -266,6 +263,7 @@
     linesToExpand: number,
     tooltip?: TemplateResult
   ) {
+    if (!this.group) return;
     let text = '';
     let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
     let ariaLabel = '';
@@ -279,14 +277,14 @@
         : this.showAbove()
         ? 'aboveButton'
         : 'belowButton';
-      if (this.partialContent) {
+      if (this.group?.hasSkipGroup()) {
         // Expanding content would require load of more data
         text += ' (too large)';
       }
-      groups.push(...this.contextGroups);
+      groups.push(...this.group.contextGroups);
     } else if (type === ContextButtonType.ABOVE) {
       groups = hideInContextControl(
-        this.contextGroups,
+        this.group.contextGroups,
         linesToExpand,
         this.numLines()
       );
@@ -295,7 +293,7 @@
       ariaLabel = `Show ${pluralize(linesToExpand, 'line')} above`;
     } else if (type === ContextButtonType.BELOW) {
       groups = hideInContextControl(
-        this.contextGroups,
+        this.group.contextGroups,
         0,
         this.numLines() - linesToExpand
       );
@@ -304,7 +302,7 @@
       ariaLabel = `Show ${pluralize(linesToExpand, 'line')} below`;
     } else if (type === ContextButtonType.BLOCK_ABOVE) {
       groups = hideInContextControl(
-        this.contextGroups,
+        this.group.contextGroups,
         linesToExpand,
         this.numLines()
       );
@@ -313,7 +311,7 @@
       ariaLabel = 'Show block above';
     } else if (type === ContextButtonType.BLOCK_BELOW) {
       groups = hideInContextControl(
-        this.contextGroups,
+        this.group.contextGroups,
         0,
         this.numLines() - linesToExpand
       );
@@ -336,11 +334,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}
@@ -354,27 +352,16 @@
     groups: GrDiffGroup[]
   ) {
     return (e: Event) => {
+      assertIsDefined(this.group);
       e.stopPropagation();
-      if (type === ContextButtonType.ALL && this.partialContent) {
-        const {leftStart, leftEnd, rightStart, rightEnd} = this.contextRange();
-        const lineRange = {
-          left: {
-            start_line: leftStart,
-            end_line: leftEnd,
-          },
-          right: {
-            start_line: rightStart,
-            end_line: rightEnd,
-          },
-        };
+      if (type === ContextButtonType.ALL && this.group?.hasSkipGroup()) {
         fire(this, 'content-load-needed', {
-          lineRange,
+          lineRange: this.group.lineRange,
         });
       } else {
-        assertIsDefined(this.section, 'section');
         fire(this, 'diff-context-expanded', {
+          contextGroup: this.group,
           groups,
-          section: this.section!,
           numLines: this.numLines(),
           buttonType: type,
           expandedLines: linesToExpand,
@@ -416,20 +403,14 @@
   }
 
   /**
-   * Checks if the collapsed section contains unavailable content (skip chunks).
-   */
-  private get partialContent() {
-    return this.contextGroups.some(c => !!c.skip);
-  }
-
-  /**
    * Creates a container div with block expansion buttons (above and/or below).
    */
   private createBlockExpansionButtons() {
+    assertIsDefined(this.group, 'group');
     if (
       !this.showPartialLinks() ||
       !this.renderPreferences?.use_block_expansion ||
-      this.partialContent
+      this.group?.hasSkipGroup()
     ) {
       return undefined;
     }
@@ -439,14 +420,14 @@
       aboveBlockButton = this.createBlockButton(
         ContextButtonType.BLOCK_ABOVE,
         this.numLines(),
-        this.contextRange().rightStart - 1
+        this.group.lineRange.right.start_line - 1
       );
     }
     if (this.showBelow()) {
       belowBlockButton = this.createBlockButton(
         ContextButtonType.BLOCK_BELOW,
         this.numLines(),
-        this.contextRange().rightEnd + 1
+        this.group.lineRange.right.end_line + 1
       );
     }
     if (aboveBlockButton || belowBlockButton) {
@@ -470,7 +451,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
     >`;
   }
@@ -481,7 +462,7 @@
     referenceLine: number
   ) {
     assertIsDefined(this.diff, 'diff');
-    const syntaxTree = this.diff!.meta_b.syntax_tree;
+    const syntaxTree = this.diff.meta_b.syntax_tree;
     const outlineSyntaxPath = findBlockTreePathForLine(
       referenceLine,
       syntaxTree
@@ -506,21 +487,8 @@
     return this.createContextButton(buttonType, linesToExpand, tooltip);
   }
 
-  private contextRange() {
-    return {
-      leftStart: this.contextGroups[0].lineRange.left.start_line,
-      leftEnd:
-        this.contextGroups[this.contextGroups.length - 1].lineRange.left
-          .end_line,
-      rightStart: this.contextGroups[0].lineRange.right.start_line,
-      rightEnd:
-        this.contextGroups[this.contextGroups.length - 1].lineRange.right
-          .end_line,
-    };
-  }
-
   private hasValidProperties() {
-    return !!(this.diff && this.section && this.contextGroups?.length);
+    return !!(this.diff && this.group?.contextGroups?.length);
   }
 
   override render() {
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
similarity index 89%
rename from polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
rename to polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
index cacea42..d8d4e25 100644
--- a/polygerrit-ui/app/elements/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';
@@ -34,12 +33,11 @@
     element = document.createElement('gr-context-controls');
     element.diff = {content: []} as any as DiffInfo;
     element.renderPreferences = {};
-    element.section = document.createElement('div');
     blankFixture.instantiate().appendChild(element);
     await flush();
   });
 
-  function createContextGroups(options: {offset?: number; count?: number}) {
+  function createContextGroup(options: {offset?: number; count?: number}) {
     const offset = options.offset || 0;
     const numLines = options.count || 10;
     const lines = [];
@@ -50,12 +48,14 @@
       line.text = 'lorem upsum';
       lines.push(line);
     }
-
-    return [new GrDiffGroup(GrDiffGroupType.BOTH, lines)];
+    return new GrDiffGroup({
+      type: GrDiffGroupType.CONTEXT_CONTROL,
+      contextGroups: [new GrDiffGroup({type: GrDiffGroupType.BOTH, lines})],
+    });
   }
 
   test('no +10 buttons for 10 or less lines', async () => {
-    element.contextGroups = createContextGroups({count: 10});
+    element.group = createContextGroup({count: 10});
 
     await flush();
 
@@ -67,7 +67,7 @@
   });
 
   test('context control at the top', async () => {
-    element.contextGroups = createContextGroups({offset: 0, count: 20});
+    element.group = createContextGroup({offset: 0, count: 20});
     element.showConfig = 'below';
 
     await flush();
@@ -85,7 +85,7 @@
   });
 
   test('context control in the middle', async () => {
-    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.group = createContextGroup({offset: 10, count: 20});
     element.showConfig = 'both';
 
     await flush();
@@ -105,7 +105,7 @@
   });
 
   test('context control at the bottom', async () => {
-    element.contextGroups = createContextGroups({offset: 30, count: 20});
+    element.group = createContextGroup({offset: 30, count: 20});
     element.showConfig = 'above';
 
     await flush();
@@ -131,7 +131,7 @@
 
   test('context control with block expansion at the top', async () => {
     prepareForBlockExpansion([]);
-    element.contextGroups = createContextGroups({offset: 0, count: 20});
+    element.group = createContextGroup({offset: 0, count: 20});
     element.showConfig = 'below';
 
     await flush();
@@ -160,7 +160,7 @@
 
   test('context control with block expansion in the middle', async () => {
     prepareForBlockExpansion([]);
-    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.group = createContextGroup({offset: 10, count: 20});
     element.showConfig = 'both';
 
     await flush();
@@ -197,7 +197,7 @@
 
   test('context control with block expansion at the bottom', async () => {
     prepareForBlockExpansion([]);
-    element.contextGroups = createContextGroups({offset: 30, count: 20});
+    element.group = createContextGroup({offset: 30, count: 20});
     element.showConfig = 'above';
 
     await flush();
@@ -237,7 +237,7 @@
         children: [],
       },
     ]);
-    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.group = createContextGroup({offset: 10, count: 20});
     element.showConfig = 'both';
 
     await flush();
@@ -289,7 +289,7 @@
         ],
       },
     ]);
-    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.group = createContextGroup({offset: 10, count: 20});
     element.showConfig = 'both';
 
     await flush();
@@ -335,7 +335,7 @@
         ],
       },
     ]);
-    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.group = createContextGroup({offset: 10, count: 20});
     element.showConfig = 'both';
     await flush();
 
@@ -353,7 +353,7 @@
   test('+Block tooltip shows "all common lines" for empty syntax tree', async () => {
     prepareForBlockExpansion([]);
 
-    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.group = createContextGroup({offset: 10, count: 20});
     element.showConfig = 'both';
     await flush();
 
@@ -372,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
new file mode 100644
index 0000000..dae5c03
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Side} from '../../../api/diff';
+import {CoverageRange, CoverageType, DiffLayer} from '../../../types/types';
+
+const TOOLTIP_MAP = new Map([
+  [CoverageType.COVERED, 'Covered by tests.'],
+  [CoverageType.NOT_COVERED, 'Not covered by tests.'],
+  [CoverageType.PARTIALLY_COVERED, 'Partially covered by tests.'],
+  [CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
+]);
+
+export class GrCoverageLayer implements DiffLayer {
+  /**
+   * Must be sorted by code_range.start_line.
+   * Must only contain ranges that match the side.
+   */
+  private coverageRanges: CoverageRange[] = [];
+
+  /**
+   * We keep track of the line number from the previous annotate() call,
+   * and also of the index of the coverage range that had matched.
+   * annotate() calls are coming in with increasing line numbers and
+   * coverage ranges are sorted by line number. So this is a very simple
+   * and efficient way for finding the coverage range that matches a given
+   * line number.
+   */
+  private lastLineNumber = 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.
+   *
+   * @param _el Not used for this layer. (unused parameter)
+   * @param lineNumberEl The <td> element with the line number.
+   * @param line Not used for this layer.
+   */
+  annotate(_el: HTMLElement, lineNumberEl: HTMLElement) {
+    if (
+      !this.side ||
+      !lineNumberEl ||
+      !lineNumberEl.classList.contains(this.side)
+    ) {
+      return;
+    }
+    let elementLineNumber;
+    const dataValue = lineNumberEl.getAttribute('data-value');
+    if (dataValue) {
+      elementLineNumber = Number(dataValue);
+    }
+    if (!elementLineNumber || elementLineNumber < 1) return;
+
+    // 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.lastLineNumber) {
+      this.index = 0;
+    }
+    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];
+
+      // If the line number has moved past the current coverage range, then
+      // try the next coverage range.
+      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.lastLineNumber < coverageRange.code_range.start_line) {
+        return;
+      }
+
+      // The line number is within the current coverage range. Style it!
+      lineNumberEl.classList.add(coverageRange.type);
+      lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type) || '';
+      return;
+    }
+  }
+}
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/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
similarity index 85%
rename from polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
index 4186a10..6699b57 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -14,12 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {queryAndAssert} from '../../../utils/common-util';
-import {RenderPreferences} from '../../../api/diff';
+import {createElementDiff} from '../gr-diff/gr-diff-utils';
 
 export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
   constructor(
@@ -31,14 +30,12 @@
   }
 
   override buildSectionElement(): HTMLElement {
-    const section = this._createElement('tbody', 'binary-diff');
+    const section = createElementDiff('tbody', 'binary-diff');
     const line = new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE');
-    const fileRow = this._createRow(line);
+    const fileRow = this.createRow(line);
     const contentTd = queryAndAssert(fileRow, 'td.both.file')!;
     contentTd.textContent = ' Difference in binary files';
     section.appendChild(fileRow);
     return section;
   }
-
-  override updateRenderPrefs(_renderPrefs: RenderPreferences) {}
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
similarity index 65%
rename from polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index 720cc27..e426e66 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -14,15 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../gr-coverage-layer/gr-coverage-layer';
 import '../gr-diff-processor/gr-diff-processor';
-import '../../shared/gr-hovercard/gr-hovercard';
-import '../gr-ranged-comment-layer/gr-ranged-comment-layer';
+import '../../../elements/shared/gr-hovercard/gr-hovercard';
 import './gr-diff-builder-side-by-side';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-builder-element_html';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GrDiffBuilder} from './gr-diff-builder';
+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';
@@ -34,6 +32,7 @@
 import {CoverageRange, DiffLayer} from '../../../types/types';
 import {
   GrDiffProcessor,
+  GroupConsumer,
   KeyLocations,
 } from '../gr-diff-processor/gr-diff-processor';
 import {
@@ -42,12 +41,16 @@
 } 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 {Side} from '../../../constants/constants';
+import {createDefaultDiffPrefs, Side} from '../../../constants/constants';
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group';
-import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from '../gr-diff/gr-diff-group';
 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';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
@@ -55,15 +58,6 @@
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 const COMMIT_MSG_LINE_LENGTH = 72;
 
-export interface GrDiffBuilderElement {
-  $: {
-    processor: GrDiffProcessor;
-    rangeLayer: GrRangedCommentLayer;
-    coverageLayerLeft: GrCoverageLayer;
-    coverageLayerRight: GrCoverageLayer;
-  };
-}
-
 export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
   return prefs.font_size * 4;
 }
@@ -94,7 +88,10 @@
 }
 
 @customElement('gr-diff-builder')
-export class GrDiffBuilderElement extends PolymerElement {
+export class GrDiffBuilderElement
+  extends PolymerElement
+  implements GroupConsumer
+{
   static get template() {
     return htmlTemplate;
   }
@@ -106,6 +103,12 @@
    */
 
   /**
+   * Fired whenever a new chunk of lines has been rendered synchronously.
+   *
+   * @event render-progress
+   */
+
+  /**
    * Fired when the diff finishes rendering text content.
    *
    * @event render-content
@@ -115,12 +118,6 @@
   diff?: DiffInfo;
 
   @property({type: String})
-  changeNum?: string;
-
-  @property({type: String})
-  patchNum?: string;
-
-  @property({type: String})
   viewMode?: string;
 
   @property({type: Boolean})
@@ -139,8 +136,23 @@
   path?: string;
 
   @property({type: Object})
-  _builder?: GrDiffBuilder;
+  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[] = [];
 
@@ -165,24 +177,12 @@
   @property({type: Array})
   commentRanges: CommentRangeLayer[] = [];
 
-  @property({type: Array})
+  @property({type: Array, observer: 'coverageObserver'})
   coverageRanges: CoverageRange[] = [];
 
   @property({type: Boolean})
   useNewImageDiffUi = false;
 
-  @property({
-    type: Array,
-    computed: '_computeLeftCoverageRanges(coverageRanges)',
-  })
-  _leftCoverageRanges?: CoverageRange[];
-
-  @property({
-    type: Array,
-    computed: '_computeRightCoverageRanges(coverageRanges)',
-  })
-  _rightCoverageRanges?: CoverageRange[];
-
   /**
    * The promise last returned from `render()` while the asynchronous
    * rendering is running - `null` otherwise. Provides a `cancel()`
@@ -191,7 +191,31 @@
   @property({type: Object})
   _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
 
+  private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
+
+  private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
+
+  private rangeLayer = new GrRangedCommentLayer();
+
+  private processor = new GrDiffProcessor();
+
+  constructor() {
+    super();
+    afterNextRender(this, () => {
+      this.addEventListener(
+        'diff-context-expanded',
+        (e: CustomEvent<DiffContextExpandedEventDetail>) => {
+          // Don't stop propagation. The host may listen for reporting or
+          // resizing.
+          this.replaceGroup(e.detail.contextGroup, e.detail.groups);
+        }
+      );
+    });
+    this.processor.consumer = this;
+  }
+
   override disconnectedCallback() {
+    this.processor.cancel();
     if (this._builder) {
       this._builder.clear();
     }
@@ -203,27 +227,32 @@
     return this.querySelector('#diffTable') as HTMLTableElement;
   }
 
-  _computeLeftCoverageRanges(coverageRanges: CoverageRange[]) {
-    return coverageRanges.filter(range => range && range.side === 'left');
+  @observe('commentRanges.*')
+  rangeObserver() {
+    this.rangeLayer.updateRanges(this.commentRanges);
   }
 
-  _computeRightCoverageRanges(coverageRanges: CoverageRange[]) {
-    return coverageRanges.filter(range => range && range.side === 'right');
+  coverageObserver(coverageRanges: CoverageRange[]) {
+    const leftRanges = coverageRanges.filter(
+      range => range && range.side === Side.LEFT
+    );
+    this.coverageLayerLeft.setRanges(leftRanges);
+
+    const rightRanges = coverageRanges.filter(
+      range => range && range.side === Side.RIGHT
+    );
+    this.coverageLayerRight.setRanges(rightRanges);
   }
 
-  render(
-    keyLocations: KeyLocations,
-    prefs: DiffPreferencesInfo,
-    renderPrefs?: RenderPreferences
-  ) {
+  render(keyLocations: KeyLocations) {
     // Setting up annotation layers must happen after plugins are
     // installed, and |render| satisfies the requirement, however,
     // |attached| doesn't because in the diff view page, the element is
     // attached before plugins are installed.
     this._setupAnnotationLayers();
 
-    this._showTabs = !!prefs.show_tabs;
-    this._showTrailingWhitespace = !!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();
@@ -234,19 +263,22 @@
     if (!this.diff) {
       throw Error('Cannot render a diff without DiffInfo.');
     }
-    this._builder = this._getDiffBuilder(this.diff, prefs, renderPrefs);
+    this._builder = this._getDiffBuilder();
 
-    this.$.processor.context = prefs.context;
-    this.$.processor.keyLocations = keyLocations;
+    this.processor.context = this.prefs.context;
+    this.processor.keyLocations = keyLocations;
 
     this._clearDiffContent();
-    this._builder.addColumns(this.diffElement, getLineNumberCellWidth(prefs));
+    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(() => {
+      this.processor.process(this.diff.content, isBinary).then(() => {
         if (this.isImageDiff) {
           (this._builder as GrDiffBuilderImage).renderDiff();
         }
@@ -274,9 +306,9 @@
       this._createIntralineLayer(),
       this._createTabIndicatorLayer(),
       this._createSpecialCharacterIndicatorLayer(),
-      this.$.rangeLayer,
-      this.$.coverageLayerLeft,
-      this.$.coverageLayerRight,
+      this.rangeLayer,
+      this.coverageLayerLeft,
+      this.coverageLayerRight,
     ];
 
     if (this.layers) {
@@ -309,36 +341,81 @@
   }
 
   getLineElByNumber(lineNumber: LineNumber, side?: Side) {
-    const sideSelector = side ? '.' + side : '';
-    return this.diffElement.querySelector(
-      `.lineNum[data-value="${lineNumber}"]${sideSelector}`
+    if (!this._builder) return null;
+    return this._builder.getLineElByNumber(lineNumber, side);
+  }
+
+  getLineNumberRows() {
+    if (!this._builder) return [];
+    return this._builder.getLineNumberRows();
+  }
+
+  getLineNumEls(side: Side) {
+    if (!this._builder) return [];
+    return this._builder.getLineNumEls(side);
+  }
+
+  /**
+   * When the line is hidden behind a context expander, expand it.
+   *
+   * @param lineNum A line number to expand. Using number here because other
+   *   special case line numbers are never hidden, so it does not make sense
+   *   to expand them.
+   * @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);
+    // Cannot unhide a line that is not part of the diff.
+    if (!group) return;
+    // If it's already visible, great!
+    if (group.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
+    const lineRange = group.lineRange[side];
+    const lineOffset = lineNum - lineRange.start_line;
+    const newGroups = [];
+    const groups = hideInContextControl(
+      group.contextGroups,
+      0,
+      lineOffset - 1 - this.prefs.context
     );
+    // If there is a context group, it will be the first group because we
+    // start hiding from 0 offset
+    if (groups[0].type === GrDiffGroupType.CONTEXT_CONTROL) {
+      newGroups.push(groups.shift()!);
+    }
+    newGroups.push(
+      ...hideInContextControl(
+        groups,
+        lineOffset + 1 + this.prefs.context,
+        // Both ends inclusive, so difference is the offset of the last line.
+        // But we need to pass the first line not to hide, which is the element
+        // after.
+        lineRange.end_line - lineRange.start_line + 1
+      )
+    );
+    this._builder.replaceGroup(group, newGroups);
+    setTimeout(() => fireEvent(this, 'render-content'), 1);
   }
 
-  emitGroup(group: GrDiffGroup, sectionEl: HTMLElement) {
+  /**
+   * Replace the group of a context control section by rendering the provided
+   * groups instead. This happens in response to expanding a context control
+   * group.
+   *
+   * @param contextGroup The context control group to replace
+   * @param newGroups The groups that are replacing the context control group
+   */
+  private replaceGroup(
+    contextGroup: GrDiffGroup,
+    newGroups: readonly GrDiffGroup[]
+  ) {
     if (!this._builder) return;
-    this._builder.emitGroup(group, sectionEl);
-  }
-
-  showContext(newGroups: GrDiffGroup[], sectionEl: HTMLElement) {
-    if (!this._builder) return;
-    const groups = this._builder.groups;
-
-    const contextIndex = groups.findIndex(group => group.element === sectionEl);
-    groups.splice(contextIndex, 1, ...newGroups);
-
-    for (const newGroup of newGroups) {
-      this._builder.emitGroup(newGroup, sectionEl);
-    }
-    if (sectionEl.parentNode) {
-      sectionEl.parentNode.removeChild(sectionEl);
-    }
-
+    this._builder.replaceGroup(contextGroup, newGroups);
     setTimeout(() => fireEvent(this, 'render-content'), 1);
   }
 
   cancel() {
-    this.$.processor.cancel();
+    this.processor.cancel();
     if (this._cancelableRenderPromise) {
       this._cancelableRenderPromise.cancel();
       this._cancelableRenderPromise = null;
@@ -353,20 +430,19 @@
     throw Error(`Invalid preference value: ${pref}`);
   }
 
-  _getDiffBuilder(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    renderPrefs?: RenderPreferences
-  ): GrDiffBuilder {
-    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+  _getDiffBuilder(): DiffBuilder {
+    if (!this.diff) {
+      throw Error('Cannot render a diff without DiffInfo.');
+    }
+    if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
       this._handlePreferenceError('tab size');
     }
 
-    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+    if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
       this._handlePreferenceError('diff width');
     }
 
-    const localPrefs = {...prefs};
+    const localPrefs = {...this.prefs};
     if (this.path === COMMIT_MSG_PATH) {
       // override line_length for commit msg the same way as
       // in gr-diff
@@ -376,32 +452,32 @@
     let builder = null;
     if (this.isImageDiff) {
       builder = new GrDiffBuilderImage(
-        diff,
+        this.diff,
         localPrefs,
         this.diffElement,
         this.baseImage,
         this.revisionImage,
-        renderPrefs,
+        this.renderPrefs,
         this.useNewImageDiffUi
       );
-    } else if (diff.binary) {
+    } else if (this.diff.binary) {
       // If the diff is binary, but not an image.
-      return new GrDiffBuilderBinary(diff, localPrefs, this.diffElement);
+      return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffElement);
     } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
       builder = new GrDiffBuilderSideBySide(
-        diff,
+        this.diff,
         localPrefs,
         this.diffElement,
         this._layers,
-        renderPrefs
+        this.renderPrefs
       );
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
       builder = new GrDiffBuilderUnified(
-        diff,
+        this.diff,
         localPrefs,
         this.diffElement,
         this._layers,
-        renderPrefs
+        this.renderPrefs
       );
     }
     if (!builder) {
@@ -414,19 +490,22 @@
     this.diffElement.innerHTML = '';
   }
 
-  @observe('_groups.splices')
-  _groupsChanged(changeRecord: PolymerSpliceChange<GrDiffGroup[]>) {
-    if (!changeRecord || !this._builder) {
-      return;
-    }
-    for (const splice of changeRecord.indexSplices) {
-      let group;
-      for (let i = 0; i < splice.addedCount; i++) {
-        group = splice.object[splice.index + i];
-        this._builder.groups.push(group);
-        this._builder.emitGroup(group, null);
-      }
-    }
+  /**
+   * Called when the processor starts converting the diff information from the
+   * server into chunks.
+   */
+  clearGroups() {
+    if (!this._builder) return;
+    this._builder.clearGroups();
+  }
+
+  /**
+   * Called when the processor is done converting a chunk of the diff.
+   */
+  addGroup(group: GrDiffGroup) {
+    if (!this._builder) return;
+    this._builder.addGroups([group]);
+    fireEvent(this, 'render-progress');
   }
 
   _createIntralineLayer(): DiffLayer {
@@ -522,12 +601,12 @@
 
   setBlame(blame: BlameInfo[] | null) {
     if (!this._builder) return;
-    this._builder.setBlame(blame);
+    this._builder.setBlame(blame ?? []);
   }
 
   updateRenderPrefs(renderPrefs: RenderPreferences) {
     this._builder?.updateRenderPrefs(renderPrefs);
-    this.$.processor.updateRenderPrefs(renderPrefs);
+    this.processor.updateRenderPrefs(renderPrefs);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
similarity index 100%
rename from polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
diff --git a/polygerrit-ui/app/elements/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
similarity index 69%
rename from polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
index 44b0b8b..0ad21b0 100644
--- a/polygerrit-ui/app/elements/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
@@ -14,23 +14,18 @@
  * 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-context-controls/gr-context-controls.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import {createDiff} from '../../../test/test-data-generators.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 {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
-import {GrDiffBuilder} from './gr-diff-builder.js';
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {DiffViewMode} from '../../../api/diff.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>
@@ -48,6 +43,14 @@
     </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;
@@ -61,60 +64,8 @@
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     stubRestApi('getProjectConfig').returns(Promise.resolve({}));
     stubBaseUrl('/r');
-    prefs = {
-      line_length: 10,
-      show_tabs: true,
-      tab_size: 4,
-    };
-    builder = new GrDiffBuilder({content: []}, prefs);
-  });
-
-  test('_createElement classStr applies all classes', () => {
-    const node = builder._createElement('div', 'test classes');
-    assert.isTrue(node.classList.contains('gr-diff'));
-    assert.isTrue(node.classList.contains('test'));
-    assert.isTrue(node.classList.contains('classes'));
-  });
-
-  test('newlines 1', () => {
-    let text = 'abcdef';
-
-    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML, text);
-    text = 'a'.repeat(20);
-    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML,
-        'a'.repeat(10) +
-        LINE_BREAK_HTML +
-        'a'.repeat(10));
-  });
-
-  test('newlines 2', () => {
-    const text = '<span class="thumbsup">👍</span>';
-    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML,
-        '&lt;span clas' +
-        LINE_BREAK_HTML +
-        's="thumbsu' +
-        LINE_BREAK_HTML +
-        'p"&gt;👍&lt;/span' +
-        LINE_BREAK_HTML +
-        '&gt;');
-  });
-
-  test('newlines 3', () => {
-    const text = '01234\t56789';
-    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML,
-        '01234' + builder._getTabWrapper(3).outerHTML + '56' +
-        LINE_BREAK_HTML +
-        '789');
-  });
-
-  test('newlines 4', () => {
-    const text = '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍';
-    assert.equal(builder._formatText(text, 'NONE', 4, 20).innerHTML,
-        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
-        LINE_BREAK_HTML +
-        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
-        LINE_BREAK_HTML +
-        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍');
+    prefs = {...DEFAULT_PREFS};
+    builder = new GrDiffBuilderLegacy({content: []}, prefs);
   });
 
   test('line_length applied with <wbr> if line_wrapping is true', () => {
@@ -123,7 +74,7 @@
 
     const line = {text, highlights: []};
     const expected = 'a'.repeat(50) + WBR_HTML + 'a';
-    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
+    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
     assert.equal(result, expected);
   });
 
@@ -133,7 +84,7 @@
 
     const line = {text, highlights: []};
     const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
-    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
+    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
     assert.equal(result, expected);
   });
 
@@ -142,26 +93,26 @@
         test(`line_length used for regular files under ${mode}`, () => {
           element.path = '/a.txt';
           element.viewMode = mode;
-          builder = element._getDiffBuilder(
-              {}, {tab_size: 4, line_length: 50}
-          );
+          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;
-          builder = element._getDiffBuilder(
-              {}, {tab_size: 4, line_length: 50}
-          );
+          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', () => {
+  test('createTextEl linewrap with tabs', () => {
     const text = '\t'.repeat(7) + '!';
     const line = {text, highlights: []};
-    const el = builder._createTextEl(undefined, line);
+    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.
@@ -172,73 +123,9 @@
         newlineEl);
   });
 
-  test('text length with tabs and unicode', () => {
-    function expectTextLength(text, tabSize, expected) {
-      // Formatting to |expected| columns should not introduce line breaks.
-      const result = builder._formatText(text, 'NONE', tabSize, expected);
-      assert.isNotOk(result.querySelector('.contentText > .br'),
-          `  Expected the result of: \n` +
-          `      _formatText(${text}', 'NONE',  ${tabSize}, ${expected})\n` +
-          `  to not contain a br. But the actual result HTML was:\n` +
-          `      '${result.innerHTML}'\nwhereupon`);
-
-      // Increasing the line limit should produce the same markup.
-      assert.equal(
-          builder._formatText(text, 'NONE', tabSize, Infinity).innerHTML,
-          result.innerHTML);
-      assert.equal(
-          builder._formatText(text, 'NONE', tabSize, expected + 1).innerHTML,
-          result.innerHTML);
-
-      // Decreasing the line limit should introduce line breaks.
-      if (expected > 0) {
-        const tooSmall = builder._formatText(text,
-            'NONE', tabSize, expected - 1);
-        assert.isOk(tooSmall.querySelector('.contentText > .br'),
-            `  Expected the result of: \n` +
-            `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
-            `  to contain a br. But the actual result HTML was:\n` +
-            `      '${tooSmall.innerHTML}'\nwhereupon`);
-      }
-    }
-    expectTextLength('12345', 4, 5);
-    expectTextLength('\t\t12', 4, 10);
-    expectTextLength('abc💢123', 4, 7);
-    expectTextLength('abc\t', 8, 8);
-    expectTextLength('abc\t\t', 10, 20);
-    expectTextLength('', 10, 0);
-    // 17 Thai combining chars.
-    expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
-    expectTextLength('abc\tde', 10, 12);
-    expectTextLength('abc\tde\t', 10, 20);
-    expectTextLength('\t\t\t\t\t', 20, 100);
-  });
-
-  test('tab wrapper insertion', () => {
-    const html = 'abc\tdef';
-    const tabSize = builder._prefs.tab_size;
-    const wrapper = builder._getTabWrapper(tabSize - 3);
-    assert.ok(wrapper);
-    assert.equal(wrapper.innerText, '\t');
-    assert.equal(
-        builder._formatText(html, 'NONE', tabSize, Infinity).innerHTML,
-        'abc' + wrapper.outerHTML + 'def');
-  });
-
-  test('tab wrapper style', () => {
-    const pattern = new RegExp('^<span class="style-scope gr-diff tab" ' +
-      'style="((?:-moz-)?tab-size: (\\d+);.?)+">\\t<\\/span>$');
-
-    for (const size of [1, 3, 8, 55]) {
-      const html = builder._getTabWrapper(size).outerHTML;
-      expect(html).to.match(pattern);
-      assert.equal(html.match(pattern)[2], size);
-    }
-  });
-
   test('_handlePreferenceError throws with invalid preference', () => {
-    const prefs = {tab_size: 0};
-    assert.throws(() => element._getDiffBuilder(element.diff, prefs));
+    element.prefs = {tab_size: 0};
+    assert.throws(() => element._getDiffBuilder());
   });
 
   test('_handlePreferenceError triggers alert and javascript error', () => {
@@ -250,37 +137,6 @@
       `Fix in diff preferences`);
   });
 
-  suite('_isTotal', () => {
-    test('is total for add', () => {
-      const group = new GrDiffGroup(GrDiffGroupType.DELTA);
-      for (let idx = 0; idx < 10; idx++) {
-        group.addLine(new GrDiffLine(GrDiffLineType.ADD));
-      }
-      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
-    });
-
-    test('is total for remove', () => {
-      const group = new GrDiffGroup(GrDiffGroupType.DELTA);
-      for (let idx = 0; idx < 10; idx++) {
-        group.addLine(new GrDiffLine(GrDiffLineType.REMOVE));
-      }
-      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
-    });
-
-    test('not total for empty', () => {
-      const group = new GrDiffGroup(GrDiffGroupType.BOTH);
-      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
-    });
-
-    test('not total for non-delta', () => {
-      const group = new GrDiffGroup(GrDiffGroupType.DELTA);
-      for (let idx = 0; idx < 10; idx++) {
-        group.addLine(new GrDiffLine(GrDiffLineType.BOTH));
-      }
-      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
-    });
-  });
-
   suite('intraline differences', () => {
     let el;
     let str;
@@ -693,19 +549,16 @@
   suite('rendering text, images and binary files', () => {
     let processStub;
     let keyLocations;
-    let prefs;
     let content;
 
     setup(() => {
       element = basicFixture.instantiate();
       element.viewMode = 'SIDE_BY_SIDE';
-      processStub = sinon.stub(element.$.processor, 'process')
+      processStub = sinon.stub(element.processor, 'process')
           .returns(Promise.resolve());
       keyLocations = {left: {}, right: {}};
-      prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
+      element.prefs = {
+        ...DEFAULT_PREFS,
         context: -1,
         syntax_highlighting: true,
       };
@@ -722,7 +575,7 @@
 
     test('text', () => {
       element.diff = {content};
-      return element.render(keyLocations, prefs).then(() => {
+      return element.render(keyLocations).then(() => {
         assert.isTrue(processStub.calledOnce);
         assert.isFalse(processStub.lastCall.args[1]);
       });
@@ -731,7 +584,7 @@
     test('image', () => {
       element.diff = {content, binary: true};
       element.isImageDiff = true;
-      return element.render(keyLocations, prefs).then(() => {
+      return element.render(keyLocations).then(() => {
         assert.isTrue(processStub.calledOnce);
         assert.isTrue(processStub.lastCall.args[1]);
       });
@@ -739,7 +592,7 @@
 
     test('binary', () => {
       element.diff = {content, binary: true};
-      return element.render(keyLocations, prefs).then(() => {
+      return element.render(keyLocations).then(() => {
         assert.isTrue(processStub.calledOnce);
         assert.isTrue(processStub.lastCall.args[1]);
       });
@@ -752,13 +605,7 @@
     let keyLocations;
 
     setup(async () => {
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-        syntax_highlighting: true,
-      };
+      const prefs = {...DEFAULT_PREFS};
       content = [
         {
           a: ['all work and no play make andybons a dull boy'],
@@ -772,6 +619,7 @@
         },
       ];
       element = basicFixture.instantiate();
+      sinon.stub(element, 'dispatchEvent');
       outputEl = element.querySelector('#diffTable');
       keyLocations = {left: {}, right: {}};
       sinon.stub(element, '_getDiffBuilder').callsFake(() => {
@@ -786,68 +634,158 @@
         return builder;
       });
       element.diff = {content};
-      await element.render(keyLocations, prefs);
+      element.prefs = prefs;
+      await element.render(keyLocations);
     });
 
-    test('addColumns is called', async () => {
-      await element.render(keyLocations, {});
+    test('addColumns is called', () => {
       assert.isTrue(element._builder.addColumns.called);
     });
 
-    test('getSectionsByLineRange one line', () => {
+    test('getGroupsByLineRange one line', () => {
       const section = outputEl.querySelector('stub:nth-of-type(3)');
-      const sections = element._builder.getSectionsByLineRange(1, 1, 'left');
-      assert.equal(sections.length, 1);
-      assert.strictEqual(sections[0], section);
+      const groups = element._builder.getGroupsByLineRange(1, 1, 'left');
+      assert.equal(groups.length, 1);
+      assert.strictEqual(groups[0].element, section);
     });
 
-    test('getSectionsByLineRange over diff', () => {
+    test('getGroupsByLineRange over diff', () => {
       const section = [
         outputEl.querySelector('stub:nth-of-type(3)'),
         outputEl.querySelector('stub:nth-of-type(4)'),
       ];
-      const sections = element._builder.getSectionsByLineRange(1, 2, 'left');
-      assert.equal(sections.length, 2);
-      assert.strictEqual(sections[0], section[0]);
-      assert.strictEqual(sections[1], section[1]);
+      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 dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-      await element.render(keyLocations, {});
-      const firedEventTypes = dispatchEventStub.getCalls()
+      const firedEventTypes = element.dispatchEvent.getCalls()
           .map(c => c.args[0].type);
       assert.include(firedEventTypes, 'render-start');
       assert.include(firedEventTypes, 'render-content');
     });
 
-    test('cancel', () => {
-      const processorCancelStub = sinon.stub(element.$.processor, 'cancel');
+    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 prefs;
     let keyLocations;
 
     setup(async () => {
       element = mockDiffFixture.instantiate();
-      diff = getMockDiffResponse();
+      diff = createDiff();
       element.diff = diff;
 
-      prefs = {
+      keyLocations = {left: {}, right: {}};
+
+      element.prefs = {
         line_length: 80,
         show_tabs: true,
         tab_size: 4,
       };
-      keyLocations = {left: {}, right: {}};
-
-      await element.render(keyLocations, prefs);
+      await element.render(keyLocations);
       builder = element._builder;
     });
 
@@ -924,13 +862,13 @@
       }
     });
 
-    test('_renderContentByRange', () => {
-      const spy = sinon.spy(builder, '_createTextEl');
+    test('renderContentByRange', () => {
+      const spy = sinon.spy(builder, 'createTextEl');
       const start = 9;
       const end = 14;
       const count = end - start + 1;
 
-      builder._renderContentByRange(start, end, 'left');
+      builder.renderContentByRange(start, end, 'left');
 
       assert.equal(spy.callCount, count);
       spy.getCalls().forEach((call, i) => {
@@ -938,10 +876,10 @@
       });
     });
 
-    test('_renderContentByRange notexistent elements', () => {
-      const spy = sinon.spy(builder, '_createTextEl');
+    test('renderContentByRange non-existent elements', () => {
+      const spy = sinon.spy(builder, 'createTextEl');
 
-      sinon.stub(builder, '_getLineNumberEl').returns(
+      sinon.stub(builder, 'getLineNumberEl').returns(
           document.createElement('div')
       );
       sinon.stub(builder, 'findLinesByRange').callsFake(
@@ -960,82 +898,82 @@
             lines.push(new GrDiffLine(GrDiffLineType.BOTH));
           });
 
-      builder._renderContentByRange(1, 10, 'left');
+      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', () => {
+    test('getLineNumberEl side-by-side left', () => {
       const contentEl = builder.getContentByLine(5, 'left',
           element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
+      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
       assert.isTrue(lineNumberEl.classList.contains('lineNum'));
       assert.isTrue(lineNumberEl.classList.contains('left'));
     });
 
-    test('_getLineNumberEl side-by-side right', () => {
+    test('getLineNumberEl side-by-side right', () => {
       const contentEl = builder.getContentByLine(5, 'right',
           element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
+      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
       assert.isTrue(lineNumberEl.classList.contains('lineNum'));
       assert.isTrue(lineNumberEl.classList.contains('right'));
     });
 
-    test('_getLineNumberEl unified left', async () => {
+    test('getLineNumberEl unified left', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations, prefs);
+      await element.render(keyLocations);
       builder = element._builder;
 
       const contentEl = builder.getContentByLine(5, 'left',
           element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
+      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
       assert.isTrue(lineNumberEl.classList.contains('lineNum'));
       assert.isTrue(lineNumberEl.classList.contains('left'));
     });
 
-    test('_getLineNumberEl unified right', async () => {
+    test('getLineNumberEl unified right', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations, prefs);
+      await element.render(keyLocations);
       builder = element._builder;
 
       const contentEl = builder.getContentByLine(5, 'right',
           element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
+      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
       assert.isTrue(lineNumberEl.classList.contains('lineNum'));
       assert.isTrue(lineNumberEl.classList.contains('right'));
     });
 
-    test('_getNextContentOnSide side-by-side left', () => {
+    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,
+      const nextElem = builder.getNextContentOnSide(startElem,
           'left');
       assert.equal(nextElem.textContent, expectedNextString);
     });
 
-    test('_getNextContentOnSide side-by-side right', () => {
+    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,
+      const nextElem = builder.getNextContentOnSide(startElem,
           'right');
       assert.equal(nextElem.textContent, expectedNextString);
     });
 
-    test('_getNextContentOnSide unified left', async () => {
+    test('getNextContentOnSide unified left', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations, prefs);
+      await element.render(keyLocations);
       builder = element._builder;
 
       const startElem = builder.getContentByLine(5, 'left',
@@ -1044,15 +982,15 @@
       const expectedNextString = diff.content[2].ab[1];
       assert.equal(startElem.textContent, expectedStartString);
 
-      const nextElem = builder._getNextContentOnSide(startElem,
+      const nextElem = builder.getNextContentOnSide(startElem,
           'left');
       assert.equal(nextElem.textContent, expectedNextString);
     });
 
-    test('_getNextContentOnSide unified right', async () => {
+    test('getNextContentOnSide unified right', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations, prefs);
+      await element.render(keyLocations);
       builder = element._builder;
 
       const startElem = builder.getContentByLine(5, 'right',
@@ -1061,22 +999,10 @@
       const expectedNextString = diff.content[1].b[1];
       assert.equal(startElem.textContent, expectedStartString);
 
-      const nextElem = builder._getNextContentOnSide(startElem,
+      const nextElem = builder.getNextContentOnSide(startElem,
           'right');
       assert.equal(nextElem.textContent, expectedNextString);
     });
-
-    test('escaping HTML', () => {
-      let input = '<script>alert("XSS");<' + '/script>';
-      let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
-      let result = builder._formatText(input, 1, Infinity).innerHTML;
-      assert.equal(result, expected);
-
-      input = '& < > " \' / `';
-      expected = '&amp; &lt; &gt; " \' / `';
-      result = builder._formatText(input, 1, Infinity).innerHTML;
-      assert.equal(result, expected);
-    });
   });
 
   suite('blame', () => {
@@ -1090,73 +1016,68 @@
     });
 
     test('setBlame attempts to render each blamed line', () => {
-      const getBlameStub = sinon.stub(builder, '_getBlameByLineNum')
+      const getBlameStub = sinon.stub(builder, 'getBlameTdByLine')
           .returns(null);
       builder.setBlame(mockBlame);
       assert.equal(getBlameStub.callCount, 32);
     });
 
-    test('_getBlameCommitForBaseLine', () => {
-      sinon.stub(builder, '_getBlameByLineNum').returns(null);
+    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(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(11));
+      assert.equal(builder.getBlameCommitForBaseLine(11).id, 'commit 1');
 
-      assert.isOk(builder._getBlameCommitForBaseLine(32));
-      assert.equal(builder._getBlameCommitForBaseLine(32).id, 'commit 2');
+      assert.isOk(builder.getBlameCommitForBaseLine(32));
+      assert.equal(builder.getBlameCommitForBaseLine(32).id, 'commit 2');
 
-      assert.isNull(builder._getBlameCommitForBaseLine(33));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
     });
 
-    test('_getBlameCommitForBaseLine w/o blame returns null', () => {
-      assert.isNull(builder._getBlameCommitForBaseLine(1));
-      assert.isNull(builder._getBlameCommitForBaseLine(11));
-      assert.isNull(builder._getBlameCommitForBaseLine(31));
+    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 mocbBlameCell = document.createElement('span');
-      const getBlameStub = sinon.stub(builder, '_getBlameForBaseLine')
-          .returns(mocbBlameCell);
-      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');
-      assert.equal(result.firstChild, mocbBlameCell);
-    });
-
-    test('_getBlameForBaseLine', () => {
-      const mockCommit = {
-        time: 1576105200,
+    test('createBlameCell', () => {
+      const mockBlameInfo = {
+        time: 1576155200,
         id: 1234567890,
         author: 'Clark Kent',
         commit_msg: 'Testing Commit',
         ranges: [1],
       };
-      const blameNode = builder._getBlameForBaseLine(1, mockCommit);
+      const getBlameStub = sinon.stub(builder, 'getBlameCommitForBaseLine')
+          .returns(mockBlameInfo);
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
 
-      const authors = blameNode.getElementsByClassName('blameAuthor');
-      assert.equal(authors.length, 1);
-      assert.equal(authors[0].innerText, ' Clark');
+      const result = builder.createBlameCell(line.beforeNumber);
 
-      const date = (new Date(mockCommit.time * 1000)).toLocaleDateString();
-      flush();
-      const cards = blameNode.getElementsByClassName('blameHoverCard');
-      assert.equal(cards.length, 1);
-      assert.equal(cards[0].innerHTML,
-          `Commit 1234567890<br>Author: Clark Kent<br>Date: ${date}`
-        + '<br><br>Testing Commit'
-      );
-
-      const url = blameNode.getElementsByClassName('blameDate');
-      assert.equal(url[0].getAttribute('href'), '/r/q/1234567890');
+      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/elements/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
similarity index 75%
rename from polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
index 5629aa4..3bcec06 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -18,10 +18,11 @@
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
 import {ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrEndpointParam} from '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import {GrEndpointParam} from '../../../elements/plugins/gr-endpoint-param/gr-endpoint-param';
 import {RenderPreferences} from '../../../api/diff';
 import '../gr-diff-image-viewer/gr-image-viewer';
 import {GrImageViewer} from '../gr-diff-image-viewer/gr-image-viewer';
+import {createElementDiff} from '../gr-diff/gr-diff-utils';
 
 // MIME types for images we allow showing. Do not include SVG, it can contain
 // arbitrary JavaScript.
@@ -41,30 +42,30 @@
   }
 
   public renderDiff() {
-    const section = this._createElement('tbody', 'image-diff');
+    const section = createElementDiff('tbody', 'image-diff');
 
     if (this._useNewImageDiffUi) {
       this._emitImageViewer(section);
 
-      this._outputEl.appendChild(section);
+      this.outputEl.appendChild(section);
     } else {
       this._emitImagePair(section);
       this._emitImageLabels(section);
 
-      this._outputEl.appendChild(section);
-      this._outputEl.appendChild(this._createEndpoint());
+      this.outputEl.appendChild(section);
+      this.outputEl.appendChild(this._createEndpoint());
     }
   }
 
   private _createEndpoint() {
-    const tbody = this._createElement('tbody');
-    const tr = this._createElement('tr');
-    const td = this._createElement('td');
+    const tbody = createElementDiff('tbody');
+    const tr = createElementDiff('tr');
+    const td = createElementDiff('td');
 
     // TODO(kaspern): Support blame for image diffs and remove the hardcoded 4
     // column limit.
     td.setAttribute('colspan', '4');
-    const endpointDomApi = this._createElement('gr-endpoint-decorator');
+    const endpointDomApi = createElementDiff('gr-endpoint-decorator');
     endpointDomApi.setAttribute('name', 'image-diff');
     endpointDomApi.appendChild(
       this._createEndpointParam('baseImage', this._baseImage)
@@ -79,7 +80,7 @@
   }
 
   private _createEndpointParam(name: string, value: ImageInfo | null) {
-    const endpointParam = this._createElement(
+    const endpointParam = createElementDiff(
       'gr-endpoint-param'
     ) as GrEndpointParam;
     endpointParam.name = name;
@@ -88,16 +89,16 @@
   }
 
   private _emitImageViewer(section: HTMLElement) {
-    const tr = this._createElement('tr');
-    const td = this._createElement('td');
+    const tr = createElementDiff('tr');
+    const td = createElementDiff('td');
     // TODO(hermannloose): Support blame for image diffs, see above.
     td.setAttribute('colspan', '4');
-    const imageViewer = this._createElement('gr-image-viewer') as GrImageViewer;
+    const imageViewer = createElementDiff('gr-image-viewer') as GrImageViewer;
 
     imageViewer.baseUrl = this._getImageSrc(this._baseImage);
     imageViewer.revisionUrl = this._getImageSrc(this._revisionImage);
     imageViewer.automaticBlink =
-      !!this._renderPrefs?.image_diff_prefs?.automatic_blink;
+      !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
 
     td.appendChild(imageViewer);
     tr.appendChild(td);
@@ -111,12 +112,12 @@
   }
 
   private _emitImagePair(section: HTMLElement) {
-    const tr = this._createElement('tr');
+    const tr = createElementDiff('tr');
 
-    tr.appendChild(this._createElement('td', 'left lineNum blank'));
+    tr.appendChild(createElementDiff('td', 'left lineNum blank'));
     tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
 
-    tr.appendChild(this._createElement('td', 'right lineNum blank'));
+    tr.appendChild(createElementDiff('td', 'right lineNum blank'));
     tr.appendChild(
       this._createImageCell(this._revisionImage, 'right', section)
     );
@@ -129,10 +130,10 @@
     className: string,
     section: HTMLElement
   ) {
-    const td = this._createElement('td', className);
+    const td = createElementDiff('td', className);
     const src = this._getImageSrc(image);
     if (image && src) {
-      const imageEl = this._createElement('img') as HTMLImageElement;
+      const imageEl = createElementDiff('img') as HTMLImageElement;
       imageEl.onload = () => {
         image._height = imageEl.naturalHeight;
         image._width = imageEl.naturalWidth;
@@ -164,7 +165,7 @@
   }
 
   private _emitImageLabels(section: HTMLElement) {
-    const tr = this._createElement('tr');
+    const tr = createElementDiff('tr');
 
     let addNamesInLabel = false;
 
@@ -176,17 +177,17 @@
       addNamesInLabel = true;
     }
 
-    tr.appendChild(this._createElement('td', 'left lineNum blank'));
-    let td = this._createElement('td', 'left');
-    let label = this._createElement('label');
+    tr.appendChild(createElementDiff('td', 'left lineNum blank'));
+    let td = createElementDiff('td', 'left');
+    let label = createElementDiff('label');
     let nameSpan;
-    let labelSpan = this._createElement('span', 'label');
+    let labelSpan = createElementDiff('span', 'label');
 
     if (addNamesInLabel) {
-      nameSpan = this._createElement('span', 'name');
+      nameSpan = createElementDiff('span', 'name');
       nameSpan.textContent = this._baseImage?._name ?? '';
       label.appendChild(nameSpan);
-      label.appendChild(this._createElement('br'));
+      label.appendChild(createElementDiff('br'));
     }
 
     this._setLabelText(labelSpan, this._baseImage);
@@ -195,16 +196,16 @@
     td.appendChild(label);
     tr.appendChild(td);
 
-    tr.appendChild(this._createElement('td', 'right lineNum blank'));
-    td = this._createElement('td', 'right');
-    label = this._createElement('label');
-    labelSpan = this._createElement('span', 'label');
+    tr.appendChild(createElementDiff('td', 'right lineNum blank'));
+    td = createElementDiff('td', 'right');
+    label = createElementDiff('label');
+    labelSpan = createElementDiff('span', 'label');
 
     if (addNamesInLabel) {
-      nameSpan = this._createElement('span', 'name');
+      nameSpan = createElementDiff('span', 'name');
       nameSpan.textContent = this._revisionImage?._name ?? '';
       label.appendChild(nameSpan);
-      label.appendChild(this._createElement('br'));
+      label.appendChild(createElementDiff('br'));
     }
 
     this._setLabelText(labelSpan, this._revisionImage);
@@ -217,7 +218,7 @@
   }
 
   override updateRenderPrefs(renderPrefs: RenderPreferences) {
-    const imageViewer = this._outputEl.querySelector(
+    const imageViewer = this.outputEl.querySelector(
       'gr-image-viewer'
     ) as GrImageViewer;
     if (this._useNewImageDiffUi && imageViewer) {
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
new file mode 100644
index 0000000..ceadc94
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
@@ -0,0 +1,505 @@
+/**
+ * @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 {
+  MovedLinkClickedEventDetail,
+  RenderPreferences,
+} from '../../../api/diff';
+import {fire} from '../../../utils/event-util';
+import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import '../gr-context-controls/gr-context-controls';
+import {
+  GrContextControls,
+  GrContextControlsShowConfig,
+} from '../gr-context-controls/gr-context-controls';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffLayer} from '../../../types/types';
+import {
+  createBlameElement,
+  createElementDiff,
+  createElementDiffWithText,
+  formatText,
+  getResponsiveMode,
+} from '../gr-diff/gr-diff-utils';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {BlameInfo} from '../../../types/common';
+
+function lineTdSelector(lineNumber: LineNumber, side?: Side): string {
+  const sideSelector = side ? `.${side}` : '';
+  return `td.lineNum[data-value="${lineNumber}"]${sideSelector}`;
+}
+/**
+ * Base class for builders that are creating the DOM elements programmatically
+ * by calling `document.createElement()` and such. We are calling such builders
+ * "legacy", because we want to create (Lit) component based diff elements.
+ *
+ * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces.
+ */
+export abstract class GrDiffBuilderLegacy extends GrDiffBuilder {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
+  ) {
+    super(diff, prefs, outputEl, layers, renderPrefs);
+  }
+
+  override getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root: Element = this.outputEl
+  ): HTMLTableCellElement | null {
+    return root.querySelector<HTMLTableCellElement>(
+      `${lineTdSelector(lineNumber, side)} ~ td.content`
+    );
+  }
+
+  override getLineElByNumber(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | null {
+    return this.outputEl.querySelector<HTMLTableCellElement>(
+      lineTdSelector(lineNumber, side)
+    );
+  }
+
+  override getLineNumberRows() {
+    return Array.from(
+      this.outputEl.querySelectorAll<HTMLTableRowElement>(
+        ':not(.contextControl) > .diff-row'
+      ) ?? []
+    ).filter(tr => tr.querySelector('button'));
+  }
+
+  override getLineNumEls(side: Side): HTMLTableCellElement[] {
+    return Array.from(
+      this.outputEl.querySelectorAll<HTMLTableCellElement>(
+        `td.lineNum.${side}`
+      ) ?? []
+    );
+  }
+
+  override getBlameTdByLine(lineNum: number): Element | undefined {
+    return (
+      this.outputEl.querySelector(`td.blame[data-line-number="${lineNum}"]`) ??
+      undefined
+    );
+  }
+
+  override getContentByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root?: HTMLElement
+  ): HTMLElement | null {
+    const td = this.getContentTdByLine(lineNumber, side, root);
+    return td ? td.querySelector('.contentText') : null;
+  }
+
+  override renderContentByRange(
+    start: LineNumber,
+    end: LineNumber,
+    side: Side
+  ) {
+    const lines: GrDiffLine[] = [];
+    const elements: HTMLElement[] = [];
+    let line;
+    let el;
+    this.findLinesByRange(start, end, side, lines, elements);
+    for (let i = 0; i < lines.length; i++) {
+      line = lines[i];
+      el = elements[i];
+      if (!el || !el.parentElement) {
+        // Cannot re-render an element if it does not exist. This can happen
+        // if lines are collapsed and not visible on the page yet.
+        continue;
+      }
+      const lineNumberEl = this.getLineNumberEl(el, side);
+      el.parentElement.replaceChild(
+        this.createTextEl(lineNumberEl, line, side).firstChild!,
+        el
+      );
+    }
+  }
+
+  override renderBlameByRange(blame: BlameInfo, start: number, end: number) {
+    for (let i = start; i <= end; i++) {
+      // TODO(wyatta): this query is expensive, but, when traversing a
+      // range, the lines are consecutive, and given the previous blame
+      // cell, the next one can be reached cheaply.
+      const blameCell = this.getBlameTdByLine(i);
+      if (!blameCell) continue;
+
+      // Remove the element's children (if any).
+      while (blameCell.hasChildNodes()) {
+        blameCell.removeChild(blameCell.lastChild!);
+      }
+      const blameEl = createBlameElement(i, blame);
+      if (blameEl) blameCell.appendChild(blameEl);
+    }
+  }
+
+  /**
+   * Finds the line number element given the content element by walking up the
+   * DOM tree to the diff row and then querying for a .lineNum element on the
+   * requested side.
+   *
+   * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
+   */
+  private 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;
+  }
+
+  /**
+   * Adds <tr> table rows to a <tbody> section for allowing the user to expand
+   * collapsed of lines. Called by subclasses.
+   */
+  protected createContextControls(
+    section: HTMLElement,
+    group: GrDiffGroup,
+    viewMode: DiffViewMode
+  ) {
+    const leftStart = group.lineRange.left.start_line;
+    const leftEnd = group.lineRange.left.end_line;
+    const firstGroupIsSkipped = !!group.contextGroups[0].skip;
+    const lastGroupIsSkipped =
+      !!group.contextGroups[group.contextGroups.length - 1].skip;
+
+    const containsWholeFile = this.numLinesLeft === leftEnd - leftStart + 1;
+    const showAbove =
+      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
+    const showBelow = leftEnd < this.numLinesLeft && !lastGroupIsSkipped;
+
+    if (showAbove) {
+      const paddingRow = this.createContextControlPaddingRow(viewMode);
+      paddingRow.classList.add('above');
+      section.appendChild(paddingRow);
+    }
+    section.appendChild(
+      this.createContextControlRow(group, showAbove, showBelow, viewMode)
+    );
+    if (showBelow) {
+      const paddingRow = this.createContextControlPaddingRow(viewMode);
+      paddingRow.classList.add('below');
+      section.appendChild(paddingRow);
+    }
+  }
+
+  /**
+   * Creates a context control <tr> table row for with buttons the allow the
+   * user to expand collapsed lines. Buttons extend from the gap created by this
+   * method up or down into the area of code that they affect.
+   */
+  private createContextControlRow(
+    group: GrDiffGroup,
+    showAbove: boolean,
+    showBelow: boolean,
+    viewMode: DiffViewMode
+  ): HTMLElement {
+    const row = createElementDiff('tr', 'dividerRow');
+    let showConfig: GrContextControlsShowConfig;
+    if (showAbove && !showBelow) {
+      showConfig = 'above';
+    } else if (!showAbove && showBelow) {
+      showConfig = 'below';
+    } else {
+      // Note that !showAbove && !showBelow also intentionally creates
+      // "show-both". This means the file is completely collapsed, which is
+      // unusual, but at least happens in one test.
+      showConfig = 'both';
+    }
+    row.classList.add(`show-${showConfig}`);
+
+    row.appendChild(this.createBlameCell(0));
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(createElementDiff('td'));
+    }
+
+    const cell = createElementDiff('td', 'dividerCell');
+    const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
+    cell.setAttribute('colspan', colspan);
+    row.appendChild(cell);
+
+    const contextControls = createElementDiff(
+      'gr-context-controls'
+    ) as GrContextControls;
+    contextControls.diff = this._diff;
+    contextControls.renderPreferences = this.renderPrefs;
+    contextControls.group = group;
+    contextControls.showConfig = showConfig;
+    cell.appendChild(contextControls);
+    return row;
+  }
+
+  /**
+   * Creates a table row to serve as padding between code and context controls.
+   * Blame column, line gutters, and content area will continue visually, but
+   * context controls can render over this background to map more clearly to
+   * the area of code they expand.
+   */
+  private createContextControlPaddingRow(viewMode: DiffViewMode) {
+    const row = createElementDiff('tr', 'contextBackground');
+
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.classList.add('side-by-side');
+      row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
+      row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
+    } else {
+      row.classList.add('unified');
+    }
+
+    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;
+  }
+
+  protected createLineEl(
+    line: GrDiffLine,
+    number: LineNumber,
+    type: GrDiffLineType,
+    side: Side
+  ) {
+    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) {
+      td.classList.add('lineNum');
+      td.dataset['value'] = number.toString();
+
+      if (
+        ((this._prefs.show_file_comment_button === false ||
+          this.renderPrefs?.show_file_comment_button === false) &&
+          number === 'FILE') ||
+        number === 'LOST'
+      ) {
+        return td;
+      }
+
+      const button = createElementDiff('button');
+      td.appendChild(button);
+      button.tabIndex = -1;
+      button.classList.add('lineNumButton');
+      button.classList.add(side);
+      button.dataset['value'] = number.toString();
+      button.textContent = number === 'FILE' ? 'File' : number.toString();
+      if (number === 'FILE') {
+        button.setAttribute('aria-label', 'Add file comment');
+      }
+
+      // Add aria-labels for valid line numbers.
+      // For unified diff, this method will be called with number set to 0 for
+      // the empty line number column for added/removed lines. This should not
+      // be announced to the screenreader.
+      if (number > 0) {
+        if (line.type === GrDiffLineType.REMOVE) {
+          button.setAttribute('aria-label', `${number} removed`);
+        } else if (line.type === GrDiffLineType.ADD) {
+          button.setAttribute('aria-label', `${number} added`);
+        }
+      }
+      this.addLineNumberMouseEvents(td, number, side);
+    }
+    return td;
+  }
+
+  private addLineNumberMouseEvents(
+    el: HTMLElement,
+    number: LineNumber,
+    side: Side
+  ) {
+    el.addEventListener('mouseenter', () => {
+      fire(el, 'line-mouse-enter', {lineNum: number, side});
+    });
+    el.addEventListener('mouseleave', () => {
+      fire(el, 'line-mouse-leave', {lineNum: number, side});
+    });
+  }
+
+  protected createTextEl(
+    lineNumberEl: HTMLElement | null,
+    line: GrDiffLine,
+    side?: Side
+  ) {
+    const td = createElementDiff('td');
+    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
+    if (!line.hasIntralineInfo) {
+      td.classList.add('no-intraline-info');
+    }
+    td.classList.add(line.type);
+
+    const {beforeNumber, afterNumber} = line;
+    if (beforeNumber !== 'FILE' && beforeNumber !== 'LOST') {
+      const responsiveMode = getResponsiveMode(this._prefs, this.renderPrefs);
+      const contentText = formatText(
+        line.text,
+        responsiveMode,
+        this._prefs.tab_size,
+        this._prefs.line_length
+      );
+
+      if (side) {
+        contentText.setAttribute('data-side', side);
+        const number = side === Side.LEFT ? beforeNumber : afterNumber;
+        this.addLineNumberMouseEvents(td, number, side);
+      }
+
+      if (lineNumberEl && side) {
+        for (const layer of this.layers) {
+          if (typeof layer.annotate === 'function') {
+            layer.annotate(contentText, lineNumberEl, line, side);
+          }
+        }
+      } else {
+        console.error('lineNumberEl or side not set, skipping layer.annotate');
+      }
+
+      td.appendChild(contentText);
+    } else if (line.beforeNumber === 'FILE') td.classList.add('file');
+    else if (line.beforeNumber === 'LOST') td.classList.add('lost');
+
+    return td;
+  }
+
+  private createMovedLineAnchor(line: number, side: Side) {
+    const anchor = createElementDiffWithText('a', `${line}`);
+
+    // href is not actually used but important for Screen Readers
+    anchor.setAttribute('href', `#${line}`);
+    anchor.addEventListener('click', e => {
+      e.preventDefault();
+      anchor.dispatchEvent(
+        new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
+          detail: {
+            lineNum: line,
+            side,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    });
+    return anchor;
+  }
+
+  private createMoveDescriptionDiv(movedIn: boolean, group: GrDiffGroup) {
+    const div = createElementDiff('div');
+    if (group.moveDetails?.range) {
+      const {changed, range} = group.moveDetails;
+      const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
+      const andChangedLabel = changed ? 'and changed ' : '';
+      const direction = movedIn ? 'from' : 'to';
+      const textLabel = `Moved ${andChangedLabel}${direction} lines `;
+      div.appendChild(createElementDiffWithText('span', textLabel));
+      div.appendChild(this.createMovedLineAnchor(range.start, otherSide));
+      div.appendChild(createElementDiffWithText('span', ' - '));
+      div.appendChild(this.createMovedLineAnchor(range.end, otherSide));
+    } else {
+      div.appendChild(
+        createElementDiffWithText('span', movedIn ? 'Moved in' : 'Moved out')
+      );
+    }
+    return div;
+  }
+
+  protected buildMoveControls(group: GrDiffGroup) {
+    const movedIn = group.adds.length > 0;
+    const {
+      numberOfCells,
+      movedOutIndex,
+      movedInIndex,
+      lineNumberCols,
+      signCols,
+    } = this.getMoveControlsConfig();
+
+    let controlsClass;
+    let descriptionIndex;
+    const descriptionTextDiv = this.createMoveDescriptionDiv(movedIn, group);
+    if (movedIn) {
+      controlsClass = 'movedIn';
+      descriptionIndex = movedInIndex;
+    } else {
+      controlsClass = 'movedOut';
+      descriptionIndex = movedOutIndex;
+    }
+
+    const controls = createElementDiff('tr', `moveControls ${controlsClass}`);
+    const cells = [...Array(numberOfCells).keys()].map(() =>
+      createElementDiff('td')
+    );
+    lineNumberCols.forEach(index => {
+      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);
+    cells[descriptionIndex].classList.add('moveHeader');
+    cells[descriptionIndex].appendChild(moveRangeHeader);
+    cells.forEach(c => {
+      controls.appendChild(c);
+    });
+    return controls;
+  }
+
+  /**
+   * Create a blame cell for the given base line. Blame information will be
+   * included in the cell if available.
+   */
+  protected createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
+    const blameTd = createElementDiff('td', 'blame') as HTMLTableCellElement;
+    blameTd.setAttribute('data-line-number', lineNumber.toString());
+    if (!lineNumber) return blameTd;
+
+    const blameInfo = this.getBlameCommitForBaseLine(lineNumber);
+    if (!blameInfo) return blameTd;
+
+    blameTd.appendChild(createBlameElement(lineNumber, blameInfo));
+    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
new file mode 100644
index 0000000..a711215
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -0,0 +1,163 @@
+/**
+ * @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 {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+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';
+import {createElementDiff} from '../gr-diff/gr-diff-utils';
+import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
+
+export class GrDiffBuilderSideBySide extends GrDiffBuilderLegacy {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
+  ) {
+    super(diff, prefs, outputEl, layers, renderPrefs);
+  }
+
+  protected override getMoveControlsConfig() {
+    return {
+      numberOfCells: 6,
+      movedOutIndex: 2,
+      movedInIndex: 5,
+      lineNumberCols: [0, 3],
+      signCols: {left: 1, right: 4},
+    };
+  }
+
+  protected override buildSectionElement(group: GrDiffGroup) {
+    const sectionEl = createElementDiff('tbody', 'section');
+    sectionEl.classList.add(group.type);
+    if (group.isTotal()) {
+      sectionEl.classList.add('total');
+    }
+    if (group.dueToRebase) {
+      sectionEl.classList.add('dueToRebase');
+    }
+    if (group.moveDetails) {
+      sectionEl.classList.add('dueToMove');
+      sectionEl.appendChild(this.buildMoveControls(group));
+    }
+    if (group.ignoredWhitespaceOnly) {
+      sectionEl.classList.add('ignoredWhitespaceOnly');
+    }
+    if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
+      this.createContextControls(sectionEl, group, DiffViewMode.SIDE_BY_SIDE);
+      return sectionEl;
+    }
+
+    const pairs = group.getSideBySidePairs();
+    for (let i = 0; i < pairs.length; i++) {
+      sectionEl.appendChild(this.createRow(pairs[i].left, pairs[i].right));
+    }
+    return sectionEl;
+  }
+
+  override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
+    const colgroup = document.createElement('colgroup');
+
+    // Add the blame column.
+    let col = createElementDiff('col', 'blame');
+    colgroup.appendChild(col);
+
+    // Add left-side line number.
+    col = createElementDiff('col', 'left');
+    col.setAttribute('width', lineNumberWidth.toString());
+    colgroup.appendChild(col);
+
+    colgroup.appendChild(createElementDiff('col', 'sign left'));
+
+    // Add left-side content.
+    colgroup.appendChild(createElementDiff('col', 'left'));
+
+    // Add right-side line number.
+    col = document.createElement('col');
+    col.setAttribute('width', lineNumberWidth.toString());
+    colgroup.appendChild(col);
+
+    colgroup.appendChild(createElementDiff('col', 'sign right'));
+
+    // Add right-side content.
+    colgroup.appendChild(document.createElement('col'));
+
+    outputEl.appendChild(colgroup);
+  }
+
+  private createRow(leftLine: GrDiffLine, rightLine: GrDiffLine) {
+    const row = createElementDiff('tr');
+    row.classList.add('diff-row', 'side-by-side');
+    row.setAttribute('left-type', leftLine.type);
+    row.setAttribute('right-type', rightLine.type);
+    // TabIndex makes screen reader read a row when navigating with j/k
+    row.tabIndex = -1;
+
+    row.appendChild(this.createBlameCell(leftLine.beforeNumber));
+
+    this.appendPair(row, leftLine, leftLine.beforeNumber, Side.LEFT);
+    this.appendPair(row, rightLine, rightLine.afterNumber, Side.RIGHT);
+    return row;
+  }
+
+  private appendPair(
+    row: HTMLElement,
+    line: GrDiffLine,
+    lineNumber: LineNumber,
+    side: Side
+  ) {
+    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));
+  }
+
+  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;
+  }
+
+  protected override getNextContentOnSide(
+    content: HTMLElement,
+    side: Side
+  ): HTMLElement | null {
+    let tr: HTMLElement = content.parentElement!.parentElement!;
+    while ((tr = tr.nextSibling as HTMLElement)) {
+      const nextContent = tr.querySelector(
+        'td.content .contentText[data-side="' + side + '"]'
+      );
+      if (nextContent) return nextContent as HTMLElement;
+    }
+    return null;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
similarity index 77%
rename from polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
index a7b3a42..4145485 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -15,14 +15,15 @@
  * limitations under the License.
  */
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {GrDiffBuilder} from './gr-diff-builder';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
 import {RenderPreferences} from '../../../api/diff';
+import {createElementDiff} from '../gr-diff/gr-diff-utils';
+import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
 
-export class GrDiffBuilderUnified extends GrDiffBuilder {
+export class GrDiffBuilderUnified extends GrDiffBuilderLegacy {
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
@@ -33,7 +34,7 @@
     super(diff, prefs, outputEl, layers, renderPrefs);
   }
 
-  _getMoveControlsConfig() {
+  protected override getMoveControlsConfig() {
     return {
       numberOfCells: 3,
       movedOutIndex: 2,
@@ -42,10 +43,10 @@
     };
   }
 
-  buildSectionElement(group: GrDiffGroup): HTMLElement {
-    const sectionEl = this._createElement('tbody', 'section');
+  protected override buildSectionElement(group: GrDiffGroup): HTMLElement {
+    const sectionEl = createElementDiff('tbody', 'section');
     sectionEl.classList.add(group.type);
-    if (this._isTotal(group)) {
+    if (group.isTotal()) {
       sectionEl.classList.add('total');
     }
     if (group.dueToRebase) {
@@ -53,17 +54,13 @@
     }
     if (group.moveDetails) {
       sectionEl.classList.add('dueToMove');
-      sectionEl.appendChild(this._buildMoveControls(group));
+      sectionEl.appendChild(this.buildMoveControls(group));
     }
     if (group.ignoredWhitespaceOnly) {
       sectionEl.classList.add('ignoredWhitespaceOnly');
     }
     if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
-      this._createContextControls(
-        sectionEl,
-        group.contextGroups,
-        DiffViewMode.UNIFIED
-      );
+      this.createContextControls(sectionEl, group, DiffViewMode.UNIFIED);
       return sectionEl;
     }
 
@@ -74,16 +71,16 @@
       if (group.ignoredWhitespaceOnly && line.type === GrDiffLineType.REMOVE) {
         continue;
       }
-      sectionEl.appendChild(this._createRow(line));
+      sectionEl.appendChild(this.createRow(line));
     }
     return sectionEl;
   }
 
-  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
+  override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
     const colgroup = document.createElement('colgroup');
 
     // Add the blame column.
-    let col = this._createElement('col', 'blame');
+    let col = createElementDiff('col', 'blame');
     colgroup.appendChild(col);
 
     // Add left-side line number.
@@ -102,20 +99,20 @@
     outputEl.appendChild(colgroup);
   }
 
-  _createRow(line: GrDiffLine) {
-    const row = this._createElement('tr', line.type);
+  protected createRow(line: GrDiffLine) {
+    const row = createElementDiff('tr', line.type);
     row.classList.add('diff-row', 'unified');
     // TabIndex makes screen reader read a row when navigating with j/k
     row.tabIndex = -1;
-    row.appendChild(this._createBlameCell(line.beforeNumber));
-    let lineNumberEl = this._createLineEl(
+    row.appendChild(this.createBlameCell(line.beforeNumber));
+    let lineNumberEl = this.createLineEl(
       line,
       line.beforeNumber,
       GrDiffLineType.REMOVE,
       Side.LEFT
     );
     row.appendChild(lineNumberEl);
-    lineNumberEl = this._createLineEl(
+    lineNumberEl = this.createLineEl(
       line,
       line.afterNumber,
       GrDiffLineType.ADD,
@@ -129,11 +126,11 @@
     if (line.type === GrDiffLineType.REMOVE) {
       side = Side.LEFT;
     }
-    row.appendChild(this._createTextEl(lineNumberEl, line, side));
+    row.appendChild(this.createTextEl(lineNumberEl, line, side));
     return row;
   }
 
-  _getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
+  getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
     let tr: HTMLElement = content.parentElement!.parentElement!;
     while ((tr = tr.nextSibling as HTMLElement)) {
       if (
@@ -146,6 +143,4 @@
     }
     return null;
   }
-
-  override updateRenderPrefs(_renderPrefs: RenderPreferences) {}
 }
diff --git a/polygerrit-ui/app/elements/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
similarity index 96%
rename from polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.js
index 2be85c3..5f3fb72 100644
--- a/polygerrit-ui/app/elements/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
@@ -52,7 +52,7 @@
       lines[1].text = '  print "Hello World";';
       lines[2].text = '  return True';
 
-      group = new GrDiffGroup(GrDiffGroupType.BOTH, lines);
+      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
     });
 
     test('creates the section', () => {
@@ -104,7 +104,7 @@
       ];
       lines[0].text = 'def hello_world():';
       lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
       group.moveDetails = {changed: false};
 
       const sectionEl = diffBuilder.buildSectionElement(group);
@@ -127,7 +127,7 @@
       ];
       lines[0].text = 'def hello_world():';
       lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
       group.moveDetails = {changed: false};
 
       const sectionEl = diffBuilder.buildSectionElement(group);
@@ -160,7 +160,7 @@
       lines[2].text = 'def hello_universe()';
       lines[3].text = '  print "Hello Universe"';
 
-      group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
+      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
     });
 
     test('creates the section', () => {
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
new file mode 100644
index 0000000..add7ffa
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -0,0 +1,373 @@
+/**
+ * @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 {
+  ContentLoadNeededEventDetail,
+  DiffContextExpandedExternalDetail,
+  RenderPreferences,
+} from '../../../api/diff';
+import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+
+import '../gr-context-controls/gr-context-controls';
+import {BlameInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
+import {DiffLayer} from '../../../types/types';
+
+export interface DiffContextExpandedEventDetail
+  extends DiffContextExpandedExternalDetail {
+  /** The context control group that should be replaced by `groups`. */
+  contextGroup: GrDiffGroup;
+  groups: GrDiffGroup[];
+  numLines: number;
+}
+
+declare global {
+  interface HTMLElementEventMap {
+    'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
+    'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
+  }
+}
+
+/**
+ * Given that GrDiffBuilder has ~1,000 lines of code, this interface is just
+ * making refactorings easier by emphasizing what the public facing "contract"
+ * of this class is. There are no plans for adding separate implementations.
+ */
+export interface DiffBuilder {
+  clear(): void;
+  addGroups(groups: readonly GrDiffGroup[]): void;
+  clearGroups(): void;
+  replaceGroup(
+    contextControl: GrDiffGroup,
+    groups: readonly GrDiffGroup[]
+  ): void;
+  findGroup(side: Side, line: LineNumber): GrDiffGroup | undefined;
+  addColumns(outputEl: HTMLElement, fontSize: number): void;
+  // TODO: Change `null` to `undefined`.
+  getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root?: Element
+  ): HTMLTableCellElement | null;
+  getLineElByNumber(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | null;
+  getLineNumberRows(): HTMLTableRowElement[];
+  getLineNumEls(side: Side): HTMLTableCellElement[];
+  setBlame(blame: BlameInfo[]): void;
+  updateRenderPrefs(renderPrefs: RenderPreferences): void;
+}
+
+/**
+ * Base class for different diff builders, like side-by-side, unified etc.
+ *
+ * The builder takes GrDiffGroups, and builds the corresponding DOM elements,
+ * called sections. Only the builder should add or remove sections from the
+ * DOM. Callers can use the ...group() methods to modify groups and thus cause
+ * rendering changes.
+ *
+ * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces.
+ */
+export abstract class GrDiffBuilder implements DiffBuilder {
+  protected readonly _diff: DiffInfo;
+
+  protected readonly numLinesLeft: number;
+
+  protected readonly _prefs: DiffPreferencesInfo;
+
+  protected readonly renderPrefs?: RenderPreferences;
+
+  protected readonly outputEl: HTMLElement;
+
+  protected groups: GrDiffGroup[];
+
+  private blameInfo: BlameInfo[] = [];
+
+  private readonly layerUpdateListener: (
+    start: LineNumber,
+    end: LineNumber,
+    side: Side
+  ) => void;
+
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    readonly layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
+  ) {
+    this._diff = diff;
+    this.numLinesLeft = this._diff.content
+      ? this._diff.content.reduce((sum, chunk) => {
+          const left = chunk.a || chunk.ab;
+          return sum + (left?.length || chunk.skip || 0);
+        }, 0)
+      : 0;
+    this._prefs = prefs;
+    this.renderPrefs = renderPrefs;
+    this.outputEl = outputEl;
+    this.groups = [];
+
+    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+      throw Error('Invalid tab size from preferences.');
+    }
+
+    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+      throw Error('Invalid line length from preferences.');
+    }
+
+    this.layerUpdateListener = (
+      start: LineNumber,
+      end: LineNumber,
+      side: Side
+    ) => this.renderContentByRange(start, end, side);
+    for (const layer of this.layers) {
+      if (layer.addListener) {
+        layer.addListener(this.layerUpdateListener);
+      }
+    }
+  }
+
+  clear() {
+    for (const layer of this.layers) {
+      if (layer.removeListener) {
+        layer.removeListener(this.layerUpdateListener);
+      }
+    }
+  }
+
+  abstract addColumns(outputEl: HTMLElement, fontSize: number): void;
+
+  protected abstract buildSectionElement(group: GrDiffGroup): HTMLElement;
+
+  addGroups(groups: readonly GrDiffGroup[]) {
+    for (const group of groups) {
+      this.groups.push(group);
+      this.emitGroup(group);
+    }
+  }
+
+  clearGroups() {
+    for (const deletedGroup of this.groups) {
+      deletedGroup.element?.remove();
+    }
+    this.groups = [];
+  }
+
+  replaceGroup(contextControl: GrDiffGroup, groups: readonly GrDiffGroup[]) {
+    const i = this.groups.indexOf(contextControl);
+    if (i === -1) throw new Error('cannot find context control group');
+
+    const contextControlSection = this.groups[i].element;
+    if (!contextControlSection) throw new Error('diff group element not set');
+
+    this.groups.splice(i, 1, ...groups);
+    for (const group of groups) {
+      this.emitGroup(group, contextControlSection);
+    }
+    if (contextControlSection) contextControlSection.remove();
+  }
+
+  findGroup(side: Side, line: LineNumber) {
+    return this.groups.find(group => group.containsLine(side, line));
+  }
+
+  private emitGroup(group: GrDiffGroup, beforeSection?: HTMLElement) {
+    const element = this.buildSectionElement(group);
+    this.outputEl.insertBefore(element, beforeSection ?? null);
+    group.element = element;
+  }
+
+  private getGroupsByLineRange(
+    startLine: LineNumber,
+    endLine: LineNumber,
+    side: Side
+  ): GrDiffGroup[] {
+    const startIndex = this.groups.findIndex(group =>
+      group.containsLine(side, startLine)
+    );
+    if (startIndex === -1) return [];
+    let endIndex = this.groups.findIndex(group =>
+      group.containsLine(side, endLine)
+    );
+    // Not all groups may have been processed yet (i.e. this.groups is still
+    // incomplete). In that case let's just return *all* groups until the end
+    // of the array.
+    if (endIndex === -1) endIndex = this.groups.length - 1;
+    // The filter preserves the legacy behavior to only return non-context
+    // groups
+    return this.groups
+      .slice(startIndex, endIndex + 1)
+      .filter(group => group.lines.length > 0);
+  }
+
+  // TODO: Change `null` to `undefined`.
+  abstract getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root?: Element
+  ): HTMLTableCellElement | null;
+
+  // TODO: Change `null` to `undefined`.
+  abstract getLineElByNumber(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | null;
+
+  abstract getLineNumberRows(): HTMLTableRowElement[];
+
+  abstract getLineNumEls(side: Side): HTMLTableCellElement[];
+
+  protected abstract getBlameTdByLine(lineNum: number): Element | undefined;
+
+  // TODO: Change `null` to `undefined`.
+  protected abstract getContentByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root?: HTMLElement
+  ): HTMLElement | null;
+
+  /**
+   * Find line elements or line objects by a range of line numbers and a side.
+   *
+   * @param start The first line number
+   * @param end The last line number
+   * @param side The side of the range. Either 'left' or 'right'.
+   * @param out_lines The output list of line objects. Use null if not desired.
+   *        TODO: Change `null` to `undefined` in paramete type. Also: Do we
+   *        really need to support null/undefined? Also change to camelCase.
+   * @param out_elements The output list of line elements. Use null if not
+   *        desired.
+   *        TODO: Change `null` to `undefined` in paramete type. Also: Do we
+   *        really need to support null/undefined? Also change to camelCase.
+   */
+  protected findLinesByRange(
+    start: LineNumber,
+    end: LineNumber,
+    side: Side,
+    out_lines: GrDiffLine[] | null,
+    out_elements: HTMLElement[] | null
+  ) {
+    const groups = this.getGroupsByLineRange(start, end, side);
+    for (const group of groups) {
+      let content: HTMLElement | null = null;
+      for (const line of group.lines) {
+        if (
+          (side === 'left' && line.type === GrDiffLineType.ADD) ||
+          (side === 'right' && line.type === GrDiffLineType.REMOVE)
+        ) {
+          continue;
+        }
+        const lineNumber =
+          side === 'left' ? line.beforeNumber : line.afterNumber;
+        if (lineNumber < start || lineNumber > end) {
+          continue;
+        }
+
+        if (out_lines) {
+          out_lines.push(line);
+        }
+        if (out_elements) {
+          if (content) {
+            content = this.getNextContentOnSide(content, side);
+          } else {
+            content = this.getContentByLine(lineNumber, side, group.element);
+          }
+          if (content) {
+            out_elements.push(content);
+          }
+        }
+      }
+    }
+  }
+
+  protected abstract renderContentByRange(
+    start: LineNumber,
+    end: LineNumber,
+    side: Side
+  ): void;
+
+  protected abstract renderBlameByRange(
+    blame: BlameInfo,
+    start: number,
+    end: number
+  ): void;
+
+  /**
+   * Finds the next DIV.contentText element following the given element, and on
+   * the same side. Will only search within a group.
+   *
+   * TODO: Change `null` to `undefined`.
+   */
+  protected abstract getNextContentOnSide(
+    content: HTMLElement,
+    side: Side
+  ): HTMLElement | null;
+
+  /**
+   * Gets configuration for creating move controls for chunks marked with
+   * dueToMove
+   */
+  protected abstract getMoveControlsConfig(): {
+    numberOfCells: number;
+    movedOutIndex: number;
+    movedInIndex: number;
+    lineNumberCols: number[];
+    signCols?: {left: number; right: number};
+  };
+
+  /**
+   * Set the blame information for the diff. For any already-rendered line,
+   * re-render its blame cell content.
+   */
+  setBlame(blame: BlameInfo[]) {
+    this.blameInfo = blame;
+    for (const commit of blame) {
+      for (const range of commit.ranges) {
+        this.renderBlameByRange(commit, range.start, range.end);
+      }
+    }
+  }
+
+  /**
+   * Given a base line number, return the commit containing that line in the
+   * current set of blame information. If no blame information has been
+   * provided, null is returned.
+   *
+   * @return The commit information.
+   */
+  protected getBlameCommitForBaseLine(
+    lineNum: LineNumber
+  ): BlameInfo | undefined {
+    for (const blameCommit of this.blameInfo) {
+      for (const range of blameCommit.ranges) {
+        if (range.start <= lineNum && range.end >= lineNum) {
+          return blameCommit;
+        }
+      }
+    }
+    return undefined;
+  }
+
+  /**
+   * Only special builders need to implement this. The default is to
+   * just ignore it.
+   */
+  updateRenderPrefs(_: RenderPreferences) {}
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
similarity index 87%
rename from polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
index de7d007..0a60bdb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
@@ -20,11 +20,7 @@
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 
-import {
-  getLineElByChild,
-  getSideByLineEl,
-  getPreviousContentNodes,
-} from '../gr-diff/gr-diff-utils';
+import {getLineElByChild, getSideByLineEl} from '../gr-diff/gr-diff-utils';
 
 import {
   getLineNumberByChild,
@@ -39,6 +35,15 @@
 /** CSS class for the currently hovered token. */
 const CSS_HIGHLIGHT = 'token-highlight';
 
+/** CSS class marking which text value each token corresponds */
+const TOKEN_TEXT_PREFIX = 'tk-text-';
+
+/**
+ * CSS class marking which index (column) where token starts within a line of code.
+ * The assumption is that we can only have a single token per column start per line.
+ */
+const TOKEN_INDEX_PREFIX = 'tk-index-';
+
 export const HOVER_DELAY_MS = 200;
 
 const LINE_LENGTH_LIMIT = 500;
@@ -137,10 +142,19 @@
       // with super long tokens. Let's guard against this scenario.
       if (length > TOKEN_LENGTH_LIMIT) continue;
       atLeastOneTokenMatched = true;
-      const css = token === this.currentHighlight ? CSS_HIGHLIGHT : CSS_TOKEN;
-      // We add the tk-* class so that we can look up the token later easily
+      const highlightTypeClass =
+        token === this.currentHighlight ? CSS_HIGHLIGHT : CSS_TOKEN;
+      const textClass = `${TOKEN_TEXT_PREFIX}${token}`;
+      const indexClass = `${TOKEN_INDEX_PREFIX}${index}`;
+      // We add the TOKEN_TEXT_PREFIX class so that we can look up the token later easily
       // even if the token element was split up into multiple smaller nodes.
-      GrAnnotation.annotateElement(el, index, length, `tk-${token} ${css}`);
+      // All parts of a single token will share a common TOKEN_INDEX_PREFIX class within the line of code.
+      GrAnnotation.annotateElement(
+        el,
+        index,
+        length,
+        `${textClass} ${indexClass} ${highlightTypeClass}`
+      );
       // We could try to detect whether we are re-rendering instead of initially
       // rendering the line. Then we would not have to call storeLineForToken()
       // again. But since the Set swallows the duplicates we don't care.
@@ -186,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();
     }
   }
 
@@ -198,7 +212,6 @@
       element,
     } = this.findTokenAncestor(e?.target);
     if (!newHighlight || newHighlight === this.currentHighlight) return;
-    if (this.countOccurrences(newHighlight) <= 1) return;
     this.hoveredElement = element;
     this.updateTokenTask = debounce(
       this.updateTokenTask,
@@ -236,24 +249,23 @@
       el.classList.contains(CSS_TOKEN) ||
       el.classList.contains(CSS_HIGHLIGHT)
     ) {
-      const tkClass = [...el.classList].find(c => c.startsWith('tk-'));
+      const tkTextClass = [...el.classList].find(c =>
+        c.startsWith(TOKEN_TEXT_PREFIX)
+      );
       const line = lineNumberToNumber(getLineNumberByChild(el));
-      if (!line || !tkClass)
+      if (!line || !tkTextClass)
         return {line: 0, token: undefined, element: undefined};
-      return {line, token: tkClass.substring(3), element: el};
+      return {
+        line,
+        token: tkTextClass.substring(TOKEN_TEXT_PREFIX.length),
+        element: el,
+      };
     }
     if (el.tagName === 'TD')
       return {line: 0, token: undefined, element: undefined};
     return this.findTokenAncestor(el.parentElement);
   }
 
-  countOccurrences(token: string | undefined) {
-    if (!token) return 0;
-    const linesLeft = this.tokenToLinesLeft.get(token);
-    const linesRight = this.tokenToLinesRight.get(token);
-    return (linesLeft?.size ?? 0) + (linesRight?.size ?? 0);
-  }
-
   private updateTokenHighlight(
     newHighlight: string | undefined,
     newLineNumber: number,
@@ -289,17 +301,19 @@
       this.tokenHighlightListener(undefined);
       return;
     }
-    const previousTextLength = getPreviousContentNodes(element)
-      .map(sib => sib.textContent!.length)
-      .reduce((partial_sum, a) => partial_sum + a, 0);
     const lineEl = getLineElByChild(element);
     assertIsDefined(lineEl, 'Line element should be found!');
+    const tokenIndexStr = [...element.classList]
+      .find(c => c.startsWith(TOKEN_INDEX_PREFIX))
+      ?.substring(TOKEN_INDEX_PREFIX.length);
+    assertIsDefined(tokenIndexStr, 'Index class should be found!');
+    const index = Number(tokenIndexStr);
     const side = getSideByLineEl(lineEl);
     const range = {
       start_line: line,
-      start_column: previousTextLength + 1, // 1-based inclusive
+      start_column: index + 1, // 1-based inclusive
       end_line: line,
-      end_column: previousTextLength + token.length, // 1-based inclusive
+      end_column: index + token.length, // 1-based inclusive
     };
     this.tokenHighlightListener({token, element, side, range});
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
similarity index 85%
rename from polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
index 2993d35..384f173 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -138,14 +138,26 @@
       const el = createLine('these are words');
       annotate(el);
       assert.isTrue(annotateElementStub.calledThrice);
-      assertAnnotation(annotateElementStub.args[0], el, 0, 5, 'tk-these token');
-      assertAnnotation(annotateElementStub.args[1], el, 6, 3, 'tk-are token');
+      assertAnnotation(
+        annotateElementStub.args[0],
+        el,
+        0,
+        5,
+        'tk-text-these tk-index-0 token'
+      );
+      assertAnnotation(
+        annotateElementStub.args[1],
+        el,
+        6,
+        3,
+        'tk-text-are tk-index-6 token'
+      );
       assertAnnotation(
         annotateElementStub.args[2],
         el,
         10,
         5,
-        'tk-words token'
+        'tk-text-words tk-index-10 token'
       );
     });
 
@@ -187,7 +199,7 @@
       annotate(line1);
       const line2 = createLine('three words');
       annotate(line2, Side.RIGHT, 2);
-      const words1 = queryAndAssert(line1, '.tk-words');
+      const words1 = queryAndAssert(line1, '.tk-text-words');
       assert.isTrue(words1.classList.contains('token'));
       dispatchMouseEvent(
         'mouseover',
@@ -217,7 +229,7 @@
       annotate(line1);
       const line2 = createLine('three words');
       annotate(line2, Side.RIGHT, 1000);
-      const words1 = queryAndAssert(line1, '.tk-words');
+      const words1 = queryAndAssert(line1, '.tk-text-words');
       assert.isTrue(words1.classList.contains('token'));
       dispatchMouseEvent(
         'mouseover',
@@ -241,7 +253,7 @@
       annotate(line1);
       const line2 = createLine('three words', 2);
       annotate(line2, Side.RIGHT, 2);
-      const words1 = queryAndAssert(line1, '.tk-words');
+      const words1 = queryAndAssert(line1, '.tk-text-words');
       assert.isTrue(words1.classList.contains('token'));
       dispatchMouseEvent(
         'mouseover',
@@ -268,7 +280,7 @@
       annotate(line1);
       const line2 = createLine('three words', 2);
       annotate(line2, Side.RIGHT, 2);
-      const words1 = queryAndAssert(line1, '.tk-words');
+      const words1 = queryAndAssert(line1, '.tk-text-words');
       assert.isTrue(words1.classList.contains('token'));
       dispatchMouseEvent(
         'mouseover',
@@ -290,13 +302,44 @@
       assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
     });
 
+    test('triggers listener on token with single occurrence', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('a tokenWithSingleOccurence');
+      const line2 = createLine('can be highlighted', 2);
+      annotate(line1);
+      annotate(line2, Side.RIGHT, 2);
+      const tokenNode = queryAndAssert(
+        line1,
+        '.tk-text-tokenWithSingleOccurence'
+      );
+      assert.isTrue(tokenNode.classList.contains('token'));
+      dispatchMouseEvent(
+        'mouseover',
+        MockInteractions.middleOfNode(tokenNode),
+        tokenNode
+      );
+      assert.equal(tokenHighlightingCalls.length, 0);
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(tokenHighlightingCalls.length, 1);
+      assert.deepEqual(tokenHighlightingCalls[0].details, {
+        token: 'tokenWithSingleOccurence',
+        side: Side.RIGHT,
+        element: tokenNode,
+        range: {start_line: 1, start_column: 3, end_line: 1, end_column: 26},
+      });
+
+      MockInteractions.click(container);
+      assert.equal(tokenHighlightingCalls.length, 2);
+      assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
+    });
+
     test('clicking clears highlight', async () => {
       const clock = sinon.useFakeTimers();
       const line1 = createLine('two words');
       annotate(line1);
       const line2 = createLine('three words', 2);
       annotate(line2, Side.RIGHT, 2);
-      const words1 = queryAndAssert(line1, '.tk-words');
+      const words1 = queryAndAssert(line1, '.tk-text-words');
       assert.isTrue(words1.classList.contains('token'));
       dispatchMouseEvent(
         'mouseover',
@@ -320,7 +363,7 @@
       annotate(line1);
       const line2 = createLine('three words', 2);
       annotate(line2, Side.RIGHT, 2);
-      const words1 = queryAndAssert(line1, '.tk-words');
+      const words1 = queryAndAssert(line1, '.tk-text-words');
       assert.isTrue(words1.classList.contains('token'));
       dispatchMouseEvent(
         'mouseover',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
similarity index 96%
rename from polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index 958f367..5e2dec0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -29,7 +29,7 @@
 import {
   GrCursorManager,
   isTargetable,
-} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+} from '../../../elements/shared/gr-cursor-manager/gr-cursor-manager';
 import {GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
@@ -125,9 +125,9 @@
   }
 
   dispose() {
+    this.cursorManager.unsetCursor();
     if (this.targetSubscription) this.targetSubscription.unsubscribe();
     window.removeEventListener('scroll', this._boundHandleWindowScroll);
-    this.cursorManager.unsetCursor();
   }
 
   // Don't remove - used by clients embedding gr-diff outside of Gerrit.
@@ -222,11 +222,16 @@
     return result;
   }
 
-  moveToLineNumber(number: number, side: Side, path?: string) {
+  moveToLineNumber(
+    number: number,
+    side: Side,
+    path?: string,
+    intentionalMove?: boolean
+  ) {
     const row = this._findRowByNumberAndFile(number, side, path);
     if (row) {
       this.side = side;
-      this.cursorManager.setCursor(row);
+      this.cursorManager.setCursor(row, undefined, intentionalMove);
     }
   }
 
@@ -285,6 +290,7 @@
    * reset the scroll behavior, use reInit() instead.
    */
   reInitCursor() {
+    this._updateStops();
     if (!this.diffRow) {
       // does not scroll during init unless requested
       this.cursorManager.scrollMode = this.initialLineNumber
@@ -318,7 +324,6 @@
   }
 
   handleDiffUpdate() {
-    this._updateStops();
     this.reInitCursor();
   }
 
@@ -330,6 +335,10 @@
     this.preventAutoScrollOnManualScroll = true;
   };
 
+  private _boundHandleDiffRenderProgress = () => {
+    this._updateStops();
+  };
+
   private _boundHandleDiffRenderContent = () => {
     this._updateStops();
     // When done rendering, turn focus on move and automatic scrolling back on
@@ -541,6 +550,10 @@
     );
     diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
     diff.removeEventListener(
+      'render-progress',
+      this._boundHandleDiffRenderProgress
+    );
+    diff.removeEventListener(
       'render-content',
       this._boundHandleDiffRenderContent
     );
@@ -556,6 +569,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/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
similarity index 93%
rename from polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
index 3b604eb..650d213 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -18,34 +18,29 @@
 import '../../../test/common-test-setup-karma.js';
 import '../gr-diff/gr-diff.js';
 import './gr-diff-cursor.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.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 {createDiff} from '../../../test/test-data-generators.js';
 import {createDefaultDiffPrefs} from '../../../constants/constants.js';
 import {GrDiffCursor} from './gr-diff-cursor.js';
 
-const basicFixture = fixtureFromTemplate(html`
-  <gr-diff></gr-diff>
-`);
-
 suite('gr-diff-cursor tests', () => {
   let cursor;
   let diffElement;
   let diff;
 
   setup(async () => {
-    diffElement = basicFixture.instantiate();
+    diffElement = await fixture(html`<gr-diff></gr-diff>`);
     cursor = new GrDiffCursor();
 
     // Register the diff with the cursor.
     cursor.replaceDiffs([diffElement]);
 
     diffElement.loggedIn = false;
-    diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
     diffElement.comments = {
       left: [],
       right: [],
-      meta: {patchRange: undefined},
+      meta: {},
     };
     diffElement.path = 'some/path.ts';
     const promise = mockPromise();
@@ -57,7 +52,7 @@
     };
     diffElement.addEventListener('render', setupDone);
 
-    diff = getMockDiffResponse();
+    diff = createDiff();
     diffElement.prefs = createDefaultDiffPrefs();
     diffElement.diff = diff;
     await promise;
@@ -473,7 +468,7 @@
       promise.resolve();
     }
     diffElement.addEventListener('render', renderHandler);
-    diffElement._diffChanged(getMockDiffResponse());
+    diffElement._diffChanged(createDiff());
     await promise;
   });
 
@@ -500,7 +495,7 @@
     cursor.initialLineNumber = 10;
     cursor.side = 'right';
 
-    diffElement._diffChanged(getMockDiffResponse());
+    diffElement._diffChanged(createDiff());
     await promise;
   });
 
@@ -553,7 +548,7 @@
         end_line: 6,
         end_character: 1,
       };
-      diffElement.$.highlights.selectedRange = {
+      diffElement.highlights.selectedRange = {
         side: 'right',
         range: someRange,
       };
@@ -618,32 +613,15 @@
     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', () => {
-    const multiDiffFixture = fixtureFromTemplate(html`
-      <gr-diff></gr-diff>
-      <gr-diff></gr-diff>
-      <gr-diff></gr-diff>
-    `);
-
     let diffElements;
 
-    setup(() => {
-      diffElements = multiDiffFixture.instantiate();
+    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.
@@ -668,8 +646,8 @@
       const diffRenderedPromises =
           diffElements.map(diffEl => listenOnce(diffEl, 'render'));
 
-      diffElements[0].diff = getMockDiffResponse();
-      diffElements[2].diff = getMockDiffResponse();
+      diffElements[0].diff = createDiff();
+      diffElements[2].diff = createDiff();
       await Promise.all([diffRenderedPromises[0], diffRenderedPromises[2]]);
 
       const lastLine = diffElements[0].diff.meta_b.lines;
@@ -690,7 +668,7 @@
       assert.equal(cursor.getTargetLineElement().textContent, lastLine);
 
       // Diff 1 finishing to load
-      diffElements[1].diff = getMockDiffResponse();
+      diffElements[1].diff = createDiff();
       await diffRenderedPromises[1];
 
       // Now we can go down
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
similarity index 100%
rename from polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.js
similarity index 100%
rename from polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.js
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
new file mode 100644
index 0000000..0714645
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -0,0 +1,529 @@
+/**
+ * @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 {GrAnnotation} from './gr-annotation';
+import {normalize} from './gr-range-normalizer';
+import {strToClassName} from '../../../utils/dom-util';
+import {Side} from '../../../constants/constants';
+import {CommentRange} from '../../../types/common';
+import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
+import {FILE} from '../gr-diff/gr-diff-line';
+import {
+  getLineElByChild,
+  getLineNumberByChild,
+  getSideByLineEl,
+  GrDiffThreadElement,
+} from '../gr-diff/gr-diff-utils';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+
+interface SidedRange {
+  side: Side;
+  range: CommentRange;
+}
+
+interface NormalizedPosition {
+  node: Node | null;
+  side: Side;
+  line: number;
+  column: number;
+}
+
+interface NormalizedRange {
+  start: NormalizedPosition | null;
+  end: NormalizedPosition | null;
+}
+
+/**
+ * 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;
+}
+
+/**
+ * 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;
+
+  init(diffTable: HTMLElement, diffBuilder: DiffBuilderInterface) {
+    this.cleanup();
+
+    this.diffTable = diffTable;
+    this.diffBuilder = diffBuilder;
+
+    diffTable.addEventListener(
+      'comment-thread-mouseleave',
+      this.handleCommentThreadMouseleave
+    );
+    diffTable.addEventListener(
+      'comment-thread-mouseenter',
+      this.handleCommentThreadMouseenter
+    );
+    diffTable.addEventListener(
+      'create-comment-requested',
+      this.handleRangeCommentRequest
+    );
+  }
+
+  cleanup() {
+    this.selectionChangeTask?.cancel();
+    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
+      );
+    }
+  }
+
+  /**
+   * Determines side/line/range for a DOM selection and shows a tooltip.
+   *
+   * With native shadow DOM, gr-diff-highlight cannot access a selection that
+   * references the DOM elements making up the diff because they are in the
+   * shadow DOM the gr-diff element. For this reason, we listen to the
+   * selectionchange event and retrieve the selection in gr-diff, and then
+   * call this method to process the Selection.
+   *
+   * @param selection A DOM Selection living in the shadow DOM of
+   * the diff element.
+   * @param isMouseUp If true, this is called due to a mouseup
+   * event, in which case we might want to immediately create a comment,
+   * because isMouseUp === true combined with an existing selection must
+   * mean that this is the end of a double-click.
+   */
+  handleSelectionChange(
+    selection: Selection | Range | null,
+    isMouseUp: boolean
+  ) {
+    if (selection === null) return;
+    // Debounce is not just nice for waiting until the selection has settled,
+    // it is also vital for being able to click on the action box before it is
+    // 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
+    // simple drag for select.
+    this.selectionChangeTask = debounce(
+      this.selectionChangeTask,
+      () => this.handleSelection(selection, isMouseUp),
+      10
+    );
+  }
+
+  private getThreadEl(e: Event): GrDiffThreadElement | null {
+    for (const pathEl of e.composedPath()) {
+      if (
+        pathEl instanceof HTMLElement &&
+        pathEl.classList.contains('comment-thread')
+      ) {
+        return pathEl as GrDiffThreadElement;
+      }
+    }
+    return null;
+  }
+
+  private toggleRangeElHighlight(
+    threadEl: GrDiffThreadElement | null,
+    highlightRange = false
+  ) {
+    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')
+        );
+    }
+  }
+
+  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).
+   */
+  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);
+    }
+    const rangeCount = selection.rangeCount;
+    if (rangeCount === 0) {
+      return null;
+    } else if (rangeCount === 1) {
+      return this.normalizeRange(selection.getRangeAt(0));
+    } else {
+      const startRange = this.normalizeRange(selection.getRangeAt(0));
+      const endRange = this.normalizeRange(
+        selection.getRangeAt(rangeCount - 1)
+      );
+      return {
+        start: startRange.start,
+        end: endRange.end,
+      };
+    }
+  }
+
+  /**
+   * Normalize a specific DOM Range.
+   *
+   * @return fixed normalized range
+   */
+  private normalizeRange(domRange: Range): NormalizedRange {
+    const range = normalize(domRange);
+    return this.fixTripleClickSelection(
+      {
+        start: this.normalizeSelectionSide(
+          range.startContainer,
+          range.startOffset
+        ),
+        end: this.normalizeSelectionSide(range.endContainer, range.endOffset),
+      },
+      domRange
+    );
+  }
+
+  /**
+   * Adjust triple click selection for the whole line.
+   * A triple click always results in:
+   * - start.column == end.column == 0
+   * - end.line == start.line + 1
+   *
+   * @param range Normalized range, ie column/line numbers
+   * @param domRange DOM Range object
+   * @return fixed normalized range
+   */
+  private fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
+    if (!range.start) {
+      // Selection outside of current diff.
+      return range;
+    }
+    const start = range.start;
+    const end = range.end;
+    // Happens when triple click in side-by-side mode with other side empty.
+    const endsAtOtherEmptySide =
+      !end &&
+      domRange.endOffset === 0 &&
+      domRange.endContainer instanceof HTMLElement &&
+      domRange.endContainer.nodeName === 'TD' &&
+      (domRange.endContainer.classList.contains('left') ||
+        domRange.endContainer.classList.contains('right'));
+    const endsAtBeginningOfNextLine =
+      end &&
+      start.column === 0 &&
+      end.column === 0 &&
+      end.line === start.line + 1;
+    const content = domRange.cloneContents().querySelector('.contentText');
+    const lineLength = (content && this.getLength(content)) || 0;
+    if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
+      // Move the selection to the end of the previous line.
+      range.end = {
+        node: start.node,
+        column: lineLength,
+        side: start.side,
+        line: start.line,
+      };
+    }
+    return range;
+  }
+
+  /**
+   * Convert DOM Range selection to concrete numbers (line, column, side).
+   * Moves range end if it's not inside td.content.
+   * Returns null if selection end is not valid (outside of diff).
+   *
+   * @param node td.content child
+   * @param offset offset within node
+   */
+  private normalizeSelectionSide(
+    node: Node | null,
+    offset: number
+  ): NormalizedPosition | null {
+    let column;
+    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);
+    if (!side) return null;
+    const line = getLineNumberByChild(lineEl);
+    if (!line || line === FILE || line === 'LOST') return null;
+    const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
+    if (!contentTd) return null;
+    const contentText = contentTd.querySelector('.contentText');
+    if (!contentTd.contains(node)) {
+      node = contentText;
+      column = 0;
+    } else {
+      const thread = contentTd.querySelector('.comment-thread');
+      if (thread?.contains(node)) {
+        column = this.getLength(contentText);
+        node = contentText;
+      } else {
+        column = this.convertOffsetToColumn(node, offset);
+      }
+    }
+
+    return {
+      node,
+      side,
+      line,
+      column,
+    };
+  }
+
+  /**
+   * The only line in which add a comment tooltip is cut off is the first
+   * line. Even if there is a collapsed section, The first visible line is
+   * in the position where the second line would have been, if not for the
+   * collapsed section, so don't need to worry about this case for
+   * positioning the tooltip.
+   */
+  // visible for testing
+  positionActionBox(
+    actionBox: GrSelectionActionBox,
+    startLine: number,
+    range: Text | Element | Range
+  ) {
+    if (startLine > 1) {
+      actionBox.positionBelow = false;
+      actionBox.placeAbove(range);
+      return;
+    }
+    actionBox.positionBelow = true;
+    actionBox.placeBelow(range);
+  }
+
+  private isRangeValid(range: NormalizedRange | null) {
+    if (!range || !range.start || !range.start.node || !range.end) {
+      return false;
+    }
+    const start = range.start;
+    const end = range.end;
+    return !(
+      start.side !== end.side ||
+      end.line < start.line ||
+      (start.line === end.line && start.column === end.column)
+    );
+  }
+
+  // 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;
+    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
+       we can get is a single Range */
+    const domRange =
+      selection instanceof Range ? selection : selection.getRangeAt(0);
+    const start = normalizedRange!.start!;
+    const end = normalizedRange!.end!;
+
+    // TODO (viktard): Drop empty first and last lines from selection.
+
+    // If the selection is from the end of one line to the start of the next
+    // line, then this must have been a double-click, or you have started
+    // dragging. Showing the action box is bad in the former case and not very
+    // useful in the latter, so never do that.
+    // If this was a mouse-up event, we create a comment immediately if
+    // the selection is from the end of a line to the start of the next line.
+    // In a perfect world we would only do this for double-click, but it is
+    // extremely rare that a user would drag from the end of one line to the
+    // start of the next and release the mouse, so we don't bother.
+    // TODO(brohlfs): This does not work, if the double-click is before a new
+    // diff chunk (start will be equal to end), and neither before an "expand
+    // the diff context" block (end line will match the first line of the new
+    // section and thus be greater than start line + 1).
+    if (start.line === end.line - 1 && end.column === 0) {
+      // Rather than trying to find the line contents (for comparing
+      // 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, {
+          start_line: start.line,
+          start_character: 0,
+          end_line: start.line,
+          end_character: start.column,
+        });
+      }
+      return;
+    }
+
+    let actionBox = this.diffTable.querySelector('gr-selection-action-box');
+    if (!actionBox) {
+      actionBox = document.createElement('gr-selection-action-box');
+      this.diffTable.appendChild(actionBox);
+    }
+    this.selectedRange = {
+      range: {
+        start_line: start.line,
+        start_character: start.column,
+        end_line: end.line,
+        end_character: end.column,
+      },
+      side: start.side,
+    };
+    if (start.line === end.line) {
+      this.positionActionBox(actionBox, start.line, domRange);
+    } else if (start.node instanceof Text) {
+      if (start.column) {
+        this.positionActionBox(
+          actionBox,
+          start.line,
+          start.node.splitText(start.column)
+        );
+      }
+      start.node.parentElement!.normalize(); // Undo splitText from above.
+    } else if (
+      start.node instanceof HTMLElement &&
+      start.node.classList.contains('content') &&
+      (start.node.firstChild instanceof Element ||
+        start.node.firstChild instanceof Text)
+    ) {
+      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);
+    } else {
+      console.warn('Failed to position comment action box.');
+      this.removeActionBox();
+    }
+  }
+
+  private fireCreateRangeComment(side: Side, range: CommentRange) {
+    this.diffTable?.dispatchEvent(
+      new CustomEvent('create-range-comment', {
+        detail: {side, range},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    this.removeActionBox();
+  }
+
+  private handleRangeCommentRequest = (e: Event) => {
+    e.stopPropagation();
+    assertIsDefined(this.selectedRange, 'selectedRange');
+    const {side, range} = this.selectedRange;
+    this.fireCreateRangeComment(side, range);
+  };
+
+  // visible for testing
+  removeActionBox() {
+    this.selectedRange = undefined;
+    const actionBox = this.diffTable?.querySelector('gr-selection-action-box');
+    if (actionBox) actionBox.remove();
+  }
+
+  private convertOffsetToColumn(el: Node, offset: number) {
+    if (el instanceof Element && el.classList.contains('content')) {
+      return offset;
+    }
+    while (
+      el.previousSibling ||
+      !el.parentElement?.classList.contains('content')
+    ) {
+      if (el.previousSibling) {
+        el = el.previousSibling;
+        offset += this.getLength(el);
+      } else {
+        el = el.parentElement!;
+      }
+    }
+    return offset;
+  }
+
+  /**
+   * Get length of a node. If the node is a content node, then only give the
+   * length of its .contentText child.
+   *
+   * @param node this is sometimes passed as null.
+   */
+  // 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'));
+    } else {
+      return GrAnnotation.getLength(node);
+    }
+  }
+}
+
+export interface CreateRangeCommentEventDetail {
+  side: Side;
+  range: CommentRange;
+}
+
+declare global {
+  interface HTMLElementEventMap {
+    'create-range-comment': CustomEvent<CreateRangeCommentEventDetail>;
+  }
+}
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/elements/diff/gr-diff-highlight/gr-range-normalizer.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts
similarity index 100%
rename from polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
similarity index 92%
rename from polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 32f5f39..1068a8d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -25,11 +25,12 @@
 import './gr-overview-image';
 import './gr-zoomed-image';
 
-import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader';
-import {RESEMBLEJS_LIBRARY_CONFIG} from '../../shared/gr-lib-loader/resemblejs_config';
+import {GrLibLoader} from '../../../elements/shared/gr-lib-loader/gr-lib-loader';
+import {RESEMBLEJS_LIBRARY_CONFIG} from '../../../elements/shared/gr-lib-loader/resemblejs_config';
 
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators';
+import {ifDefined} from 'lit/directives/if-defined';
 import {classMap} from 'lit/directives/class-map';
 import {StyleInfo, styleMap} from 'lit/directives/style-map';
 
@@ -374,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>
     `;
@@ -391,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>
@@ -411,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}
       />
     `;
 
@@ -427,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="${this.diffHighlightSrc}"
+          })}
+          src=${ifDefined(this.diffHighlightSrc)}
         />
       </div>
     `;
@@ -460,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>
@@ -485,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>
@@ -495,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>
     `;
@@ -515,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>
             `
@@ -532,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>
@@ -565,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>
     `;
@@ -608,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>
@@ -888,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/elements/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
similarity index 96%
rename from polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
index 28e6d82..49a0eb5 100644
--- a/polygerrit-ui/app/elements/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/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
similarity index 97%
rename from polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
index 66d4671..6fdec67 100644
--- a/polygerrit-ui/app/elements/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/elements/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
similarity index 100%
rename from polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.js
similarity index 100%
rename from polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.js
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
new file mode 100644
index 0000000..1b53d05
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -0,0 +1,174 @@
+/**
+ * @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 {Subscription} from 'rxjs';
+import '@polymer/iron-icon/iron-icon';
+import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+import '../../../elements/shared/gr-button/gr-button';
+import {DiffViewMode} from '../../../constants/constants';
+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} from '../../../models/dependency';
+import {css, html, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+
+@customElement('gr-diff-mode-selector')
+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}) showTooltipBelow = false;
+
+  @state() private mode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  private readonly getBrowserModel = resolve(this, browserModelToken);
+
+  private readonly userModel = getAppContext().userModel;
+
+  private subscriptions: Subscription[] = [];
+
+  constructor() {
+    super();
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    (
+      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
+    ).requestAvailability();
+    this.subscriptions.push(
+      this.getBrowserModel().diffViewMode$.subscribe(
+        diffView => (this.mode = diffView)
+      )
+    );
+  }
+
+  override disconnectedCallback() {
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+    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.
+   */
+  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()) {
+      announcement = 'Changed diff view to unified';
+    } else if (this.isSideBySideSelected()) {
+      announcement = 'Changed diff view to side by side';
+    }
+    if (announcement) {
+      fireIronAnnounce(this, announcement);
+    }
+  }
+
+  private computeSideBySideSelected() {
+    return this.mode === DiffViewMode.SIDE_BY_SIDE ? 'selected' : '';
+  }
+
+  private computeUnifiedSelected() {
+    return this.mode === DiffViewMode.UNIFIED ? 'selected' : '';
+  }
+
+  private isSideBySideSelected() {
+    return this.mode === DiffViewMode.SIDE_BY_SIDE;
+  }
+
+  private isUnifiedSelected() {
+    return this.mode === DiffViewMode.UNIFIED;
+  }
+
+  private handleSideBySideTap() {
+    this.setMode(DiffViewMode.SIDE_BY_SIDE);
+  }
+
+  private handleUnifiedTap() {
+    this.setMode(DiffViewMode.UNIFIED);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-mode-selector': GrDiffModeSelector;
+  }
+}
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
new file mode 100644
index 0000000..6ba5533
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
@@ -0,0 +1,169 @@
+/**
+ * @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';
+import './gr-diff-mode-selector';
+import {GrDiffModeSelector} from './gr-diff-mode-selector';
+import {DiffViewMode} from '../../../constants/constants';
+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(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('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
+    );
+
+    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('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');
+
+    // Setting the mode initially does not save prefs.
+    element.saveOnChange = true;
+    queryAndAssert<GrButton>(element, 'gr-button#sideBySideBtn').click();
+    await element.updateComplete;
+
+    assert.isFalse(saveStub.called);
+
+    // Setting the mode to itself does not save prefs.
+    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;
+    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;
+    queryAndAssert<GrButton>(element, 'gr-button#sideBySideBtn').click();
+    await element.updateComplete;
+
+    assert.isTrue(saveStub.calledOnce);
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
similarity index 74%
rename from polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index b1e15a8..4a1e5be 100644
--- a/polygerrit-ui/app/elements/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,
@@ -28,15 +16,16 @@
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
 import {CancelablePromise, util} from '../../../scripts/util';
-import {customElement, property} from '@polymer/decorators';
 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,48 +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;
 
-  @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
     );
   };
@@ -149,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.
@@ -160,33 +138,34 @@
       return Promise.resolve();
     }
 
-    this._processPromise = util.makeCancelable(
+    this.processPromise = util.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;
@@ -194,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);
           }
@@ -205,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);
     });
   }
 
@@ -214,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
     );
@@ -235,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
@@ -249,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;
   }
 
@@ -289,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
@@ -299,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
@@ -329,7 +311,7 @@
     };
   }
 
-  _commonChunkLength(chunk: DiffContent) {
+  private commonChunkLength(chunk: DiffContent) {
     if (chunk.skip) {
       return chunk.skip;
     }
@@ -340,53 +322,62 @@
       '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 group = new GrDiffGroup(type, lines);
-    group.keyLocation = !!chunk.keyLocation;
-    group.dueToRebase = !!chunk.due_to_rebase;
-    group.moveDetails = chunk.move_details;
-    group.skip = chunk.skip;
-    group.ignoredWhitespaceOnly = !!chunk.common;
+    const lines = this.linesFromChunk(chunk, offsetLeft, offsetRight);
+    const options = {
+      moveDetails: chunk.move_details,
+      dueToRebase: !!chunk.due_to_rebase,
+      ignoredWhitespaceOnly: !!chunk.common,
+      keyLocation: !!chunk.keyLocation,
+    };
     if (chunk.skip) {
-      group.lineRange = {
-        left: {start_line: offsetLeft, end_line: offsetLeft + chunk.skip - 1},
-        right: {
-          start_line: offsetRight,
-          end_line: offsetRight + chunk.skip - 1,
-        },
-      };
+      return new GrDiffGroup({
+        type,
+        skip: chunk.skip,
+        offsetLeft,
+        offsetRight,
+        ...options,
+      });
+    } else {
+      return new GrDiffGroup({
+        type,
+        lines,
+        ...options,
+      });
     }
-    return group;
   }
 
-  _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[] = [];
@@ -394,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,
@@ -406,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,
@@ -417,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,
@@ -452,11 +444,11 @@
     return line;
   }
 
-  _makeGroup(number: LineNumber) {
+  private makeGroup(number: LineNumber) {
     const line = new GrDiffLine(GrDiffLineType.BOTH);
     line.beforeNumber = number;
     line.afterNumber = number;
-    return new GrDiffGroup(GrDiffGroupType.BOTH, [line]);
+    return new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [line]});
   }
 
   /**
@@ -474,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;
@@ -489,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.
@@ -510,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;
@@ -533,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
@@ -550,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,
@@ -561,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 {
@@ -583,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
@@ -616,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) {
@@ -633,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[] {
@@ -683,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) {
@@ -700,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) {
@@ -718,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 [];
     }
@@ -729,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/elements/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/elements/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/elements/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/elements/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
similarity index 65%
rename from polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
rename to polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
index 2665ef0..4bb8cc3 100644
--- a/polygerrit-ui/app/elements/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_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/elements/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
similarity index 65%
rename from polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
rename to polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index ba6fe7e..b8262e0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -15,7 +15,8 @@
  * limitations under the License.
  */
 import {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
-import {LineRange} from '../../../api/diff';
+import {LineRange, Side} from '../../../api/diff';
+import {LineNumber} from './gr-diff-line';
 
 export enum GrDiffGroupType {
   /** Unchanged context. */
@@ -42,7 +43,7 @@
  * originated from an `ab` chunk, or from an `a`+`b` chunk with
  * `common: true`.
  *
- * If the hidden range is 1 line or less, nothing is hidden and no context
+ * If the hidden range is 3 lines or less, nothing is hidden and no context
  * control group is created.
  *
  * @param groups Common groups, ordered by their line ranges.
@@ -54,7 +55,7 @@
  *     start line, left and right respectively.
  */
 export function hideInContextControl(
-  groups: GrDiffGroup[],
+  groups: readonly GrDiffGroup[],
   hiddenStart: number,
   hiddenEnd: number
 ): GrDiffGroup[] {
@@ -65,7 +66,7 @@
 
   let before: GrDiffGroup[] = [];
   let hidden = groups;
-  let after: GrDiffGroup[] = [];
+  let after: readonly GrDiffGroup[] = [];
 
   const numHidden = hiddenEnd - hiddenStart;
 
@@ -90,9 +91,12 @@
 
   const result = [...before];
   if (hidden.length) {
-    const ctxGroup = new GrDiffGroup(GrDiffGroupType.CONTEXT_CONTROL, []);
-    ctxGroup.contextGroups = hidden;
-    result.push(ctxGroup);
+    result.push(
+      new GrDiffGroup({
+        type: GrDiffGroupType.CONTEXT_CONTROL,
+        contextGroups: [...hidden],
+      })
+    );
   }
   result.push(...after);
   return result;
@@ -173,7 +177,7 @@
  *   list of groups before and the list of groups after the split.
  */
 function _splitCommonGroups(
-  groups: GrDiffGroup[],
+  groups: readonly GrDiffGroup[],
   split: number
 ): GrDiffGroup[][] {
   if (groups.length === 0) return [[], []];
@@ -210,57 +214,134 @@
   return [beforeGroups, afterGroups];
 }
 
-/**
- * A chunk of the diff that should be rendered together.
- *
- * @constructor
- * @param {!GrDiffGroupType} type
- * @param {!Array<!GrDiffLine>=} opt_lines
- */
+export interface GrMoveDetails {
+  changed: boolean;
+  range?: {
+    start: number;
+    end: number;
+  };
+}
+
+/** A chunk of the diff that should be rendered together. */
 export class GrDiffGroup {
-  constructor(readonly type: GrDiffGroupType, lines: GrDiffLine[] = []) {
-    lines.forEach((line: GrDiffLine) => this.addLine(line));
+  constructor(
+    options:
+      | {
+          type: GrDiffGroupType.BOTH | GrDiffGroupType.DELTA;
+          lines?: GrDiffLine[];
+          skip?: undefined;
+          moveDetails?: GrMoveDetails;
+          dueToRebase?: boolean;
+          ignoredWhitespaceOnly?: boolean;
+          keyLocation?: boolean;
+        }
+      | {
+          type: GrDiffGroupType.BOTH | GrDiffGroupType.DELTA;
+          lines?: undefined;
+          skip: number;
+          offsetLeft: number;
+          offsetRight: number;
+          moveDetails?: GrMoveDetails;
+          dueToRebase?: boolean;
+          ignoredWhitespaceOnly?: boolean;
+          keyLocation?: boolean;
+        }
+      | {
+          type: GrDiffGroupType.CONTEXT_CONTROL;
+          contextGroups: GrDiffGroup[];
+        }
+  ) {
+    this.type = options.type;
+    switch (options.type) {
+      case GrDiffGroupType.BOTH:
+      case GrDiffGroupType.DELTA: {
+        this.moveDetails = options.moveDetails;
+        this.dueToRebase = options.dueToRebase ?? false;
+        this.ignoredWhitespaceOnly = options.ignoredWhitespaceOnly ?? false;
+        this.keyLocation = options.keyLocation ?? false;
+        if (options.skip && options.lines) {
+          throw new Error('Cannot set skip and lines');
+        }
+        this.skip = options.skip;
+        if (options.skip) {
+          this.lineRange = {
+            left: {
+              start_line: options.offsetLeft,
+              end_line: options.offsetLeft + options.skip - 1,
+            },
+            right: {
+              start_line: options.offsetRight,
+              end_line: options.offsetRight + options.skip - 1,
+            },
+          };
+        } else {
+          for (const line of options.lines ?? []) {
+            this.addLine(line);
+          }
+        }
+        break;
+      }
+      case GrDiffGroupType.CONTEXT_CONTROL: {
+        this.contextGroups = options.contextGroups;
+        if (this.contextGroups.length > 0) {
+          const firstGroup = this.contextGroups[0];
+          const lastGroup = this.contextGroups[this.contextGroups.length - 1];
+          this.lineRange = {
+            left: {
+              start_line: firstGroup.lineRange.left.start_line,
+              end_line: lastGroup.lineRange.left.end_line,
+            },
+            right: {
+              start_line: firstGroup.lineRange.right.start_line,
+              end_line: lastGroup.lineRange.right.end_line,
+            },
+          };
+        }
+        break;
+      }
+      default:
+        throw new Error(`Unknown group type: ${this.type}`);
+    }
   }
 
-  dueToRebase = false;
+  readonly type: GrDiffGroupType;
+
+  readonly dueToRebase: boolean = false;
 
   /**
    * True means all changes in this line are whitespace changes that should
    * not be highlighted as changed as per the user settings.
    */
-  ignoredWhitespaceOnly = false;
+  readonly ignoredWhitespaceOnly: boolean = false;
 
   /**
    * True means it should not be collapsed (because it was in the URL, or
    * there is a comment on that line)
    */
-  keyLocation = false;
+  readonly keyLocation: boolean = false;
 
+  /**
+   * Once rendered the diff builder sets this to the diff section element.
+   */
   element?: HTMLElement;
 
-  lines: GrDiffLine[] = [];
+  readonly lines: GrDiffLine[] = [];
 
-  adds: GrDiffLine[] = [];
+  readonly adds: GrDiffLine[] = [];
 
-  removes: GrDiffLine[] = [];
+  readonly removes: GrDiffLine[] = [];
 
-  contextGroups: GrDiffGroup[] = [];
+  readonly contextGroups: GrDiffGroup[] = [];
 
-  skip?: number;
+  readonly skip?: number;
 
   /** Both start and end line are inclusive. */
-  lineRange = {
-    left: {start_line: 0, end_line: 0} as LineRange,
-    right: {start_line: 0, end_line: 0} as LineRange,
+  readonly lineRange: {[side in Side]: LineRange} = {
+    [Side.LEFT]: {start_line: 0, end_line: 0},
+    [Side.RIGHT]: {start_line: 0, end_line: 0},
   };
 
-  moveDetails?: {
-    changed: boolean;
-    range?: {
-      start: number;
-      end: number;
-    };
-  };
+  readonly moveDetails?: GrMoveDetails;
 
   /**
    * Creates a new group with the same properties but different lines.
@@ -269,13 +350,22 @@
    * rendering of the old lines, so that would not make sense.
    */
   cloneWithLines(lines: GrDiffLine[]): GrDiffGroup {
-    const group = new GrDiffGroup(this.type, lines);
-    group.dueToRebase = this.dueToRebase;
-    group.ignoredWhitespaceOnly = this.ignoredWhitespaceOnly;
+    if (
+      this.type !== GrDiffGroupType.BOTH &&
+      this.type !== GrDiffGroupType.DELTA
+    ) {
+      throw new Error('Cannot clone context group with lines');
+    }
+    const group = new GrDiffGroup({
+      type: this.type,
+      lines,
+      dueToRebase: this.dueToRebase,
+      ignoredWhitespaceOnly: this.ignoredWhitespaceOnly,
+    });
     return group;
   }
 
-  addLine(line: GrDiffLine) {
+  private addLine(line: GrDiffLine) {
     this.lines.push(line);
 
     const notDelta =
@@ -293,7 +383,7 @@
     } else if (line.type === GrDiffLineType.REMOVE) {
       this.removes.push(line);
     }
-    this._updateRange(line);
+    this._updateRangeWithNewLine(line);
   }
 
   getSideBySidePairs(): GrDiffLinePair[] {
@@ -323,7 +413,21 @@
     return pairs;
   }
 
-  _updateRange(line: GrDiffLine) {
+  /** Returns true if it is, or contains, a skip group. */
+  hasSkipGroup() {
+    return !!this.skip || this.contextGroups?.some(g => !!g.skip);
+  }
+
+  containsLine(side: Side, line: LineNumber) {
+    if (line === 'FILE' || line === 'LOST') {
+      // For FILE and LOST, beforeNumber and afterNumber are the same
+      return this.lines[0]?.beforeNumber === line;
+    }
+    const lineRange = this.lineRange[side];
+    return lineRange.start_line <= line && line <= lineRange.end_line;
+  }
+
+  private _updateRangeWithNewLine(line: GrDiffLine) {
     if (
       line.beforeNumber === 'FILE' ||
       line.afterNumber === 'FILE' ||
@@ -360,4 +464,16 @@
       }
     }
   }
+
+  /**
+   * Determines whether the group is either totally an addition or totally
+   * a removal.
+   */
+  isTotal(): boolean {
+    return (
+      this.type === GrDiffGroupType.DELTA &&
+      (!this.adds.length || !this.removes.length) &&
+      !(!this.adds.length && !this.removes.length)
+    );
+  }
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js
similarity index 75%
rename from polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js
index 4c7d346..321086c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.js
@@ -21,13 +21,12 @@
 
 suite('gr-diff-group tests', () => {
   test('delta line pairs', () => {
-    let group = new GrDiffGroup(GrDiffGroupType.DELTA);
     const l1 = new GrDiffLine(GrDiffLineType.ADD, 0, 128);
     const l2 = new GrDiffLine(GrDiffLineType.ADD, 0, 129);
     const l3 = new GrDiffLine(GrDiffLineType.REMOVE, 64, 0);
-    group.addLine(l1);
-    group.addLine(l2);
-    group.addLine(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]);
@@ -42,7 +41,7 @@
       {left: BLANK_LINE, right: l2},
     ]);
 
-    group = new GrDiffGroup(GrDiffGroupType.DELTA, [l1, l2, l3]);
+    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 +58,8 @@
     const l2 = new GrDiffLine(GrDiffLineType.BOTH, 65, 129);
     const l3 = new GrDiffLine(GrDiffLineType.BOTH, 66, 130);
 
-    let group = new GrDiffGroup(GrDiffGroupType.BOTH, [l1, l2, l3]);
+    const group = new GrDiffGroup({
+      type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]});
 
     assert.deepEqual(group.lines, [l1, l2, l3]);
     assert.deepEqual(group.adds, []);
@@ -70,19 +70,7 @@
       right: {start_line: 128, end_line: 130},
     });
 
-    let pairs = group.getSideBySidePairs();
-    assert.deepEqual(pairs, [
-      {left: l1, right: l1},
-      {left: l2, right: l2},
-      {left: l3, right: l3},
-    ]);
-
-    group = new GrDiffGroup(GrDiffGroupType.CONTEXT_CONTROL, [l1, l2, l3]);
-    assert.deepEqual(group.lines, [l1, l2, l3]);
-    assert.deepEqual(group.adds, []);
-    assert.deepEqual(group.removes, []);
-
-    pairs = group.getSideBySidePairs();
+    const pairs = group.getSideBySidePairs();
     assert.deepEqual(pairs, [
       {left: l1, right: l1},
       {left: l2, right: l2},
@@ -95,27 +83,20 @@
     const l2 = new GrDiffLine(GrDiffLineType.REMOVE);
     const l3 = new GrDiffLine(GrDiffLineType.BOTH);
 
-    let group = new GrDiffGroup(GrDiffGroupType.BOTH);
-    assert.throws(group.addLine.bind(group, l1));
-    assert.throws(group.addLine.bind(group, l2));
-    assert.doesNotThrow(group.addLine.bind(group, l3));
-
-    group = new GrDiffGroup(GrDiffGroupType.CONTEXT_CONTROL);
-    assert.throws(group.addLine.bind(group, l1));
-    assert.throws(group.addLine.bind(group, l2));
-    assert.doesNotThrow(group.addLine.bind(group, l3));
+    assert.throws(() =>
+      new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]}));
   });
 
   suite('hideInContextControl', () => {
     let groups;
     setup(() => {
       groups = [
-        new GrDiffGroup(GrDiffGroupType.BOTH, [
+        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(GrDiffGroupType.DELTA, [
+        ]}),
+        new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [
           new GrDiffLine(GrDiffLineType.REMOVE, 8),
           new GrDiffLine(GrDiffLineType.ADD, 0, 10),
           new GrDiffLine(GrDiffLineType.REMOVE, 9),
@@ -124,12 +105,12 @@
           new GrDiffLine(GrDiffLineType.ADD, 0, 12),
           new GrDiffLine(GrDiffLineType.REMOVE, 11),
           new GrDiffLine(GrDiffLineType.ADD, 0, 13),
-        ]),
-        new GrDiffGroup(GrDiffGroupType.BOTH, [
+        ]}),
+        new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
           new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
           new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
           new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
-        ]),
+        ]}),
       ];
     });
 
@@ -181,24 +162,23 @@
 
     suite('with skip chunks', () => {
       setup(() => {
-        const skipGroup = new GrDiffGroup(GrDiffGroupType.BOTH);
-        skipGroup.skip = 60;
-        skipGroup.lineRange = {
-          left: {start_line: 8, end_line: 67},
-          right: {start_line: 10, end_line: 69},
-        };
+        const skipGroup = new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          skip: 60,
+          offsetLeft: 8,
+          offsetRight: 10});
         groups = [
-          new GrDiffGroup(GrDiffGroupType.BOTH, [
+          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(GrDiffGroupType.BOTH, [
+          new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [
             new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
             new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
             new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
-          ]),
+          ]}),
         ];
       });
 
@@ -218,5 +198,39 @@
           hideInContextControl(groups, 3, 4), groups);
     });
   });
+
+  suite('isTotal', () => {
+    test('is total for add', () => {
+      const lines = [];
+      for (let idx = 0; idx < 10; idx++) {
+        lines.push(new GrDiffLine(GrDiffLineType.ADD));
+      }
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.isTrue(group.isTotal(group));
+    });
+
+    test('is total for remove', () => {
+      const lines = [];
+      for (let idx = 0; idx < 10; idx++) {
+        lines.push(new GrDiffLine(GrDiffLineType.REMOVE));
+      }
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.isTrue(group.isTotal(group));
+    });
+
+    test('not total for empty', () => {
+      const group = new GrDiffGroup({type: GrDiffGroupType.BOTH});
+      assert.isFalse(group.isTotal(group));
+    });
+
+    test('not total for non-delta', () => {
+      const lines = [];
+      for (let idx = 0; idx < 10; idx++) {
+        lines.push(new GrDiffLine(GrDiffLineType.BOTH));
+      }
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.isFalse(group.isTotal(group));
+    });
+  });
 });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
similarity index 100%
rename from polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
rename to polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
new file mode 100644
index 0000000..182d48e
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -0,0 +1,368 @@
+/**
+ * @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 {BlameInfo, CommentRange} from '../../../types/common';
+import {FILE, LineNumber} from './gr-diff-line';
+import {Side} from '../../../constants/constants';
+import {DiffInfo} from '../../../types/diff';
+import {
+  DiffPreferencesInfo,
+  DiffResponsiveMode,
+  RenderPreferences,
+} from '../../../api/diff';
+import {getBaseUrl} from '../../../utils/url-util';
+
+/**
+ * In JS, unicode code points above 0xFFFF occupy two elements of a string.
+ * For example '𐀏'.length is 2. An occurrence of such a code point is called a
+ * surrogate pair.
+ *
+ * This regex segments a string along tabs ('\t') and surrogate pairs, since
+ * these are two cases where '1 char' does not automatically imply '1 column'.
+ *
+ * TODO: For human languages whose orthographies use combining marks, this
+ * approach won't correctly identify the grapheme boundaries. In those cases,
+ * a grapheme consists of multiple code points that should count as only one
+ * character against the column limit. Getting that correct (if it's desired)
+ * is probably beyond the limits of a regex, but there are nonstandard APIs to
+ * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
+ *
+ * Further reading:
+ *   On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
+ *   Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
+ *   A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
+ */
+const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+// If any line of the diff is more than the character limit, then disable
+// syntax highlighting for the entire file.
+export const SYNTAX_MAX_LINE_LENGTH = 500;
+
+export function getResponsiveMode(
+  prefs: DiffPreferencesInfo,
+  renderPrefs?: RenderPreferences
+): DiffResponsiveMode {
+  if (renderPrefs?.responsive_mode) {
+    return renderPrefs.responsive_mode;
+  }
+  // Backwards compatibility to the line_wrapping param.
+  if (prefs.line_wrapping) {
+    return 'FULL_RESPONSIVE';
+  }
+  return 'NONE';
+}
+
+export function isResponsive(responsiveMode: DiffResponsiveMode) {
+  return (
+    responsiveMode === 'FULL_RESPONSIVE' || responsiveMode === 'SHRINK_ONLY'
+  );
+}
+
+/**
+ * Compare two ranges. Either argument may be falsy, but will only return
+ * true if both are falsy or if neither are falsy and have the same position
+ * values.
+ */
+export function rangesEqual(a?: CommentRange, b?: CommentRange): boolean {
+  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
+  );
+}
+
+export function isLongCommentRange(range: CommentRange): boolean {
+  return range.end_line - range.start_line > 10;
+}
+
+export function getLineNumberByChild(node?: Node) {
+  return getLineNumber(getLineElByChild(node));
+}
+
+export function lineNumberToNumber(lineNumber?: LineNumber | null): number {
+  if (!lineNumber) return 0;
+  if (lineNumber === 'LOST') return 0;
+  if (lineNumber === 'FILE') return 0;
+  return lineNumber;
+}
+
+export function getLineElByChild(node?: Node): HTMLElement | null {
+  while (node) {
+    if (node instanceof Element) {
+      if (node.classList.contains('lineNum')) {
+        return node as HTMLElement;
+      }
+      if (node.classList.contains('section')) {
+        return null;
+      }
+    }
+    node = node.previousSibling ?? node.parentElement ?? undefined;
+  }
+  return null;
+}
+
+export function getSideByLineEl(lineEl: Element) {
+  return lineEl.classList.contains(Side.RIGHT) ? Side.RIGHT : Side.LEFT;
+}
+
+export function getLineNumber(lineEl?: Element | null): LineNumber | null {
+  if (!lineEl) return null;
+  const lineNumberStr = lineEl.getAttribute('data-value');
+  if (!lineNumberStr) return null;
+  if (lineNumberStr === FILE) return FILE;
+  if (lineNumberStr === 'LOST') return 'LOST';
+  const lineNumber = Number(lineNumberStr);
+  return Number.isInteger(lineNumber) ? lineNumber : null;
+}
+
+export function getLine(threadEl: HTMLElement): LineNumber {
+  const lineAtt = threadEl.getAttribute('line-num');
+  if (lineAtt === 'LOST') return lineAtt;
+  if (!lineAtt || lineAtt === 'FILE') return FILE;
+  const line = Number(lineAtt);
+  if (isNaN(line)) throw new Error(`cannot parse line number: ${lineAtt}`);
+  if (line < 1) throw new Error(`line number smaller than 1: ${line}`);
+  return line;
+}
+
+export function getSide(threadEl: HTMLElement): Side | undefined {
+  const sideAtt = threadEl.getAttribute('diff-side');
+  if (!sideAtt) {
+    console.warn('comment thread without side');
+    return undefined;
+  }
+  if (sideAtt !== Side.LEFT && sideAtt !== Side.RIGHT)
+    throw Error(`unexpected value for side: ${sideAtt}`);
+  return sideAtt as Side;
+}
+
+export function getRange(threadEl: HTMLElement): CommentRange | undefined {
+  const rangeAtt = threadEl.getAttribute('range');
+  if (!rangeAtt) return undefined;
+  const range = JSON.parse(rangeAtt) as CommentRange;
+  if (!range.start_line) throw new Error(`invalid range: ${rangeAtt}`);
+  return range;
+}
+
+// TODO: This type should be exposed to gr-diff clients in a separate type file.
+// For Gerrit these are instances of GrCommentThread, but other gr-diff users
+// have different HTML elements in use for comment threads.
+// TODO: Also document the required HTML attributes that thread elements must
+// have, e.g. 'diff-side', 'range', 'line-num'.
+export interface GrDiffThreadElement extends HTMLElement {
+  rootId: string;
+}
+
+export function isThreadEl(node: Node): node is GrDiffThreadElement {
+  return (
+    node.nodeType === Node.ELEMENT_NODE &&
+    (node as Element).classList.contains('comment-thread')
+  );
+}
+
+/**
+ * @return whether any of the lines in diff are longer
+ * than SYNTAX_MAX_LINE_LENGTH.
+ */
+export function anyLineTooLong(diff?: DiffInfo) {
+  if (!diff) return false;
+  return diff.content.some(section => {
+    const lines = section.ab
+      ? section.ab
+      : (section.a || []).concat(section.b || []);
+    return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
+  });
+}
+
+/**
+ * Simple helper method for creating elements in the context of gr-diff.
+ *
+ * We are adding 'style-scope', 'gr-diff' classes for compatibility with
+ * Shady DOM. TODO: Is that still required??
+ *
+ * Otherwise this is just a super simple convenience function.
+ */
+export function createElementDiff(
+  tagName: string,
+  classStr?: string
+): HTMLElement {
+  const el = document.createElement(tagName);
+  // When Shady DOM is being used, these classes are added to account for
+  // Polymer's polyfill behavior. In order to guarantee sufficient
+  // specificity within the CSS rules, these are added to every element.
+  // Since the Polymer DOM utility functions (which would do this
+  // automatically) are not being used for performance reasons, this is
+  // done manually.
+  el.classList.add('style-scope', 'gr-diff');
+  if (classStr) {
+    for (const className of classStr.split(' ')) {
+      el.classList.add(className);
+    }
+  }
+  return el;
+}
+
+export function createElementDiffWithText(
+  tagName: string,
+  textContent: string
+) {
+  const element = createElementDiff(tagName);
+  element.textContent = textContent;
+  return element;
+}
+
+export function createLineBreak(mode: DiffResponsiveMode) {
+  return isResponsive(mode)
+    ? createElementDiff('wbr')
+    : createElementDiff('span', 'br');
+}
+
+/**
+ * Returns a <span> element holding a '\t' character, that will visually
+ * occupy |tabSize| many columns.
+ *
+ * @param tabSize The effective size of this tab stop.
+ */
+export function createTabWrapper(tabSize: number): HTMLElement {
+  // Force this to be a number to prevent arbitrary injection.
+  const result = createElementDiff('span', 'tab');
+  result.setAttribute(
+    'style',
+    `tab-size: ${tabSize}; -moz-tab-size: ${tabSize};`
+  );
+  result.innerText = '\t';
+  return result;
+}
+
+/**
+ * Returns a 'div' element containing the supplied |text| as its innerText,
+ * with '\t' characters expanded to a width determined by |tabSize|, and the
+ * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
+ * desired.
+ *
+ * @param text The text to be formatted.
+ * @param responsiveMode The responsive mode of the diff.
+ * @param tabSize The width of each tab stop.
+ * @param lineLimit The column after which to wrap lines.
+ */
+export function formatText(
+  text: string,
+  responsiveMode: DiffResponsiveMode,
+  tabSize: number,
+  lineLimit: number
+): HTMLElement {
+  const contentText = createElementDiff('div', 'contentText');
+  contentText.ariaLabel = text;
+  let columnPos = 0;
+  let textOffset = 0;
+  for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
+    if (segment) {
+      // |segment| contains only normal characters. If |segment| doesn't fit
+      // entirely on the current line, append chunks of |segment| followed by
+      // line breaks.
+      let rowStart = 0;
+      let rowEnd = lineLimit - columnPos;
+      while (rowEnd < segment.length) {
+        contentText.appendChild(
+          document.createTextNode(segment.substring(rowStart, rowEnd))
+        );
+        contentText.appendChild(createLineBreak(responsiveMode));
+        columnPos = 0;
+        rowStart = rowEnd;
+        rowEnd += lineLimit;
+      }
+      // Append the last part of |segment|, which fits on the current line.
+      contentText.appendChild(
+        document.createTextNode(segment.substring(rowStart))
+      );
+      columnPos += segment.length - rowStart;
+      textOffset += segment.length;
+    }
+    if (textOffset < text.length) {
+      // Handle the special character at |textOffset|.
+      if (text.startsWith('\t', textOffset)) {
+        // Append a single '\t' character.
+        let effectiveTabSize = tabSize - (columnPos % tabSize);
+        if (columnPos + effectiveTabSize > lineLimit) {
+          contentText.appendChild(createLineBreak(responsiveMode));
+          columnPos = 0;
+          effectiveTabSize = tabSize;
+        }
+        contentText.appendChild(createTabWrapper(effectiveTabSize));
+        columnPos += effectiveTabSize;
+        textOffset++;
+      } else {
+        // Append a single surrogate pair.
+        if (columnPos >= lineLimit) {
+          contentText.appendChild(createLineBreak(responsiveMode));
+          columnPos = 0;
+        }
+        contentText.appendChild(
+          document.createTextNode(text.substring(textOffset, textOffset + 2))
+        );
+        textOffset += 2;
+        columnPos += 1;
+      }
+    }
+  }
+  return contentText;
+}
+
+/**
+ * Given the number of a base line and the BlameInfo create a <span> element
+ * with a hovercard. This is supposed to be put into a <td> cell of the diff.
+ */
+export function createBlameElement(
+  lineNum: LineNumber,
+  commit: BlameInfo
+): HTMLElement {
+  const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
+
+  const date = new Date(commit.time * 1000).toLocaleDateString();
+  const blameNode = createElementDiff(
+    'span',
+    isStartOfRange ? 'startOfRange' : ''
+  );
+
+  const shaNode = createElementDiff('a', 'blameDate');
+  shaNode.innerText = `${date}`;
+  shaNode.setAttribute('href', `${getBaseUrl()}/q/${commit.id}`);
+  blameNode.appendChild(shaNode);
+
+  const shortName = commit.author.split(' ')[0];
+  const authorNode = createElementDiff('span', 'blameAuthor');
+  authorNode.innerText = ` ${shortName}`;
+  blameNode.appendChild(authorNode);
+
+  const hoverCardFragment = createElementDiff('span', 'blameHoverCard');
+  hoverCardFragment.innerText = `Commit ${commit.id}
+Author: ${commit.author}
+Date: ${date}
+
+${commit.commit_msg}`;
+  const hovercard = createElementDiff('gr-hovercard');
+  hovercard.appendChild(hoverCardFragment);
+  blameNode.appendChild(hovercard);
+
+  return blameNode;
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
new file mode 100644
index 0000000..600913e
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -0,0 +1,153 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import {createElementDiff, formatText, createTabWrapper} from './gr-diff-utils';
+
+const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
+
+suite('gr-diff-utils tests', () => {
+  test('createElementDiff classStr applies all classes', () => {
+    const node = createElementDiff('div', 'test classes');
+    assert.isTrue(node.classList.contains('gr-diff'));
+    assert.isTrue(node.classList.contains('test'));
+    assert.isTrue(node.classList.contains('classes'));
+  });
+
+  test('formatText newlines 1', () => {
+    let text = 'abcdef';
+
+    assert.equal(formatText(text, 'NONE', 4, 10).innerHTML, text);
+    text = 'a'.repeat(20);
+    assert.equal(
+      formatText(text, 'NONE', 4, 10).innerHTML,
+      'a'.repeat(10) + LINE_BREAK_HTML + 'a'.repeat(10)
+    );
+  });
+
+  test('formatText newlines 2', () => {
+    const text = '<span class="thumbsup">👍</span>';
+    assert.equal(
+      formatText(text, 'NONE', 4, 10).innerHTML,
+      '&lt;span clas' +
+        LINE_BREAK_HTML +
+        's="thumbsu' +
+        LINE_BREAK_HTML +
+        'p"&gt;👍&lt;/span' +
+        LINE_BREAK_HTML +
+        '&gt;'
+    );
+  });
+
+  test('formatText newlines 3', () => {
+    const text = '01234\t56789';
+    assert.equal(
+      formatText(text, 'NONE', 4, 10).innerHTML,
+      '01234' + createTabWrapper(3).outerHTML + '56' + LINE_BREAK_HTML + '789'
+    );
+  });
+
+  test('formatText newlines 4', () => {
+    const text = '👍'.repeat(58);
+    assert.equal(
+      formatText(text, 'NONE', 4, 20).innerHTML,
+      '👍'.repeat(20) +
+        LINE_BREAK_HTML +
+        '👍'.repeat(20) +
+        LINE_BREAK_HTML +
+        '👍'.repeat(18)
+    );
+  });
+
+  test('tab wrapper style', () => {
+    const pattern = new RegExp(
+      '^<span class="style-scope gr-diff tab" ' +
+        'style="((?:-moz-)?tab-size: (\\d+);.?)+">\\t<\\/span>$'
+    );
+
+    for (const size of [1, 3, 8, 55]) {
+      const html = createTabWrapper(size).outerHTML;
+      expect(html).to.match(pattern);
+      assert.equal(html.match(pattern)?.[2], size.toString());
+    }
+  });
+
+  test('tab wrapper insertion', () => {
+    const html = 'abc\tdef';
+    const tabSize = 8;
+    const wrapper = createTabWrapper(tabSize - 3);
+    assert.ok(wrapper);
+    assert.equal(wrapper.innerText, '\t');
+    assert.equal(
+      formatText(html, 'NONE', tabSize, Infinity).innerHTML,
+      'abc' + wrapper.outerHTML + 'def'
+    );
+  });
+
+  test('escaping HTML', () => {
+    let input = '<script>alert("XSS");<' + '/script>';
+    let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
+
+    let result = formatText(
+      input,
+      'NONE',
+      1,
+      Number.POSITIVE_INFINITY
+    ).innerHTML;
+    assert.equal(result, expected);
+
+    input = '& < > " \' / `';
+    expected = '&amp; &lt; &gt; " \' / `';
+    result = formatText(input, 'NONE', 1, Number.POSITIVE_INFINITY).innerHTML;
+    assert.equal(result, expected);
+  });
+
+  test('text length with tabs and unicode', () => {
+    function expectTextLength(text: string, tabSize: number, expected: number) {
+      // Formatting to |expected| columns should not introduce line breaks.
+      const result = formatText(text, 'NONE', tabSize, expected);
+      assert.isNotOk(
+        result.querySelector('.contentText > .br'),
+        '  Expected the result of: \n' +
+          `      _formatText(${text}', 'NONE',  ${tabSize}, ${expected})\n` +
+          '  to not contain a br. But the actual result HTML was:\n' +
+          `      '${result.innerHTML}'\nwhereupon`
+      );
+
+      // Increasing the line limit should produce the same markup.
+      assert.equal(
+        formatText(text, 'NONE', tabSize, Infinity).innerHTML,
+        result.innerHTML
+      );
+      assert.equal(
+        formatText(text, 'NONE', tabSize, expected + 1).innerHTML,
+        result.innerHTML
+      );
+
+      // Decreasing the line limit should introduce line breaks.
+      if (expected > 0) {
+        const tooSmall = formatText(text, 'NONE', tabSize, expected - 1);
+        assert.isOk(
+          tooSmall.querySelector('.contentText > .br'),
+          '  Expected the result of: \n' +
+            `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
+            '  to contain a br. But the actual result HTML was:\n' +
+            `      '${tooSmall.innerHTML}'\nwhereupon`
+        );
+      }
+    }
+    expectTextLength('12345', 4, 5);
+    expectTextLength('\t\t12', 4, 10);
+    expectTextLength('abc💢123', 4, 7);
+    expectTextLength('abc\t', 8, 8);
+    expectTextLength('abc\t\t', 10, 20);
+    expectTextLength('', 10, 0);
+    // 17 Thai combining chars.
+    expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
+    expectTextLength('abc\tde', 10, 12);
+    expectTextLength('abc\tde\t', 10, 20);
+    expectTextLength('\t\t\t\t\t', 20, 100);
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
similarity index 90%
rename from polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
rename to polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index 6003a2f..34c2a33 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -15,8 +15,8 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
+import '../../../elements/shared/gr-button/gr-button';
+import '../../../elements/shared/gr-icons/gr-icons';
 import '../gr-diff-builder/gr-diff-builder-element';
 import '../gr-diff-highlight/gr-diff-highlight';
 import '../gr-diff-selection/gr-diff-selection';
@@ -37,15 +37,12 @@
   isLongCommentRange,
   isThreadEl,
   rangesEqual,
+  getResponsiveMode,
+  isResponsive,
 } from './gr-diff-utils';
 import {getHiddenScroll} from '../../../scripts/hiddenscroll';
 import {customElement, observe, property} from '@polymer/decorators';
-import {
-  BlameInfo,
-  CommentRange,
-  ImageInfo,
-  NumericChangeId,
-} from '../../../types/common';
+import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
 import {
   DiffInfo,
   DiffPreferencesInfo,
@@ -78,18 +75,15 @@
   CreateCommentEventDetail as CreateCommentEventDetailApi,
   RenderPreferences,
   GrDiff as GrDiffApi,
+  DisplayLine,
 } from '../../../api/diff';
 import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {
-  DiffContextExpandedEventDetail,
-  getResponsiveMode,
-  isResponsive,
-} from '../gr-diff-builder/gr-diff-builder';
+import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
 
-const NO_NEWLINE_BASE = 'No newline at end of base file.';
-const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
+const NO_NEWLINE_LEFT = 'No newline at end of left file.';
+const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
 
 const LARGE_DIFF_THRESHOLD_LINES = 10000;
 const FULL_CONTEXT = -1;
@@ -104,14 +98,8 @@
  */
 const COMMIT_MSG_LINE_LENGTH = 72;
 
-export interface LineOfInterest {
-  number: number;
-  leftSide: boolean;
-}
-
 export interface GrDiff {
   $: {
-    highlights: GrDiffHighlight;
     diffBuilder: GrDiffBuilderElement;
     diffTable: HTMLTableElement;
   };
@@ -160,9 +148,6 @@
    * @event diff-context-expanded
    */
 
-  @property({type: String})
-  changeNum?: NumericChangeId;
-
   @property({type: Boolean})
   noAutoRender = false;
 
@@ -203,8 +188,8 @@
   @property({type: String, observer: '_viewModeObserver'})
   viewMode = DiffViewMode.SIDE_BY_SIDE;
 
-  @property({type: Object})
-  lineOfInterest?: LineOfInterest;
+  @property({type: Object, observer: '_lineOfInterestObserver'})
+  lineOfInterest?: DisplayLine;
 
   /**
    * True when diff is changed, until the content is done rendering.
@@ -309,6 +294,10 @@
 
   private renderDiffTableTask?: DelayedTask;
 
+  private diffSelection = new GrDiffSelection();
+
+  private highlights = new GrDiffHighlight();
+
   constructor() {
     super();
     this._setLoading(true);
@@ -330,13 +319,13 @@
     this.renderDiffTableTask?.cancel();
     this._unobserveIncrementalNodes();
     this._unobserveNodes();
+    this.diffSelection.cleanup();
+    this.highlights.cleanup();
     super.disconnectedCallback();
   }
 
   getLineNumEls(side: Side): HTMLElement[] {
-    return Array.from(
-      this.root?.querySelectorAll<HTMLElement>(`.lineNum.${side}`) ?? []
-    );
+    return this.$.diffBuilder.getLineNumEls(side);
   }
 
   showNoChangeMessage(
@@ -374,7 +363,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 = () => {
@@ -382,7 +371,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. */
@@ -409,8 +398,6 @@
     });
   }
 
-  // TODO(brohlfs): Rewrite gr-diff to be agnostic of GrCommentThread, because
-  // other users of gr-diff may use different comment widgets.
   _updateRanges(
     addedThreadEls: GrDiffThreadElement[],
     removedThreadEls: GrDiffThreadElement[]
@@ -423,7 +410,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.
@@ -449,7 +436,6 @@
       this.push('_commentRanges', {
         side: Side.RIGHT,
         range: this.highlightRange,
-        hovering: true,
         rootId: '',
       });
     }
@@ -463,8 +449,8 @@
   _computeKeyLocations() {
     const keyLocations: KeyLocations = {left: {}, right: {}};
     if (this.lineOfInterest) {
-      const side = this.lineOfInterest.leftSide ? Side.LEFT : Side.RIGHT;
-      keyLocations[side][this.lineOfInterest.number] = true;
+      const side = this.lineOfInterest.side;
+      keyLocations[side][this.lineOfInterest.lineNum] = true;
     }
     const threadEls = (dom(this) as PolymerDomWrapper)
       .getEffectiveChildNodes()
@@ -504,19 +490,20 @@
   getCursorStops(): Array<HTMLElement | AbortStop> {
     if (this.hidden && this.noAutoRender) return [];
 
-    if (this.loading) {
-      return [new AbortStop()];
-    }
+    // Get rendered stops.
+    const stops: Array<HTMLElement | AbortStop> =
+      this.$.diffBuilder.getLineNumberRows();
 
-    return Array.from(
-      this.root?.querySelectorAll<HTMLElement>(
-        ':not(.contextControl) > .diff-row'
-      ) || []
-    ).filter(tr => tr.querySelector('button'));
+    // 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) {
+      stops.push(new AbortStop());
+    }
+    return stops;
   }
 
   isRangeSelected() {
-    return !!this.$.highlights.selectedRange;
+    return !!this.highlights.selectedRange;
   }
 
   toggleLeftDiff() {
@@ -547,11 +534,6 @@
     return classes.join(' ');
   }
 
-  _handleDiffContextExpanded(e: CustomEvent<DiffContextExpandedEventDetail>) {
-    // Don't stop propagation. The host may listen for reporting or resizing.
-    this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
-  }
-
   _handleTap(e: CustomEvent) {
     const el = (dom(e) as EventApi).localTarget as Element;
 
@@ -613,7 +595,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);
@@ -657,17 +639,13 @@
     );
   }
 
-  _getThreadGroupForLine(contentEl: Element) {
-    return contentEl.querySelector('.thread-group');
-  }
-
   /**
    * Gets or creates a comment thread group for a specific line and side on a
    * diff.
    */
   _getOrCreateThreadGroup(contentEl: Element, commentSide: Side) {
     // Check if thread group exists.
-    let threadGroupEl = this._getThreadGroupForLine(contentEl);
+    let threadGroupEl = contentEl.querySelector('.thread-group');
     if (!threadGroupEl) {
       threadGroupEl = document.createElement('div');
       threadGroupEl.className = 'thread-group';
@@ -716,6 +694,14 @@
     this._prefsChanged(this.prefs);
   }
 
+  _lineOfInterestObserver() {
+    if (this.loading) return;
+    if (!this.lineOfInterest) return;
+    const lineNum = this.lineOfInterest.lineNum;
+    if (typeof lineNum !== 'number') return;
+    this.$.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
+  }
+
   _cleanup() {
     this.cancel();
     this.blame = null;
@@ -777,6 +763,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
@@ -790,7 +780,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';
     }
@@ -812,6 +802,9 @@
     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);
     }
@@ -825,12 +818,15 @@
       this._diffLength = this.getDiffLength(newValue);
       this._debounceRenderDiffTable();
     }
+    if (this.diff) {
+      this.diffSelection.init(this.diff, this.$.diffTable);
+      this.highlights.init(this.$.diffTable, this.$.diffBuilder);
+    }
   }
 
   /**
-   * When called multiple times from the same microtask, will call
-   * _renderDiffTable only once, in the next microtask, unless it is cancelled
-   * before that microtask runs.
+   * When called multiple times from the same task, will call
+   * _renderDiffTable only once, in the next task (scheduled via `setTimeout`).
    *
    * This should be used instead of calling _renderDiffTable directly to
    * render the diff in response to an input change, because there may be
@@ -838,6 +834,14 @@
    * render once.
    */
   _debounceRenderDiffTable() {
+    // at this point gr-diff might be considered as rendered from the outside
+    // (client), although it was not actually rendered. Clients need to know
+    // when it is safe to perform operations like cursor moves, for example,
+    // and if changing an input actually requires a reload of the diff table.
+    // Since `fireEvent` is synchronous it allows clients to be aware when an
+    // async render is needed and that they can wait for a further `render`
+    // event to actually take further action.
+    fireEvent(this, 'render-required');
     this.renderDiffTableTask = debounce(this.renderDiffTableTask, () =>
       this._renderDiffTable()
     );
@@ -862,18 +866,11 @@
     this._showWarning = false;
 
     const keyLocations = this._computeKeyLocations();
-    const bypassPrefs = this._getBypassPrefs(this.prefs);
-    this.$.diffBuilder
-      .render(keyLocations, bypassPrefs, this.renderPrefs)
-      .then(() => {
-        this.dispatchEvent(
-          new CustomEvent('render', {
-            bubbles: true,
-            composed: true,
-            detail: {contentRendered: true},
-          })
-        );
-      });
+    this.$.diffBuilder.prefs = this._getBypassPrefs(this.prefs);
+    this.$.diffBuilder.renderPrefs = this.renderPrefs;
+    this.$.diffBuilder.render(keyLocations).then(() => {
+      fireEvent(this, 'render');
+    });
   }
 
   _handleRenderContent() {
@@ -941,7 +938,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;
@@ -1064,10 +1061,10 @@
   _computeNewlineWarning(warnLeft: boolean, warnRight: boolean) {
     const messages = [];
     if (warnLeft) {
-      messages.push(NO_NEWLINE_BASE);
+      messages.push(NO_NEWLINE_LEFT);
     }
     if (warnRight) {
-      messages.push(NO_NEWLINE_REVISION);
+      messages.push(NO_NEWLINE_RIGHT);
     }
     if (!messages.length) {
       return null;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
similarity index 77%
rename from polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
rename to polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
index 67b7a9f..6d36b89 100644
--- a/polygerrit-ui/app/elements/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,12 +163,92 @@
     .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);
     }
+
+    /*
+      Describes two states of semantic tokens: whenever a token has a
+      definition that can be navigated to (navigable) and whenever
+      the token is actually clickable to perform this navigation.
+    */
+    .semantic-token.navigable {
+      text-decoration-style: dotted;
+      text-decoration-line: underline;
+    }
+    .semantic-token.navigable.clickable {
+      text-decoration-style: solid;
+      cursor: pointer;
+    }
+
     /*
       The file line, which has no contentText, add some margin before the first
       comment. We cannot add padding the container because we only want it if
@@ -211,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
@@ -221,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 {
@@ -254,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);
     }
@@ -338,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';
@@ -387,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;
     }
@@ -423,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;
     }
@@ -534,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;
@@ -555,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 */
@@ -570,48 +697,37 @@
   <div
     class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
     on-click="_handleTap"
-    on-diff-context-expanded="_handleDiffContextExpanded"
   >
-    <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]]"
-          change-num="[[changeNum]]"
-          patch-num="[[patchRange.patchNum]]"
-          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>
+    <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>
 
-          <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>
+    </gr-diff-builder>
   </div>
   <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
     [[_newlineWarning]]
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
similarity index 93%
rename from polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
index 9ee779c..c8d8a2f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
@@ -14,16 +14,17 @@
  * 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 {createDiff} from '../../../test/test-data-generators.js';
 import './gr-diff.js';
 import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
 import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import '@polymer/paper-button/paper-button.js';
+import {Side} from '../../../api/diff.js';
 import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
+import {AbortStop} from '../../../api/core.js';
 
 const basicFixture = fixtureFromElement('gr-diff');
 
@@ -49,21 +50,21 @@
 
     setup(() => {
       element = basicFixture.instantiate();
-      sinon.stub(element.$.highlights, 'handleSelectionChange');
+      sinon.stub(element.highlights, 'handleSelectionChange');
     });
 
     test('enabled if logged in', async () => {
       element.loggedIn = true;
       emulateSelection();
       await flush();
-      assert.isTrue(element.$.highlights.handleSelectionChange.called);
+      assert.isTrue(element.highlights.handleSelectionChange.called);
     });
 
     test('ignored if logged out', async () => {
       element.loggedIn = false;
       emulateSelection();
       await flush();
-      assert.isFalse(element.$.highlights.handleSelectionChange.called);
+      assert.isFalse(element.highlights.handleSelectionChange.called);
     });
   });
 
@@ -123,14 +124,14 @@
       element.viewMode = 'SIDE_BY_SIDE';
       flush();
       assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 48px + 1px + 2px)');
+          'calc(2 * 80ch + 2 * 48px + 0ch + 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)');
+          'calc(1 * 80ch + 2 * 48px + 0ch + 1px + 2px)');
     });
 
     test('max-width considers font-size', () => {
@@ -138,7 +139,14 @@
       flush();
       // Each line number column: 4 * 13 = 52px
       assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 52px + 1px + 2px)');
+          '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)');
     });
   });
 
@@ -182,20 +190,18 @@
       element.changeNum = 123;
       element.patchRange = {basePatchNum: 1, patchNum: 2};
       element.path = 'file.txt';
-
-      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
-          getMockDiffResponse(), {...MINIMAL_PREFS});
+      element.$.diffBuilder.diff = createDiff();
+      element.$.diffBuilder.prefs = {...MINIMAL_PREFS};
+      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder();
 
       // No thread groups.
-      assert.isNotOk(element._getThreadGroupForLine(contentEl));
+      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.isOk(element._getThreadGroupForLine(contentEl));
-
       assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
     });
 
@@ -214,7 +220,6 @@
           type: 'image/bmp',
         };
 
-        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
         element.isImageDiff = true;
         element.prefs = {
           context: 10,
@@ -496,21 +501,6 @@
       await promise;
     });
 
-    test('_handleTap context', async () => {
-      const showContextStub =
-          sinon.stub(element.$.diffBuilder, 'showContext');
-      const el = document.createElement('div');
-      el.className = 'showContext';
-      const promise = mockPromise();
-      el.addEventListener('click', e => {
-        element._handleDiffContextExpanded(e);
-        assert.isTrue(showContextStub.called);
-        promise.resolve();
-      });
-      el.click();
-      await promise;
-    });
-
     test('_handleTap content', async () => {
       const content = document.createElement('div');
       const lineEl = document.createElement('div');
@@ -535,7 +525,7 @@
 
     suite('getCursorStops', () => {
       function setupDiff() {
-        element.diff = getMockDiffResponse();
+        element.diff = createDiff();
         element.prefs = {
           context: 10,
           tab_size: 8,
@@ -553,23 +543,38 @@
         };
 
         element._renderDiffTable();
-        element._setLoading(false);
+
         flush();
       }
 
-      test('getCursorStops returns [] when hidden and noAutoRender', () => {
+      test('returns [] when hidden and noAutoRender', () => {
         element.noAutoRender = true;
         setupDiff();
+        element._setLoading(false);
+        flush();
         element.hidden = true;
         assert.equal(element.getCursorStops().length, 0);
       });
 
-      test('getCursorStops', () => {
+      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', () => {
@@ -585,7 +590,6 @@
     setup(() => {
       element = basicFixture.instantiate();
       element.loggedIn = true;
-      element.patchRange = {};
 
       fakeLineEl = {
         getAttribute: sinon.stub().returns(42),
@@ -802,7 +806,7 @@
             return Promise.resolve({});
           });
       sinon.stub(element, 'getDiffLength').returns(10000);
-      element.diff = getMockDiffResponse();
+      element.diff = createDiff();
       element.noRenderOnPrefsChange = true;
     });
 
@@ -858,7 +862,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.equal(element._safetyBypass, -1);
-      assert.equal(renderStub.firstCall.args[1].context, -1);
+      assert.equal(element.$.diffBuilder.prefs.context, -1);
     });
 
     test('toggles collapse context from bypass', async () => {
@@ -871,7 +875,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.isNull(element._safetyBypass);
-      assert.equal(renderStub.firstCall.args[1].context, 3);
+      assert.equal(element.$.diffBuilder.prefs.context, 3);
     });
 
     test('toggles collapse context from pref using default', async () => {
@@ -883,7 +887,7 @@
 
       assert.equal(element.prefs.context, -1);
       assert.equal(element._safetyBypass, 10);
-      assert.equal(renderStub.firstCall.args[1].context, 10);
+      assert.equal(element.$.diffBuilder.prefs.context, 10);
     });
   });
 
@@ -908,8 +912,8 @@
   });
 
   suite('trailing newline warnings', () => {
-    const NO_NEWLINE_BASE = 'No newline at end of base file.';
-    const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
+    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;
@@ -923,41 +927,42 @@
     test('shows combined warning if both sides set to warn', () => {
       element.showNewlineWarningLeft = true;
       element.showNewlineWarningRight = true;
-      assert.include(getWarning(element),
-          NO_NEWLINE_BASE + ' \u2014 ' + NO_NEWLINE_REVISION);// \u2014 - '—'
+      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_BASE);
+        assert.include(getWarning(element), NO_NEWLINE_LEFT);
       });
 
       test('hide warning if false', () => {
         element.showNewlineWarningLeft = false;
-        assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
+        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
       });
 
       test('hide warning if undefined', () => {
         element.showNewlineWarningLeft = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
+        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
       });
     });
 
     suite('showNewlineWarningRight', () => {
       test('show warning if true', () => {
         element.showNewlineWarningRight = true;
-        assert.include(getWarning(element), NO_NEWLINE_REVISION);
+        assert.include(getWarning(element), NO_NEWLINE_RIGHT);
       });
 
       test('hide warning if false', () => {
         element.showNewlineWarningRight = false;
-        assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
+        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
       });
 
       test('hide warning if undefined', () => {
         element.showNewlineWarningRight = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
+        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
       });
     });
 
@@ -1000,7 +1005,7 @@
     });
 
     test('lineOfInterest is a key location', () => {
-      element.lineOfInterest = {number: 789, leftSide: true};
+      element.lineOfInterest = {lineNum: 789, side: Side.LEFT};
       element._renderDiffTable();
       assert.isTrue(renderStub.called);
       assert.deepEqual(renderStub.lastCall.args[0], {
@@ -1211,24 +1216,10 @@
   });
 
   test('getDiffLength', () => {
-    const diff = getMockDiffResponse();
+    const diff = createDiff();
     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));
diff --git a/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
similarity index 96%
rename from polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts
rename to polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
index dcf7236..8ce8ce2 100644
--- a/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts
+++ b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
@@ -55,7 +55,7 @@
   override render() {
     const icon = this.icon ?? '';
     return html` <div class="row">
-      <iron-icon class="icon" .icon=${icon}></iron-icon>
+      <iron-icon class="icon" .icon=${icon} aria-hidden="true"></iron-icon>
       <slot></slot>
     </div>`;
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
similarity index 100%
rename from polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
rename to polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
similarity index 100%
rename from polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
rename to polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
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
new file mode 100644
index 0000000..70cec64
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -0,0 +1,208 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {strToClassName} from '../../../utils/dom-util';
+import {Side} from '../../../constants/constants';
+import {CommentRange} from '../../../types/common';
+import {DiffLayer, DiffLayerListener} from '../../../types/types';
+import {isLongCommentRange} from '../gr-diff/gr-diff-utils';
+
+/**
+ * Enhanced CommentRange by UI state. Interface for incoming ranges set from the
+ * outside.
+ *
+ * TODO(TS): Unify with what is used in gr-diff when these objects are created.
+ */
+export interface CommentRangeLayer {
+  side: Side;
+  range: CommentRange;
+  // 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}`;
+}
+
+/**
+ * This class breaks down all comment ranges into individual line segment
+ * highlights.
+ */
+interface CommentRangeLineLayer {
+  longRange: boolean;
+  id: string;
+  // start char (0-based)
+  start: number;
+  // end char (0-based)
+  end: number;
+}
+
+type LinesMap = {
+  [line in number]: CommentRangeLineLayer[];
+};
+
+type RangesMap = {
+  [side in Side]: LinesMap;
+};
+
+const RANGE_BASE_ONLY = 'style-scope gr-diff range';
+const RANGE_HIGHLIGHT = 'style-scope gr-diff range rangeHighlight';
+// Note that there is also `rangeHoverHighlight` being set by GrDiffHighlight.
+
+export class GrRangedCommentLayer implements DiffLayer {
+  private knownRanges: CommentRangeLayer[] = [];
+
+  private listeners: DiffLayerListener[] = [];
+
+  private rangesMap: RangesMap = {left: {}, right: {}};
+
+  /**
+   * Layer method to add annotations to a line.
+   *
+   * @param el The DIV.contentText element to apply the annotation to.
+   */
+  annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+    let ranges: CommentRangeLineLayer[] = [];
+    if (
+      line.type === GrDiffLineType.REMOVE ||
+      (line.type === GrDiffLineType.BOTH &&
+        el.getAttribute('data-side') !== Side.RIGHT)
+    ) {
+      ranges = this.getRangesForLine(line, Side.LEFT);
+    }
+    if (
+      line.type === GrDiffLineType.ADD ||
+      (line.type === GrDiffLineType.BOTH &&
+        el.getAttribute('data-side') !== Side.LEFT)
+    ) {
+      ranges = this.getRangesForLine(line, Side.RIGHT);
+    }
+
+    for (const range of ranges) {
+      GrAnnotation.annotateElement(
+        el,
+        range.start,
+        range.end - range.start,
+        (range.longRange ? RANGE_BASE_ONLY : RANGE_HIGHLIGHT) +
+          ` ${strToClassName(range.id)}`
+      );
+    }
+  }
+
+  /**
+   * Register a listener for layer updates.
+   */
+  addListener(listener: DiffLayerListener) {
+    this.listeners.push(listener);
+  }
+
+  removeListener(listener: DiffLayerListener) {
+    this.listeners = this.listeners.filter(f => f !== listener);
+  }
+
+  /**
+   * Notify Layer listeners of changes to annotations.
+   */
+  private notifyUpdateRange(start: number, end: number, side: Side) {
+    for (const listener of this.listeners) {
+      listener(start, end, side);
+    }
+  }
+
+  updateRanges(newRanges: CommentRangeLayer[]) {
+    for (const newRange of newRanges) {
+      if (this.knownRanges.some(equals(newRange))) continue;
+      this.addRange(newRange);
+    }
+
+    for (const knownRange of this.knownRanges) {
+      if (newRanges.some(equals(knownRange))) continue;
+      this.removeRange(knownRange);
+    }
+
+    this.knownRanges = [...newRanges];
+  }
+
+  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;
+    operation: (
+      forLine: CommentRangeLineLayer[],
+      start: number,
+      end: number
+    ) => void;
+  }) {
+    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);
+    }
+    this.notifyUpdateRange(range.start_line, range.end_line, side);
+  }
+
+  // visible for testing
+  getRangesForLine(line: GrDiffLine, side: Side): CommentRangeLineLayer[] {
+    const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
+    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;
+      }
+
+      return range;
+    });
+  }
+}
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/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
similarity index 85%
rename from polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
rename to polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
index fb20a91..6db361d 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
@@ -17,11 +17,6 @@
 
 import {css} from 'lit';
 
-// 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 {};
-
 const $_documentContainer = document.createElement('template');
 
 export const grRangedCommentTheme = css`
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
new file mode 100644
index 0000000..bb6d1e9
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -0,0 +1,138 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../elements/shared/gr-tooltip/gr-tooltip';
+import {GrTooltip} from '../../../elements/shared/gr-tooltip/gr-tooltip';
+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 {
+    'gr-selection-action-box': GrSelectionActionBox;
+  }
+}
+
+@customElement('gr-selection-action-box')
+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));
+  }
+
+  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) {
+    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;
+    }
+    this.style.top = `${rect.top - parentRect.top - boxRect.height - 6}px`;
+    this.style.left = `${
+      rect.left - parentRect.left + (rect.width - boxRect.width) / 2
+    }px`;
+    this.invisible = false;
+  }
+
+  async placeBelow(el: Text | Element | Range) {
+    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;
+    }
+    this.style.top = `${rect.top - parentRect.top + boxRect.height - 6}px`;
+    this.style.left = `${
+      rect.left - parentRect.left + (rect.width - boxRect.width) / 2
+    }px`;
+    this.invisible = false;
+  }
+
+  private getParentBoundingClientRect() {
+    // With native shadow DOM, the parent is the shadow root, not the gr-diff
+    // element
+    if (this.parentElement) {
+      return this.parentElement.getBoundingClientRect();
+    }
+    if (this.parentNode !== null) {
+      return (this.parentNode as ShadowRoot).host.getBoundingClientRect();
+    }
+    return null;
+  }
+
+  // visible for testing
+  getTargetBoundingRect(el: Text | Element | Range) {
+    let rect;
+    if (el instanceof Text) {
+      const range = document.createRange();
+      range.selectNode(el);
+      rect = range.getBoundingClientRect();
+      range.detach();
+    } else {
+      rect = el.getBoundingClientRect();
+    }
+    return rect;
+  }
+
+  // visible for testing
+  handleMouseDown(e: MouseEvent) {
+    if (e.button !== 0) {
+      return;
+    } // 0 = main button
+    e.preventDefault();
+    e.stopPropagation();
+    fireEvent(this, 'create-comment-requested');
+  }
+}
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
new file mode 100644
index 0000000..a92c967
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
@@ -0,0 +1,142 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import './gr-selection-action-box';
+import {GrSelectionActionBox} from './gr-selection-action-box';
+import {queryAndAssert} from '../../../test/test-utils';
+import {fixture, html} from '@open-wc/testing-helpers';
+
+suite('gr-selection-action-box', () => {
+  let container: HTMLDivElement;
+  let element: GrSelectionActionBox;
+  let dispatchEventStub: sinon.SinonStub;
+
+  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', () => {
+    const event = new KeyboardEvent('keydown', {key: 'a'});
+    document.body.dispatchEvent(event);
+    assert.isFalse(dispatchEventStub.called);
+  });
+
+  suite('mousedown reacts only to main button', () => {
+    let e: any;
+
+    setup(() => {
+      e = {
+        button: 0,
+        preventDefault: sinon.stub(),
+        stopPropagation: sinon.stub(),
+      };
+    });
+
+    test('event handled if main button', () => {
+      element.handleMouseDown(e);
+      assert.isTrue(e.preventDefault.called);
+      assert.equal(
+        dispatchEventStub.lastCall.args[0].type,
+        'create-comment-requested'
+      );
+    });
+
+    test('event ignored if not main button', () => {
+      e.button = 1;
+      element.handleMouseDown(e);
+      assert.isFalse(e.preventDefault.called);
+      assert.isFalse(dispatchEventStub.called);
+    });
+  });
+
+  suite('placeAbove', () => {
+    let target: HTMLDivElement;
+    let getTargetBoundingRectStub: sinon.SinonStub;
+
+    setup(() => {
+      target = queryAndAssert<HTMLDivElement>(container, '.target');
+      sinon.stub(container, 'getBoundingClientRect').returns({
+        top: 1,
+        bottom: 2,
+        left: 3,
+        right: 4,
+        width: 50,
+        height: 6,
+      } as DOMRect);
+      getTargetBoundingRectStub = sinon
+        .stub(element, 'getTargetBoundingRect')
+        .returns({
+          top: 42,
+          bottom: 20,
+          left: 30,
+          right: 40,
+          width: 100,
+          height: 60,
+        } as DOMRect);
+      assert.isOk(element.tooltip);
+      sinon
+        .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');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('placeAbove for Text Node argument', async () => {
+      await element.placeAbove(target.firstElementChild!);
+      assert.equal(element.style.top, '25px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('placeBelow for Element argument', async () => {
+      await element.placeBelow(target);
+      assert.equal(element.style.top, '45px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('placeBelow for Text Node argument', async () => {
+      await element.placeBelow(target.firstElementChild!);
+      assert.equal(element.style.top, '45px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('uses document.createRange', async () => {
+      const createRangeSpy = sinon.spy(document, 'createRange');
+      getTargetBoundingRectStub.restore();
+      await element.placeAbove(target.firstChild as HTMLElement);
+      assert.isTrue(createRangeSpy.called);
+    });
+  });
+});
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
new file mode 100644
index 0000000..9938d34
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -0,0 +1,318 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {FILE, GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
+import {DiffLayer, DiffLayerListener} from '../../../types/types';
+import {Side} from '../../../constants/constants';
+import {getAppContext} from '../../../services/app-context';
+import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
+import {CancelablePromise, util} 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-';
+
+const CLASS_SAFELIST = new Set<string>([
+  'attr',
+  'attribute',
+  'built_in',
+  'bullet',
+  'code',
+  'comment',
+  'doctag',
+  'emphasis',
+  'formula',
+  'function',
+  'keyword',
+  'link',
+  'literal',
+  'meta',
+  'meta-keyword',
+  'name',
+  '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 {
+  diff?: DiffInfo;
+
+  enabled = true;
+
+  // private, but visible for testing
+  leftRanges: 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;
+
+  private readonly reportingService = getAppContext().reportingService;
+
+  setEnabled(enabled: boolean) {
+    this.enabled = enabled;
+  }
+
+  addListener(listener: DiffLayerListener) {
+    this.listeners.push(listener);
+  }
+
+  removeListener(listener: DiffLayerListener) {
+    this.listeners = this.listeners.filter(f => f !== listener);
+  }
+
+  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;
+
+    let side: Side | undefined;
+    if (
+      line.type === GrDiffLineType.REMOVE ||
+      (line.type === GrDiffLineType.BOTH &&
+        el.getAttribute('data-side') !== Side.RIGHT)
+    ) {
+      side = Side.LEFT;
+    } else if (
+      line.type === GrDiffLineType.ADD ||
+      el.getAttribute('data-side') !== Side.LEFT
+    ) {
+      side = Side.RIGHT;
+    }
+    if (!side) return;
+
+    const isLeft = side === Side.LEFT;
+    const lineNumber = isLeft ? line.beforeNumber : line.afterNumber;
+    const rangesPerLine = isLeft ? this.leftRanges : this.rightRanges;
+    const ranges = rangesPerLine[lineNumber - 1]?.ranges ?? [];
+
+    for (const range of ranges) {
+      if (!CLASS_SAFELIST.has(range.className)) continue;
+      if (range.length === 0) continue;
+      GrAnnotation.annotateElement(
+        el,
+        range.start,
+        range.length,
+        CLASS_PREFIX + range.className
+      );
+    }
+  }
+
+  _getLanguage(metaInfo?: DiffFileMetaInfo) {
+    if (!metaInfo) return undefined;
+    // 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);
+  }
+
+  /**
+   * Computes SyntaxLayerLines asynchronously, which can then later be applied,
+   * when the annotate() method of the layer API is called.
+   *
+   * 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.
+   *
+   * 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(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);
+    const rightLanguage = this._getLanguage(this.diff.meta_b);
+
+    let leftContent = '';
+    let rightContent = '';
+    for (const chunk of this.diff.content) {
+      const a = [...(chunk.a ?? []), ...(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();
+
+    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 = [];
+    }
+  }
+
+  highlight(
+    language?: string,
+    code?: string
+  ): CancelablePromise<SyntaxLayerLine[]> {
+    const hlPromise = this.highlightService.highlight(language, code);
+    return util.makeCancelable(hlPromise);
+  }
+
+  notify() {
+    // We don't want to notify for lines that don't have any SyntaxLayerRange.
+    // So for both sides we are looking for the first and the last occurrence
+    // of a line with at least one SyntaxLayerRange.
+    const leftRangesReversed = [...this.leftRanges].reverse();
+    const leftStart = this.leftRanges.findIndex(line => line.ranges.length > 0);
+    const leftEnd =
+      this.leftRanges.length -
+      1 -
+      leftRangesReversed.findIndex(line => line.ranges.length > 0);
+
+    const rightRangesReversed = [...this.rightRanges].reverse();
+    const rightStart = this.rightRanges.findIndex(
+      line => line.ranges.length > 0
+    );
+    const rightEnd =
+      this.rightRanges.length -
+      1 -
+      rightRangesReversed.findIndex(line => line.ranges.length > 0);
+
+    for (const listener of this.listeners) {
+      if (leftStart > -1) listener(leftStart + 1, leftEnd + 1, Side.LEFT);
+      if (rightStart > -1) listener(rightStart + 1, rightEnd + 1, Side.RIGHT);
+    }
+  }
+}
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
new file mode 100644
index 0000000..ee82d5d
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
@@ -0,0 +1,153 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+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';
+
+const diff: DiffInfo = {
+  meta_a: {
+    name: 'somepath/somefile.js',
+    content_type: 'text/javascript',
+    lines: 3,
+    language: 'lang-left',
+  },
+  meta_b: {
+    name: 'somepath/somefile.js',
+    content_type: 'text/javascript',
+    lines: 4,
+    language: 'lang-right',
+  },
+  change_type: 'MODIFIED',
+  intraline_status: 'OK',
+  content: [
+    {
+      ab: ['import it;'],
+    },
+    {
+      b: ['b only'],
+    },
+    {
+      ab: ['  public static final {', 'ab3'],
+    },
+  ],
+};
+
+const leftRanges: SyntaxLayerLine[] = [
+  {ranges: [{start: 0, length: 6, className: 'literal'}]},
+  {ranges: []},
+  {ranges: []},
+];
+
+const rightRanges: SyntaxLayerLine[] = [
+  {ranges: []},
+  {ranges: []},
+  {
+    ranges: [
+      {start: 0, length: 2, className: 'not-safe'},
+      {start: 2, length: 6, className: 'literal'},
+      {start: 9, length: 6, className: 'keyword'},
+      {start: 16, length: 5, className: 'name'},
+    ],
+  },
+  {ranges: []},
+];
+
+suite('gr-syntax-layer-worker tests', () => {
+  let layer: GrSyntaxLayerWorker;
+  let listener: sinon.SinonStub;
+
+  const annotate = (side: Side, lineNumber: number, text: string) => {
+    const el = document.createElement('div');
+    const lineNumberEl = document.createElement('td');
+    el.setAttribute('data-side', side);
+    el.innerText = text;
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    if (side === Side.LEFT) line.beforeNumber = lineNumber;
+    if (side === Side.RIGHT) line.afterNumber = lineNumber;
+    layer.annotate(el, lineNumberEl, line);
+    return el;
+  };
+
+  setup(() => {
+    layer = new GrSyntaxLayerWorker();
+  });
+
+  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([]);
+      });
+    });
+
+    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(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-themes/gr-syntax-theme.ts b/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
new file mode 100644
index 0000000..363de13
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
@@ -0,0 +1,147 @@
+/**
+ * @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 {css} from 'lit';
+
+const $_documentContainer = document.createElement('template');
+
+/**
+ * HighlightJS emits the following classes that do not have styles here:
+ *    subst, symbol, class, function, doctag, meta-string, section, name,
+ *    builtin-name, bulletm, code, formula, quote, addition, deletion,
+ *    attribute
+ *
+ * @see {@link http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html}
+ */
+export const grSyntaxTheme = css`
+  .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);
+  }
+  .gr-syntax-number {
+    color: var(--syntax-number-color);
+  }
+  .gr-syntax-params {
+    color: var(--syntax-params-color);
+  }
+  .gr-syntax-property {
+    color: var(--syntax-property-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-template-variable {
+    color: var(--syntax-template-variable-color);
+  }
+  .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);
+  }
+`;
+
+$_documentContainer.innerHTML = `<dom-module id="gr-syntax-theme">
+  <template>
+    <style>
+    ${grSyntaxTheme.cssText}
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
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 6a30477..46bdec8 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -15,18 +15,21 @@
  * limitations under the License.
  */
 
-import {appContext} from '../services/app-context';
+import {create, Registry, Finalizable} from '../services/registry';
+import {AppContext} from '../services/app-context';
+import {AuthService} from '../services/gr-auth/gr-auth';
 import {FlagsService} from '../services/flags/flags';
 import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
-import {AuthService} from '../services/gr-auth/gr-auth';
 
 class MockFlagsService implements FlagsService {
   isEnabled() {
     return false;
   }
 
+  finalize() {}
+
   /**
-   * @returns array of all enabled experiments.
+   * @return array of all enabled experiments.
    */
   get enabledExperiments() {
     return [];
@@ -48,6 +51,8 @@
 
   setup() {}
 
+  finalize() {}
+
   fetch() {
     const blob = new Blob();
     const init = {status: 200, statusText: 'Ack'};
@@ -59,15 +64,38 @@
 // Setup mocks for appContext.
 // This is a temporary solution
 // TODO(dmfilippov): find a better solution for gr-diff
-export function initDiffAppContext() {
-  function setMock(serviceName: string, setupMock: unknown) {
-    Object.defineProperty(appContext, serviceName, {
-      get() {
-        return setupMock;
-      },
-    });
-  }
-  setMock('flagsService', new MockFlagsService());
-  setMock('reportingService', grReportingMock);
-  setMock('authService', new MockAuthService());
+export function createDiffAppContext(): AppContext & Finalizable {
+  const appRegistry: Registry<AppContext> = {
+    flagsService: (_ctx: Partial<AppContext>) => new MockFlagsService(),
+    authService: (_ctx: Partial<AppContext>) => new MockAuthService(),
+    reportingService: (_ctx: Partial<AppContext>) => grReportingMock,
+    eventEmitter: (_ctx: Partial<AppContext>) => {
+      throw new Error('eventEmitter is not implemented');
+    },
+    restApiService: (_ctx: Partial<AppContext>) => {
+      throw new Error('restApiService is not implemented');
+    },
+    jsApiService: (_ctx: Partial<AppContext>) => {
+      throw new Error('jsApiService is not implemented');
+    },
+    storageService: (_ctx: Partial<AppContext>) => {
+      throw new Error('storageService is not implemented');
+    },
+    userModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('userModel is not implemented');
+    },
+    routerModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('routerModel is not implemented');
+    },
+    shortcutsService: (_ctx: Partial<AppContext>) => {
+      throw new Error('shortcutsService is not implemented');
+    },
+    pluginsModel: (_ctx: Partial<AppContext>) => {
+      throw new Error('pluginsModel is not implemented');
+    },
+    highlightService: (_ctx: Partial<AppContext>) => {
+      throw new Error('highlightService is not implemented');
+    },
+  };
+  return create<AppContext>(appRegistry);
 }
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
index 832c931..bb46484 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
@@ -16,14 +16,11 @@
  */
 
 import '../test/common-test-setup-karma.js';
-import {appContext} from '../services/app-context.js';
-import {initDiffAppContext} from './gr-diff-app-context-init.js';
-suite('gr diff app context initializer tests', () => {
-  setup(() => {
-    initDiffAppContext();
-  });
+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);
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 422667a4..1b32c55 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -23,16 +23,17 @@
 // exposed by shared gr-diff component.
 import '../api/embed';
 import '../scripts/bundled-polymer';
-import '../elements/diff/gr-diff/gr-diff';
-import '../elements/diff/gr-diff-cursor/gr-diff-cursor';
-import {TokenHighlightLayer} from '../elements/diff/gr-diff-builder/token-highlight-layer';
-import {GrDiffCursor} from '../elements/diff/gr-diff-cursor/gr-diff-cursor';
-import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
-import {initDiffAppContext} from './gr-diff-app-context-init';
+import './diff/gr-diff/gr-diff';
+import './diff/gr-diff-cursor/gr-diff-cursor';
+import {TokenHighlightLayer} from './diff/gr-diff-builder/token-highlight-layer';
+import {GrDiffCursor} from './diff/gr-diff-cursor/gr-diff-cursor';
+import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
+import {createDiffAppContext} from './gr-diff-app-context-init';
+import {injectAppContext} from '../services/app-context';
 
 // Setup appContext for diff.
 // TODO (dmfilippov): find a better solution
-initDiffAppContext();
+injectAppContext(createDiffAppContext());
 // Setup global variables for existing usages of this component
 window.grdiff = {
   GrAnnotation,
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.ts b/polygerrit-ui/app/gr-diff/gr-diff-root.ts
index fbe81fb..7111d80 100644
--- a/polygerrit-ui/app/gr-diff/gr-diff-root.ts
+++ b/polygerrit-ui/app/gr-diff/gr-diff-root.ts
@@ -15,4 +15,4 @@
  * limitations under the License.
  */
 
-import '../elements/diff/gr-diff/gr-diff';
+import '../embed/diff/gr-diff/gr-diff';
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
index fd4da4f..c5985bb 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -22,6 +22,19 @@
 import {debounce, DelayedTask} from '../../utils/async-util';
 import {hovercardStyles} from '../../styles/gr-hovercard-styles';
 import {sharedStyles} from '../../styles/shared-styles';
+import {DependencyRequestEvent} from '../../models/dependency';
+import {
+  addShortcut,
+  findActiveElement,
+  isElementTarget,
+  Key,
+  Modifier,
+} from '../../utils/dom-util';
+import {ShortcutController} from '../../elements/lit/shortcut-controller';
+import {
+  getFocusableElements,
+  getFocusableElementsReverse,
+} from '../../utils/focusable';
 
 interface ReloadEventDetail {
   clearPatchset?: boolean;
@@ -35,6 +48,12 @@
  */
 const containerId = 'gr-hovercard-container';
 
+export interface MouseKeyboardOrFocusEvent {
+  keyboardEvent?: KeyboardEvent;
+  mouseEvent?: MouseEvent;
+  focusEvent?: FocusEvent;
+}
+
 export function getHovercardContainer(
   options: {createIfNotExists: boolean} = {createIfNotExists: false}
 ): HTMLElement | null {
@@ -126,6 +145,13 @@
 
     isScheduledToHide?: boolean;
 
+    openedByKeyboard = false;
+
+    private targetCleanups: Array<() => void> = [];
+
+    /** Called in disconnectedCallback. */
+    private cleanups: (() => void)[] = [];
+
     static get styles() {
       return [sharedStyles, hovercardStyles];
     }
@@ -135,9 +161,13 @@
       super(...args);
       // show the hovercard if mouse moves to hovercard
       // this will cancel pending hide as well
-      this.addEventListener('mouseenter', this.show);
+      this.addEventListener('mouseenter', this.mouseShow);
       // when leave hovercard, hide it immediately
-      this.addEventListener('mouseleave', this.hide);
+      this.addEventListener('mouseleave', this.mouseHide);
+      const keyboardController = new ShortcutController(this);
+      keyboardController.addGlobal({key: Key.ESC}, (e: KeyboardEvent) =>
+        this.hide({keyboardEvent: e})
+      );
     }
 
     override connectedCallback() {
@@ -150,11 +180,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();
     }
 
@@ -164,19 +220,35 @@
       // trigger the hovercard, which can annoying for the user, for example
       // when added reviewer chips appear in the reply dialog via keyboard
       // interaction.
-      this._target?.addEventListener('mousemove', this.debounceShow);
-      this._target?.addEventListener('focus', this.debounceShow);
-      this._target?.addEventListener('mouseleave', this.debounceHide);
-      this._target?.addEventListener('blur', this.debounceHide);
-      this._target?.addEventListener('click', this.hide);
+      this._target?.addEventListener('mousemove', this.mouseDebounceShow);
+      this._target?.addEventListener('mouseleave', this.mouseDebounceHide);
+      this._target?.addEventListener('blur', this.focusDebounceHide);
+      this._target?.addEventListener('click', this.mouseHide);
+      if (this._target) {
+        this.targetCleanups.push(
+          addShortcut(this._target, {key: Key.ENTER}, (e: KeyboardEvent) => {
+            this.show({keyboardEvent: e});
+          })
+        );
+        this.targetCleanups.push(
+          addShortcut(this._target, {key: Key.SPACE}, (e: KeyboardEvent) => {
+            this.show({keyboardEvent: e});
+          })
+        );
+      }
+      this.addEventListener('request-dependency', this.resolveDep);
     }
 
     private removeTargetEventListeners() {
-      this._target?.removeEventListener('mousemove', this.debounceShow);
-      this._target?.removeEventListener('focus', this.debounceShow);
-      this._target?.removeEventListener('mouseleave', this.debounceHide);
-      this._target?.removeEventListener('blur', this.debounceHide);
-      this._target?.removeEventListener('click', this.hide);
+      this._target?.removeEventListener('mousemove', this.mouseDebounceShow);
+      this._target?.removeEventListener('mouseleave', this.mouseDebounceHide);
+      this._target?.removeEventListener('blur', this.focusDebounceHide);
+      this._target?.removeEventListener('click', this.mouseHide);
+      for (const cleanup of this.targetCleanups) {
+        cleanup();
+      }
+      this.targetCleanups = [];
+      this.removeEventListener('request-dependency', this.resolveDep);
     }
 
     /**
@@ -192,7 +264,27 @@
       }
     }
 
-    readonly debounceHide = () => {
+    readonly mouseDebounceHide = (e: MouseEvent) => {
+      this.debounceHide({mouseEvent: e});
+    };
+
+    readonly mouseDebounceShow = (e: MouseEvent) => {
+      this.debounceShow({mouseEvent: e});
+    };
+
+    readonly mouseHide = (e: MouseEvent) => {
+      this.hide({mouseEvent: e});
+    };
+
+    readonly mouseShow = (e: MouseEvent) => {
+      this.show({mouseEvent: e});
+    };
+
+    readonly focusDebounceHide = (e: FocusEvent) => {
+      this.debounceHide({focusEvent: e});
+    };
+
+    readonly debounceHide = (props: MouseKeyboardOrFocusEvent) => {
       this.cancelShowTask();
       if (!this._isShowing || this.isScheduledToHide) return;
       this.isScheduledToHide = true;
@@ -202,7 +294,7 @@
           // This happens when hide immediately through click or mouse leave
           // on the hovercard
           if (!this.isScheduledToHide) return;
-          this.hide();
+          this.hide(props);
         },
         HIDE_DELAY_MS
       );
@@ -264,23 +356,44 @@
       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.
+     */
+    readonly resolveDep = (e: DependencyRequestEvent<unknown>) => {
+      this._target?.dispatchEvent(
+        new DependencyRequestEvent<unknown>(e.dependency, e.callback)
+      );
+    };
+
+    readonly forceHide = () => {
+      this.hide({keyboardEvent: new KeyboardEvent('enter')});
+    };
+
     /**
      * Hides/closes the hovercard. This occurs when the user triggers the
      * `mouseleave` event on the hovercard's `target` element (as long as the
-     * user is not hovering over the hovercard).
-     *
+     * user is not hovering over the hovercard). If event is not specified
+     * in props, code assumes mouseEvent
      */
-    readonly hide = (e?: MouseEvent) => {
+    readonly hide = (props: MouseKeyboardOrFocusEvent) => {
       this.cancelHideTask();
       this.cancelShowTask();
       if (!this._isShowing) {
         return;
       }
-
-      // If the user is now hovering over the hovercard or the user is returning
-      // from the hovercard but now hovering over the target (to stop an annoying
-      // flicker effect), just return.
-      if (e) {
+      if (!props?.keyboardEvent && this.openedByKeyboard) return;
+      // If the user is clicking on a link and still hovering over the hovercard
+      // or the user is returning from the hovercard but now hovering over the
+      // target (to stop an annoying flicker effect), just return.
+      if (props?.mouseEvent) {
+        const e = props.mouseEvent;
         if (
           e.relatedTarget === this ||
           (e.target === this && e.relatedTarget === this._target)
@@ -288,6 +401,14 @@
           return;
         }
       }
+      if (this.openedByKeyboard) {
+        if (this._target) {
+          this._target.focus();
+        }
+      }
+      // Make sure to reset the keyboard variable so new shows will not
+      // assume keyboard is the reason for opening the hovercard.
+      this.openedByKeyboard = false;
 
       // Mark that the hovercard is not visible and do not allow focusing
       this._isShowing = false;
@@ -304,19 +425,20 @@
       if (this.container?.contains(this)) {
         this.container.removeChild(this);
       }
+      document.removeEventListener('click', this.documentClickListener);
     };
 
     /**
      * Shows/opens the hovercard with a fixed delay.
      */
-    readonly debounceShow = () => {
-      this.debounceShowBy(SHOW_DELAY_MS);
+    readonly debounceShow = (props: MouseKeyboardOrFocusEvent) => {
+      this.debounceShowBy(SHOW_DELAY_MS, props);
     };
 
     /**
      * Shows/opens the hovercard with the given delay.
      */
-    debounceShowBy(delayMs: number) {
+    debounceShowBy(delayMs: number, props: MouseKeyboardOrFocusEvent) {
       this.cancelHideTask();
       if (this._isShowing || this.isScheduledToShow) return;
       this.isScheduledToShow = true;
@@ -325,12 +447,35 @@
         () => {
           // This happens when the mouse leaves the target before the delay is over.
           if (!this.isScheduledToShow) return;
-          this.show();
+          this.show(props);
         },
         delayMs
       );
     }
 
+    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();
@@ -340,18 +485,22 @@
 
     /**
      * Shows/opens the hovercard. This occurs when the user triggers the
-     * `mousenter` event on the hovercard's `target` element.
+     * `mousenter` event on the hovercard's `target` element or when a user
+     * presses enter/space on the hovercard's `target` element. If event is not
+     * specified in props, code assumes mouseEvent
      */
-    readonly show = async () => {
+    readonly show = async (props: MouseKeyboardOrFocusEvent) => {
       this.cancelHideTask();
       this.cancelShowTask();
+      // If we are calling show again because of a mouse reason, then keep
+      // the keyboard valuable set.
+      this.openedByKeyboard = this.openedByKeyboard || !!props?.keyboardEvent;
       if (this._isShowing || !this.container) {
         return;
       }
 
       // 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);
@@ -367,6 +516,10 @@
       });
       this.updatePosition();
       this.classList.remove(HIDE_CLASS);
+      if (props?.keyboardEvent) {
+        this.focus();
+      }
+      document.addEventListener('click', this.documentClickListener);
     };
 
     updatePosition() {
@@ -385,6 +538,7 @@
         this.updatePositionTo(position);
         if (this._isInsideViewport()) return;
       }
+      this.updatePositionTo(this.position);
       console.warn('Could not find a visible position for the hovercard.');
     }
 
@@ -478,15 +632,17 @@
   _target: HTMLElement | null;
   _isShowing: boolean;
   dispatchEventThroughTarget(eventName: string, detail?: unknown): void;
-  show(): void;
+  show(props: MouseKeyboardOrFocusEvent): Promise<void>;
+  forceHide(): void;
 
   // Used for tests
-  hide(e: MouseEvent): void;
+  mouseHide(e: MouseEvent): void;
+  hide(props: MouseKeyboardOrFocusEvent): void;
   container: HTMLElement | null;
   hideTask?: DelayedTask;
   showTask?: DelayedTask;
   position: string;
-  debounceShowBy(delayMs: number): void;
+  debounceShowBy(delayMs: number, props: MouseKeyboardOrFocusEvent): void;
   updatePosition(): void;
   isScheduledToShow?: boolean;
   isScheduledToHide?: boolean;
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 dd86b38..88e57a0 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
@@ -19,7 +19,8 @@
 import {HovercardMixin} from './hovercard-mixin.js';
 import {html, LitElement} from 'lit';
 import {customElement} from 'lit/decorators';
-import {MockPromise, mockPromise} from '../../test/test-utils.js';
+import {MockPromise, mockPromise, pressKey} from '../../test/test-utils.js';
+import {findActiveElement, Key} from '../../utils/dom-util.js';
 
 const base = HovercardMixin(LitElement);
 
@@ -37,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>`;
   }
 }
 
@@ -47,7 +51,7 @@
   let element: HovercardMixinTest;
 
   let button: HTMLElement;
-  let testPromise: MockPromise;
+  let testPromise: MockPromise<void>;
 
   setup(() => {
     testPromise = mockPromise();
@@ -60,8 +64,9 @@
   });
 
   teardown(() => {
-    element.hide(new MouseEvent('click'));
-    button?.remove();
+    pressKey(element, Key.ESC);
+    element.mouseHide(new MouseEvent('click'));
+    if (button) button.remove();
   });
 
   test('updatePosition', async () => {
@@ -95,7 +100,7 @@
   });
 
   test('hide', () => {
-    element.hide(new MouseEvent('click'));
+    element.mouseHide(new MouseEvent('click'));
     const style = getComputedStyle(element);
     assert.isFalse(element._isShowing);
     assert.isFalse(element.classList.contains('hovered'));
@@ -104,7 +109,7 @@
   });
 
   test('show', async () => {
-    await element.show();
+    await element.show({});
     await element.updateComplete;
     const style = getComputedStyle(element);
     assert.isTrue(element._isShowing);
@@ -114,14 +119,14 @@
   });
 
   test('debounceShow does not show immediately', async () => {
-    element.debounceShowBy(100);
+    element.debounceShowBy(100, {});
     setTimeout(() => testPromise.resolve(), 0);
     await testPromise;
     assert.isFalse(element._isShowing);
   });
 
   test('debounceShow shows after delay', async () => {
-    element.debounceShowBy(1);
+    element.debounceShowBy(1, {});
     setTimeout(() => testPromise.resolve(), 10);
     await testPromise;
     assert.isTrue(element._isShowing);
@@ -174,4 +179,52 @@
     assert.isFalse(element.isScheduledToShow);
     assert.isFalse(element._isShowing);
   });
+
+  test('do not show on focus', async () => {
+    const button = document.querySelector('button');
+    button?.focus();
+    await element.updateComplete;
+    assert.isNotTrue(element.isScheduledToShow);
+    assert.isFalse(element._isShowing);
+  });
+
+  test('show on pressing enter when focused', async () => {
+    const button = document.querySelector('button')!;
+    button.focus();
+    await element.updateComplete;
+    pressKey(button, Key.ENTER);
+    await element.updateComplete;
+    assert.isTrue(element._isShowing);
+  });
+
+  test('show on pressing space when focused', async () => {
+    const button = document.querySelector('button')!;
+    button.focus();
+    await element.updateComplete;
+    pressKey(button, Key.SPACE);
+    await element.updateComplete;
+    assert.isTrue(element._isShowing);
+  });
+
+  test('when on pressing enter, focus is moved to hovercard', async () => {
+    const button = document.querySelector('button')!;
+    button.focus();
+    await element.updateComplete;
+    await element.show({keyboardEvent: new KeyboardEvent('enter')});
+    await element.updateComplete;
+    assert.isTrue(element._isShowing);
+    const activeElement = findActiveElement(document);
+    assert.equal(activeElement?.id, 'focusable');
+  });
+
+  test('when on mouseEvent, focus is not moved to hovercard', async () => {
+    const button = document.querySelector('button')!;
+    button.focus();
+    await element.updateComplete;
+    await element.show({mouseEvent: new MouseEvent('enter')});
+    await element.updateComplete;
+    assert.isTrue(element._isShowing);
+    const activeElement = findActiveElement(document);
+    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/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index f133c116..8e27c74 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -17,7 +17,7 @@
 import {property} from '@polymer/decorators';
 import {PolymerElement} from '@polymer/polymer';
 import {check, Constructor} from '../../utils/common-util';
-import {appContext} from '../../services/app-context';
+import {getAppContext} from '../../services/app-context';
 import {
   Shortcut,
   ShortcutSection,
@@ -50,7 +50,7 @@
     // This enables `ShortcutSection` to be used in the html template.
     ShortcutSection = ShortcutSection;
 
-    private readonly shortcuts = appContext.shortcutsService;
+    private readonly shortcuts = getAppContext().shortcutsService;
 
     /** Used to disable shortcuts when the element is not visible. */
     private observer?: IntersectionObserver;
diff --git a/polygerrit-ui/app/models/browser/browser-model.ts b/polygerrit-ui/app/models/browser/browser-model.ts
new file mode 100644
index 0000000..490f868
--- /dev/null
+++ b/polygerrit-ui/app/models/browser/browser-model.ts
@@ -0,0 +1,82 @@
+/**
+ * @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 {Observable, combineLatest} from 'rxjs';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+import {Finalizable} from '../../services/registry';
+import {define} from '../dependency';
+import {DiffViewMode} from '../../api/diff';
+import {UserModel} from '../user/user-model';
+import {Model} from '../model';
+
+// This value is somewhat arbitrary and not based on research or calculations.
+const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
+
+export interface BrowserState {
+  /**
+   * We maintain the screen width in the state so that the app can react to
+   * changes in the width such as automatically changing to unified diff view
+   */
+  screenWidth?: number;
+}
+
+const initialState: BrowserState = {};
+
+export const browserModelToken = define<BrowserModel>('browser-model');
+
+export class BrowserModel extends Model<BrowserState> implements Finalizable {
+  readonly diffViewMode$: Observable<DiffViewMode>;
+
+  constructor(readonly userModel: UserModel) {
+    super(initialState);
+    const screenWidth$ = this.state$.pipe(
+      map(
+        state =>
+          !!state.screenWidth &&
+          state.screenWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX
+      ),
+      distinctUntilChanged()
+    );
+    // TODO; Inject the UserModel once preferenceDiffViewMode$ has moved to
+    // the user model.
+    this.diffViewMode$ = combineLatest([
+      screenWidth$,
+      userModel.preferenceDiffViewMode$,
+    ]).pipe(
+      map(([isScreenTooSmall, preferenceDiffViewMode]) => {
+        if (isScreenTooSmall) return DiffViewMode.UNIFIED;
+        else return preferenceDiffViewMode;
+      }),
+      distinctUntilChanged()
+    );
+  }
+
+  /* Observe the screen width so that the app can react to changes to it */
+  observeWidth() {
+    return new ResizeObserver(entries => {
+      entries.forEach(entry => {
+        this.setScreenWidth(entry.contentRect.width);
+      });
+    });
+  }
+
+  // Private but used in tests.
+  setScreenWidth(screenWidth: number) {
+    this.subject$.next({...this.subject$.getValue(), screenWidth});
+  }
+
+  finalize() {}
+}
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
new file mode 100644
index 0000000..b0984c1
--- /dev/null
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -0,0 +1,250 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+  ChangeInfo,
+  NumericChangeId,
+  ChangeStatus,
+  ReviewerState,
+  AccountInfo,
+} from '../../api/rest-api';
+import {Model} from '../model';
+import {Finalizable} from '../../services/registry';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {define} from '../dependency';
+import {select} from '../../utils/observable-util';
+import {ReviewInput, ReviewerInput} from '../../types/common';
+
+export const bulkActionsModelToken =
+  define<BulkActionsModel>('bulk-actions-model');
+
+export enum LoadingState {
+  NOT_SYNCED = 'NOT_SYNCED',
+  LOADING = 'LOADING',
+  LOADED = 'LOADED',
+}
+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
+{
+  constructor(private readonly restApiService: RestApiService) {
+    super(initialState);
+  }
+
+  public readonly selectedChangeNums$ = select(
+    this.state$,
+    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) {
+    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 selectedChangeNums = [...current.selectedChangeNums];
+    selectedChangeNums.push(changeNum);
+    this.setState({...current, selectedChangeNums});
+  }
+
+  removeSelectedChangeNum(changeNum: NumericChangeId) {
+    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 selectedChangeNums = [...current.selectedChangeNums];
+    const index = selectedChangeNums.findIndex(item => item === changeNum);
+    if (index === -1) return;
+    selectedChangeNums.splice(index, 1);
+    this.setState({...current, selectedChangeNums});
+  }
+
+  clearSelectedChangeNums() {
+    this.setState({...this.subject$.getValue(), selectedChangeNums: []});
+  }
+
+  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[]>
+  ): Promise<Response>[] {
+    const current = this.subject$.getValue();
+    const changes = current.selectedChangeNums.map(
+      changeNum => current.allChanges.get(changeNum)!
+    );
+    return changes.map(change => {
+      const reviewersNewToChange = [
+        ReviewerState.REVIEWER,
+        ReviewerState.CC,
+      ].flatMap(state =>
+        this.getNewReviewersToChange(change, state, changedReviewers)
+      );
+      if (reviewersNewToChange.length === 0) {
+        return Promise.resolve(new Response());
+      }
+      const reviewInput: ReviewInput = {
+        reviewers: reviewersNewToChange,
+      };
+      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,
+    });
+
+    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 (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({
+      ...currentState,
+      loadingState: LoadingState.LOADED,
+      allChanges: allDetailedChanges,
+    });
+  }
+
+  /** Required for testing */
+  getState() {
+    return this.subject$.getValue();
+  }
+
+  setState(state: BulkActionsState) {
+    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[]>
+  ): ReviewerInput[] {
+    return (
+      changedReviewers
+        .get(state)
+        ?.filter(account => !change.reviewers[state]?.includes(account))
+        .map(account => {
+          return {state, reviewer: account._account_id!};
+        }) ?? []
+    );
+  }
+
+  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
new file mode 100644
index 0000000..b08455c
--- /dev/null
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -0,0 +1,464 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+  createAccountWithIdNameAndEmail,
+  createChange,
+  createRevisions,
+} from '../../test/test-data-generators';
+import {
+  ChangeInfo,
+  NumericChangeId,
+  ChangeStatus,
+  HttpMethod,
+  SubmitRequirementStatus,
+  AccountInfo,
+  ReviewerState,
+  AccountId,
+} 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} 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;
+  setup(() => {
+    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;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+
+    assert.isEmpty(bulkActionsModel.getState().selectedChangeNums);
+
+    assert.throws(() => bulkActionsModel.addSelectedChangeNum(c1._number));
+    assert.isEmpty(bulkActionsModel.getState().selectedChangeNums);
+
+    bulkActionsModel.sync([c1, c2]);
+
+    bulkActionsModel.addSelectedChangeNum(c2._number);
+    assert.sameMembers(bulkActionsModel.getState().selectedChangeNums, [
+      c2._number,
+    ]);
+
+    bulkActionsModel.removeSelectedChangeNum(c2._number);
+    assert.isEmpty(bulkActionsModel.getState().selectedChangeNums);
+  });
+
+  test('add and remove selected changes', () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+    bulkActionsModel.sync([c1, c2]);
+
+    assert.isEmpty(bulkActionsModel.getState().selectedChangeNums);
+
+    bulkActionsModel.addSelectedChangeNum(c1._number);
+    assert.sameMembers(bulkActionsModel.getState().selectedChangeNums, [
+      c1._number,
+    ]);
+
+    bulkActionsModel.addSelectedChangeNum(c2._number);
+    assert.sameMembers(bulkActionsModel.getState().selectedChangeNums, [
+      c1._number,
+      c2._number,
+    ]);
+
+    bulkActionsModel.removeSelectedChangeNum(c1._number);
+    assert.sameMembers(bulkActionsModel.getState().selectedChangeNums, [
+      c2._number,
+    ]);
+
+    bulkActionsModel.removeSelectedChangeNum(c2._number);
+    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);
+  });
+
+  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 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]]],
+          [ReviewerState.CC, [accounts[1]]],
+        ])
+      );
+
+      // changes[0] is not updated since it already has the reviewer & CC
+      assert.isTrue(saveChangeReviewStub.calledOnce);
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
+        changes[1]._number,
+        'current',
+        {
+          reviewers: [
+            {reviewer: accounts[0]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: accounts[1]._account_id, state: ReviewerState.CC},
+          ],
+        },
+      ]);
+    });
+  });
+
+  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;
+    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.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 () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+
+    assert.equal(
+      bulkActionsModel.getState().loadingState,
+      LoadingState.NOT_SYNCED
+    );
+
+    bulkActionsModel.sync([c1, c2]);
+    await waitUntilObserved(
+      bulkActionsModel.loadingState$,
+      s => s === LoadingState.LOADING
+    );
+
+    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
+    );
+
+    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 () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+
+    const responsePromise1 = mockPromise<ChangeInfo[]>();
+    let promise = responsePromise1;
+    const getChangesStub = stubRestApi(
+      'getDetailedChangesWithActions'
+    ).callsFake(() => promise);
+    bulkActionsModel.sync([c1, c2]);
+    assert.strictEqual(getChangesStub.callCount, 1);
+    await waitUntilObserved(
+      bulkActionsModel.loadingState$,
+      s => s === LoadingState.LOADING
+    );
+    const responsePromise2 = mockPromise<ChangeInfo[]>();
+
+    promise = responsePromise2;
+    bulkActionsModel.sync([c1, c2]);
+    assert.strictEqual(getChangesStub.callCount, 2);
+
+    responsePromise2.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'
+    );
+
+    // Resolve the old promise.
+    responsePromise1.resolve([
+      {...createChange(), _number: 1, subject: 'Subject 1-old'},
+      {...createChange(), _number: 2, subject: 'Subject 2-old'},
+    ] as ChangeInfo[]);
+    await flush();
+    const model2 = bulkActionsModel.getState();
+
+    // No change should happen.
+    assert.strictEqual(
+      model2.allChanges.get(1 as NumericChangeId)?.subject,
+      'Subject 1'
+    );
+    assert.strictEqual(
+      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
new file mode 100644
index 0000000..b9a768c
--- /dev/null
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -0,0 +1,405 @@
+/**
+ * @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 {
+  EditInfo,
+  EditPatchSetNum,
+  NumericChangeId,
+  PatchSetNum,
+} from '../../types/common';
+import {
+  combineLatest,
+  from,
+  fromEvent,
+  Observable,
+  Subscription,
+  forkJoin,
+  of,
+} from 'rxjs';
+import {
+  map,
+  filter,
+  withLatestFrom,
+  distinctUntilChanged,
+  startWith,
+  switchMap,
+} from 'rxjs/operators';
+import {RouterModel} from '../../services/router/router-model';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+} from '../../utils/patch-set-util';
+import {ParsedChangeInfo} from '../../types/types';
+import {fireAlert} from '../../utils/event-util';
+
+import {ChangeInfo} from '../../types/common';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {Finalizable} from '../../services/registry';
+import {select} from '../../utils/observable-util';
+import {assertIsDefined} from '../../utils/common-util';
+import {Model} from '../model';
+import {UserModel} from '../user/user-model';
+import {define} from '../dependency';
+
+export enum LoadingStatus {
+  NOT_LOADED = 'NOT_LOADED',
+  LOADING = 'LOADING',
+  RELOADING = 'RELOADING',
+  LOADED = 'LOADED',
+}
+
+const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
+
+export interface ChangeState {
+  /**
+   * If `change` is undefined, this must be either NOT_LOADED or LOADING.
+   * If `change` is defined, this must be either LOADED or RELOADING.
+   */
+  loadingStatus: LoadingStatus;
+  change?: ParsedChangeInfo;
+  /**
+   * The name of the file user is viewing in the diff view mode. File path is
+   * specified in the url or derived from the commentId.
+   * Does not apply to change-view or edit-view.
+   */
+  diffPath?: string;
+  /**
+   * The list of reviewed files, kept in the model because we want changes made
+   * in one view to reflect on other views without re-rendering the other views.
+   * Undefined means it's still loading and empty set means no files reviewed.
+   */
+  reviewedFiles?: string[];
+}
+
+/**
+ * Updates the change object with information from the saved `edit` patchset.
+ */
+// visible for testing
+export function updateChangeWithEdit(
+  change?: ParsedChangeInfo,
+  edit?: EditInfo,
+  routerPatchNum?: PatchSetNum
+): ParsedChangeInfo | undefined {
+  if (!change || !edit) return change;
+  assertIsDefined(edit.commit.commit, 'edit.commit.commit');
+  if (!change.revisions) change.revisions = {};
+  change.revisions[edit.commit.commit] = {
+    _number: EditPatchSetNum,
+    basePatchNum: edit.base_patch_set_number,
+    commit: edit.commit,
+    fetch: edit.fetch,
+  };
+  // If the change was loaded without a specific patchset, then this normally
+  // means that the *latest* patchset should be loaded. But if there is an
+  // active edit, then automatically switch to that edit as the current
+  // patchset.
+  // TODO: This goes together with `_patchRange.patchNum' being set to `edit`,
+  // which is still done in change-view. `_patchRange.patchNum` should
+  // eventually also be model managed, so we can reconcile these two code
+  // snippets into one location.
+  if (routerPatchNum === undefined) {
+    change.current_revision = edit.commit.commit;
+  }
+  return change;
+}
+
+// TODO: Figure out how to best enforce immutability of all states. Use Immer?
+// Use DeepReadOnly?
+const initialState: ChangeState = {
+  loadingStatus: LoadingStatus.NOT_LOADED,
+};
+
+export const changeModelToken = define<ChangeModel>('change-model');
+
+export class ChangeModel extends Model<ChangeState> implements Finalizable {
+  private change?: ParsedChangeInfo;
+
+  private currentPatchNum?: PatchSetNum;
+
+  public readonly change$ = select(
+    this.state$,
+    changeState => changeState.change
+  );
+
+  public readonly changeLoadingStatus$ = select(
+    this.state$,
+    changeState => changeState.loadingStatus
+  );
+
+  public readonly diffPath$ = select(
+    this.state$,
+    changeState => changeState?.diffPath
+  );
+
+  public readonly reviewedFiles$ = select(
+    this.state$,
+    changeState => changeState?.reviewedFiles
+  );
+
+  public readonly changeNum$ = select(this.change$, change => change?._number);
+
+  public readonly repo$ = select(this.change$, change => change?.project);
+
+  public readonly labels$ = select(this.change$, change => change?.labels);
+
+  public readonly latestPatchNum$ = select(this.change$, change =>
+    computeLatestPatchNum(computeAllPatchSets(change))
+  );
+
+  /**
+   * Emits the current patchset number. If the route does not define the current
+   * patchset num, then this selector waits for the change to be defined and
+   * returns the number of the latest patchset.
+   *
+   * Note that this selector can emit a patchNum without the change being
+   * available!
+   */
+  public readonly currentPatchNum$: Observable<PatchSetNum | undefined> =
+    /**
+     * If you depend on both, router and change state, then you want to filter
+     * out inconsistent state, e.g. router changeNum already updated, change not
+     * yet reset to undefined.
+     */
+    combineLatest([this.routerModel.state$, this.state$])
+      .pipe(
+        filter(([routerState, changeState]) => {
+          const changeNum = changeState.change?._number;
+          const routerChangeNum = routerState.changeNum;
+          return changeNum === undefined || changeNum === routerChangeNum;
+        }),
+        distinctUntilChanged()
+      )
+      .pipe(
+        withLatestFrom(this.routerModel.routerPatchNum$, this.latestPatchNum$),
+        map(([_, routerPatchN, latestPatchN]) => routerPatchN || latestPatchN),
+        distinctUntilChanged()
+      );
+
+  private subscriptions: Subscription[] = [];
+
+  // For usage in `combineLatest` we need `startWith` such that reload$ has an
+  // initial value.
+  private readonly reload$: Observable<unknown> = fromEvent(
+    document,
+    'reload'
+  ).pipe(startWith(undefined));
+
+  constructor(
+    readonly routerModel: RouterModel,
+    readonly restApiService: RestApiService,
+    readonly userModel: UserModel
+  ) {
+    super(initialState);
+    this.subscriptions = [
+      combineLatest([this.routerModel.routerChangeNum$, this.reload$])
+        .pipe(
+          map(([changeNum, _]) => changeNum),
+          switchMap(changeNum => {
+            if (changeNum !== undefined) this.updateStateLoading(changeNum);
+            const change = from(this.restApiService.getChangeDetail(changeNum));
+            const edit = from(this.restApiService.getChangeEdit(changeNum));
+            return forkJoin([change, edit]);
+          }),
+          withLatestFrom(this.routerModel.routerPatchNum$),
+          map(([[change, edit], patchNum]) =>
+            updateChangeWithEdit(change, edit, patchNum)
+          )
+        )
+        .subscribe(change => {
+          // The change service is currently a singleton, so we have to be
+          // careful to avoid situations where the application state is
+          // partially set for the old change where the user is coming from,
+          // and partially for the new change where the user is navigating to.
+          // So setting the change explicitly to undefined when the user
+          // moves away from diff and change pages (changeNum === undefined)
+          // helps with that.
+          this.updateStateChange(change ?? undefined);
+        }),
+      this.change$.subscribe(change => (this.change = change)),
+      this.currentPatchNum$.subscribe(
+        currentPatchNum => (this.currentPatchNum = currentPatchNum)
+      ),
+      combineLatest([
+        this.currentPatchNum$,
+        this.changeNum$,
+        this.userModel.loggedIn$,
+      ])
+        .pipe(
+          switchMap(([currentPatchNum, changeNum, loggedIn]) => {
+            if (!changeNum || !currentPatchNum || !loggedIn) {
+              this.updateStateReviewedFiles([]);
+              return of(undefined);
+            }
+            return from(this.fetchReviewedFiles(currentPatchNum, changeNum));
+          })
+        )
+        .subscribe(),
+    ];
+  }
+
+  finalize() {
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+  }
+
+  // Temporary workaround until path is derived in the model itself.
+  updatePath(diffPath?: string) {
+    const current = this.subject$.getValue();
+    this.setState({...current, diffPath});
+  }
+
+  updateStateReviewedFiles(reviewedFiles: string[]) {
+    const current = this.subject$.getValue();
+    this.setState({...current, reviewedFiles});
+  }
+
+  updateStateFileReviewed(file: string, reviewed: boolean) {
+    const current = this.subject$.getValue();
+    if (current.reviewedFiles === undefined) {
+      // Reviewed files haven't loaded yet.
+      // TODO(dhruvsri): disable updating status if reviewed files are not loaded.
+      fireAlert(
+        document,
+        'Updating status failed. Reviewed files not loaded yet.'
+      );
+      return;
+    }
+    const reviewedFiles = [...current.reviewedFiles];
+
+    // File is already reviewed and is being marked reviewed
+    if (reviewedFiles.includes(file) && reviewed) return;
+    // File is not reviewed and is being marked not reviewed
+    if (!reviewedFiles.includes(file) && !reviewed) return;
+
+    if (reviewed) reviewedFiles.push(file);
+    else reviewedFiles.splice(reviewedFiles.indexOf(file), 1);
+    this.setState({...current, reviewedFiles});
+  }
+
+  fetchReviewedFiles(currentPatchNum: PatchSetNum, changeNum: NumericChangeId) {
+    return this.restApiService
+      .getReviewedFiles(changeNum, currentPatchNum)
+      .then(files => {
+        if (
+          changeNum !== this.change?._number ||
+          currentPatchNum !== this.currentPatchNum
+        )
+          return;
+        this.updateStateReviewedFiles(files ?? []);
+      });
+  }
+
+  setReviewedFilesStatus(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    file: string,
+    reviewed: boolean
+  ) {
+    return this.restApiService
+      .saveFileReviewed(changeNum, patchNum, file, reviewed)
+      .then(() => {
+        if (
+          changeNum !== this.change?._number ||
+          patchNum !== this.currentPatchNum
+        )
+          return;
+        this.updateStateFileReviewed(file, reviewed);
+      })
+      .catch(() => {
+        fireAlert(document, ERR_REVIEW_STATUS);
+      });
+  }
+
+  /**
+   * Typically you would just subscribe to change$ yourself to get updates. But
+   * sometimes it is nice to also be able to get the current ChangeInfo on
+   * demand. So here it is for your convenience.
+   */
+  getChange() {
+    return this.subject$.getValue().change;
+  }
+
+  /**
+   * Check whether there is no newer patch than the latest patch that was
+   * available when this change was loaded.
+   *
+   * @return A promise that yields true if the latest patch
+   *     has been loaded, and false if a newer patch has been uploaded in the
+   *     meantime. The promise is rejected on network error.
+   */
+  fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
+    const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
+    return this.restApiService.getChangeDetail(change._number).then(detail => {
+      if (!detail) {
+        const error = new Error('Change detail not found.');
+        return Promise.reject(error);
+      }
+      const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
+      if (!actualLatest || !knownLatest) {
+        const error = new Error('Unable to check for latest patchset.');
+        return Promise.reject(error);
+      }
+      return {
+        isLatest: actualLatest <= knownLatest,
+        newStatus: change.status !== detail.status ? detail.status : null,
+        newMessages:
+          (change.messages || []).length < (detail.messages || []).length
+            ? detail.messages![detail.messages!.length - 1]
+            : undefined,
+      };
+    });
+  }
+
+  /**
+   * Called when change detail loading is initiated.
+   *
+   * If the change number matches the current change in the state, then
+   * this is a reload. If not, then we not just want to set the state to
+   * LOADING instead of RELOADING, but we also want to set the change to
+   * undefined right away. Otherwise components could see inconsistent state:
+   * a new change number, but an old change.
+   */
+  private updateStateLoading(changeNum: NumericChangeId) {
+    const current = this.subject$.getValue();
+    const reloading = current.change?._number === changeNum;
+    this.setState({
+      ...current,
+      change: reloading ? current.change : undefined,
+      loadingStatus: reloading
+        ? LoadingStatus.RELOADING
+        : LoadingStatus.LOADING,
+    });
+  }
+
+  // Private but used in tests.
+  updateStateChange(change?: ParsedChangeInfo) {
+    const current = this.subject$.getValue();
+    this.setState({
+      ...current,
+      change,
+      loadingStatus:
+        change === undefined ? LoadingStatus.NOT_LOADED : LoadingStatus.LOADED,
+    });
+  }
+
+  // Private but used in tests
+  setState(state: ChangeState) {
+    this.subject$.next(state);
+  }
+}
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
new file mode 100644
index 0000000..ceb87cc
--- /dev/null
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -0,0 +1,292 @@
+/**
+ * @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 {Subject} from 'rxjs';
+import {ChangeStatus} from '../../constants/constants';
+import '../../test/common-test-setup-karma';
+import {
+  createChange,
+  createChangeMessageInfo,
+  createEditInfo,
+  createParsedChange,
+  createRevision,
+} from '../../test/test-data-generators';
+import {
+  mockPromise,
+  stubRestApi,
+  waitUntilObserved,
+} from '../../test/test-utils';
+import {
+  CommitId,
+  EditPatchSetNum,
+  NumericChangeId,
+  PatchSetNum,
+} from '../../types/common';
+import {ParsedChangeInfo} from '../../types/types';
+import {getAppContext} from '../../services/app-context';
+import {GerritView} from '../../services/router/router-model';
+import {ChangeState, LoadingStatus, updateChangeWithEdit} from './change-model';
+import {ChangeModel} from './change-model';
+
+suite('updateChangeWithEdit() tests', () => {
+  test('undefined change', async () => {
+    assert.isUndefined(updateChangeWithEdit());
+  });
+
+  test('undefined edit', async () => {
+    const change = createParsedChange();
+    assert.equal(updateChangeWithEdit(change), change);
+  });
+
+  test('set edit rev and current rev', async () => {
+    let change: ParsedChangeInfo | undefined = createParsedChange();
+    const edit = createEditInfo();
+    change = updateChangeWithEdit(change, edit);
+    const editRev = change?.revisions[`${edit.commit.commit}`];
+    assert.isDefined(editRev);
+    assert.equal(editRev?._number, EditPatchSetNum);
+    assert.equal(editRev?.basePatchNum, edit.base_patch_set_number);
+    assert.equal(change?.current_revision, edit.commit.commit);
+  });
+
+  test('do not set current rev when patchNum already set', async () => {
+    let change: ParsedChangeInfo | undefined = createParsedChange();
+    const edit = createEditInfo();
+    change = updateChangeWithEdit(change, edit, 1 as PatchSetNum);
+    const editRev = change?.revisions[`${edit.commit.commit}`];
+    assert.isDefined(editRev);
+    assert.equal(change?.current_revision, 'abc' as CommitId);
+  });
+});
+
+suite('change service tests', () => {
+  let changeModel: ChangeModel;
+  let knownChange: ParsedChangeInfo;
+  const testCompleted = new Subject<void>();
+
+  async function waitForLoadingStatus(
+    loadingStatus: LoadingStatus
+  ): Promise<ChangeState> {
+    return await waitUntilObserved(
+      changeModel.state$,
+      state => state.loadingStatus === loadingStatus,
+      `LoadingStatus was never ${loadingStatus}`
+    );
+  }
+
+  setup(() => {
+    changeModel = new ChangeModel(
+      getAppContext().routerModel,
+      getAppContext().restApiService,
+      getAppContext().userModel
+    );
+    knownChange = {
+      ...createChange(),
+      revisions: {
+        sha1: {
+          ...createRevision(1),
+          description: 'patch 1',
+          _number: 1 as PatchSetNum,
+        },
+        sha2: {
+          ...createRevision(2),
+          description: 'patch 2',
+          _number: 2 as PatchSetNum,
+        },
+      },
+      status: ChangeStatus.NEW,
+      current_revision: 'abc' as CommitId,
+      messages: [],
+    };
+  });
+
+  teardown(() => {
+    testCompleted.next();
+    changeModel.finalize();
+  });
+
+  test('load a change', async () => {
+    const promise = mockPromise<ParsedChangeInfo | undefined>();
+    const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+    let state: ChangeState;
+
+    state = await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
+    assert.equal(stub.callCount, 0);
+    assert.isUndefined(state?.change);
+
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
+    state = await waitForLoadingStatus(LoadingStatus.LOADING);
+    assert.equal(stub.callCount, 1);
+    assert.isUndefined(state?.change);
+
+    promise.resolve(knownChange);
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 1);
+    assert.equal(state?.change, knownChange);
+  });
+
+  test('reload a change', async () => {
+    // setting up a loaded change
+    const promise = mockPromise<ParsedChangeInfo | undefined>();
+    const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+    let state: ChangeState;
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
+    promise.resolve(knownChange);
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+
+    // Reloading same change
+    document.dispatchEvent(new CustomEvent('reload'));
+    state = await waitForLoadingStatus(LoadingStatus.RELOADING);
+    assert.equal(stub.callCount, 2);
+    assert.equal(state?.change, knownChange);
+
+    promise.resolve(knownChange);
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 2);
+    assert.equal(state?.change, knownChange);
+  });
+
+  test('navigating to another change', async () => {
+    // setting up a loaded change
+    let promise = mockPromise<ParsedChangeInfo | undefined>();
+    const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+    let state: ChangeState;
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
+    promise.resolve(knownChange);
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+
+    // Navigating to other change
+
+    const otherChange: ParsedChangeInfo = {
+      ...knownChange,
+      _number: 123 as NumericChangeId,
+    };
+    promise = mockPromise<ParsedChangeInfo | undefined>();
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: otherChange._number,
+    });
+    state = await waitForLoadingStatus(LoadingStatus.LOADING);
+    assert.equal(stub.callCount, 2);
+    assert.isUndefined(state?.change);
+
+    promise.resolve(otherChange);
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 2);
+    assert.equal(state?.change, otherChange);
+  });
+
+  test('navigating to dashboard', async () => {
+    // setting up a loaded change
+    let promise = mockPromise<ParsedChangeInfo | undefined>();
+    const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+    let state: ChangeState;
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
+    promise.resolve(knownChange);
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+
+    // Navigating to dashboard
+
+    promise = mockPromise<ParsedChangeInfo | undefined>();
+    promise.resolve(undefined);
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: undefined,
+    });
+    state = await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
+    assert.equal(stub.callCount, 2);
+    assert.isUndefined(state?.change);
+
+    // Navigating back from dashboard to change page
+
+    promise = mockPromise<ParsedChangeInfo | undefined>();
+    promise.resolve(knownChange);
+    changeModel.routerModel.setState({
+      view: GerritView.CHANGE,
+      changeNum: knownChange._number,
+    });
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 3);
+    assert.equal(state?.change, knownChange);
+  });
+
+  test('changeModel.fetchChangeUpdates on latest', async () => {
+    stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
+    const result = await changeModel.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeModel.fetchChangeUpdates not on latest', async () => {
+    const actualChange = {
+      ...knownChange,
+      revisions: {
+        ...knownChange.revisions,
+        sha3: {
+          ...createRevision(3),
+          description: 'patch 3',
+          _number: 3 as PatchSetNum,
+        },
+      },
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeModel.fetchChangeUpdates(knownChange);
+    assert.isFalse(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeModel.fetchChangeUpdates new status', async () => {
+    const actualChange = {
+      ...knownChange,
+      status: ChangeStatus.MERGED,
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeModel.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.equal(result.newStatus, ChangeStatus.MERGED);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeModel.fetchChangeUpdates new messages', async () => {
+    const actualChange = {
+      ...knownChange,
+      messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeModel.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.deepEqual(result.newMessages, {
+      ...createChangeMessageInfo(),
+      message: 'blah blah',
+    });
+  });
+});
diff --git a/polygerrit-ui/app/models/checks/checks-fakes.ts b/polygerrit-ui/app/models/checks/checks-fakes.ts
new file mode 100644
index 0000000..a16af7b
--- /dev/null
+++ b/polygerrit-ui/app/models/checks/checks-fakes.ts
@@ -0,0 +1,570 @@
+/**
+ * @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 {
+  Action,
+  Category,
+  Link,
+  LinkIcon,
+  RunStatus,
+  TagColor,
+} from '../../api/checks';
+import {CheckRun, ChecksModel, ChecksPatchset} from './checks-model';
+
+// TODO(brohlfs): Eventually these fakes should be removed. But they have proven
+// to be super convenient for testing, debugging and demoing, so I would like to
+// keep them around for a few quarters. Maybe remove by EOY 2022?
+
+export const fakeRun0: CheckRun = {
+  pluginName: 'f0',
+  internalRunId: 'f0',
+  checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder Finder',
+  labelName: 'Presubmit',
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f0r0',
+      category: Category.ERROR,
+      summary: 'I would like to point out this error: 1 is not equal to 2!',
+      links: [
+        {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL},
+      ],
+      tags: [{name: 'OBSOLETE'}, {name: 'E2E'}],
+    },
+    {
+      internalResultId: 'f0r1',
+      category: Category.ERROR,
+      summary: 'Running the mighty test has failed by crashing.',
+      message: 'Btw, 1 is also not equal to 3. Did you know?',
+      actions: [
+        {
+          name: 'Ignore',
+          tooltip: 'Ignore this result',
+          primary: true,
+          callback: () => Promise.resolve({message: 'fake "ignore" triggered'}),
+        },
+        {
+          name: 'Flag',
+          tooltip: 'Flag this result as totally absolutely really not useful',
+          primary: true,
+          disabled: true,
+          callback: () => Promise.resolve({message: 'flag "flag" triggered'}),
+        },
+        {
+          name: 'Upload',
+          tooltip: 'Upload the result to the super cloud.',
+          primary: false,
+          callback: () => Promise.resolve({message: 'fake "upload" triggered'}),
+        },
+      ],
+      tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
+      links: [
+        {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+        {
+          primary: true,
+          url: 'https://google.com',
+          icon: LinkIcon.DOWNLOAD_MOBILE,
+        },
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
+      ],
+    },
+  ],
+  status: RunStatus.COMPLETED,
+};
+
+export const fakeRun1: CheckRun = {
+  pluginName: 'f1',
+  internalRunId: 'f1',
+  checkName: 'FAKE Super Check',
+  startedTimestamp: new Date(new Date().getTime() - 5 * 60 * 1000),
+  finishedTimestamp: new Date(new Date().getTime() + 5 * 60 * 1000),
+  patchset: 2,
+  labelName: 'Verified',
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f1r0',
+      category: Category.WARNING,
+      summary: 'We think that you could improve this.',
+      message: `There is a lot to be said. A lot. I say, a lot.
+                So please keep reading.`,
+      tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
+      codePointers: [
+        {
+          path: '/COMMIT_MSG',
+          range: {
+            start_line: 7,
+            start_character: 5,
+            end_line: 9,
+            end_character: 20,
+          },
+        },
+      ],
+      links: [
+        {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+        {
+          primary: true,
+          url: 'https://google.com',
+          icon: LinkIcon.DOWNLOAD_MOBILE,
+        },
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {
+          primary: false,
+          url: 'https://google.com',
+          tooltip: 'look at this',
+          icon: LinkIcon.IMAGE,
+        },
+        {
+          primary: false,
+          url: 'https://google.com',
+          tooltip: 'not at this',
+          icon: LinkIcon.IMAGE,
+        },
+      ],
+    },
+    {
+      internalResultId: 'f1r1',
+      category: Category.INFO,
+      summary: 'Suspicious Author',
+      message: 'Do you personally know this person?',
+      codePointers: [
+        {
+          path: '/COMMIT_MSG',
+          range: {
+            start_line: 2,
+            start_character: 0,
+            end_line: 2,
+            end_character: 0,
+          },
+        },
+      ],
+      links: [],
+    },
+    {
+      internalResultId: 'f1r2',
+      category: Category.ERROR,
+      summary: 'Suspicious Date',
+      message: 'That was a holiday, you know.',
+      codePointers: [
+        {
+          path: '/COMMIT_MSG',
+          range: {
+            start_line: 3,
+            start_character: 0,
+            end_line: 3,
+            end_character: 0,
+          },
+        },
+      ],
+      links: [],
+    },
+  ],
+  status: RunStatus.RUNNING,
+};
+
+export const fakeRun2: CheckRun = {
+  pluginName: 'f2',
+  internalRunId: 'f2',
+  checkName: 'FAKE Mega Analysis',
+  statusDescription: 'This run is nearly completed, but not quite.',
+  statusLink: 'https://www.google.com/',
+  checkDescription:
+    'From what the title says you can tell that this check analyses.',
+  checkLink: 'https://www.google.com/',
+  scheduledTimestamp: new Date('2021-04-01T03:14:15'),
+  startedTimestamp: new Date('2021-04-01T04:24:25'),
+  finishedTimestamp: new Date('2021-04-01T04:44:44'),
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  actions: [
+    {
+      name: 'Re-Run',
+      tooltip: 'More powerful run than before',
+      primary: true,
+      callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+    },
+    {
+      name: 'Monetize',
+      primary: true,
+      disabled: true,
+      callback: () => Promise.resolve({message: 'fake "monetize" triggered'}),
+    },
+    {
+      name: 'Delete',
+      primary: true,
+      callback: () => Promise.resolve({message: 'fake "delete" triggered'}),
+    },
+  ],
+  results: [
+    {
+      internalResultId: 'f2r0',
+      category: Category.INFO,
+      summary: 'This is looking a bit too large.',
+      message: `We are still looking into how large exactly. Stay tuned.
+And have a look at https://www.google.com!
+
+Or have a look at change 30000.
+Example code:
+  const constable = '';
+  var variable = '';`,
+      tags: [{name: 'FLAKY'}, {name: 'MAC-OS'}],
+    },
+  ],
+  status: RunStatus.COMPLETED,
+};
+
+export const fakeRun3: CheckRun = {
+  pluginName: 'f3',
+  internalRunId: 'f3',
+  checkName: 'FAKE Critical Observations',
+  status: RunStatus.RUNNABLE,
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+};
+
+export const fakeRun4_1: CheckRun = {
+  pluginName: 'f4',
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
+  status: RunStatus.RUNNABLE,
+  attempt: 1,
+  isSingleAttempt: false,
+  isLatestAttempt: false,
+  attemptDetails: [],
+};
+
+export const fakeRun4_2: CheckRun = {
+  pluginName: 'f4',
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
+  status: RunStatus.COMPLETED,
+  attempt: 2,
+  isSingleAttempt: false,
+  isLatestAttempt: false,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f42r0',
+      category: Category.INFO,
+      summary: 'Please eliminate all the TODOs!',
+    },
+  ],
+};
+
+export const fakeRun4_3: CheckRun = {
+  pluginName: 'f4',
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
+  status: RunStatus.COMPLETED,
+  attempt: 3,
+  isSingleAttempt: false,
+  isLatestAttempt: false,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f43r0',
+      category: Category.ERROR,
+      summary: 'Without eliminating all the TODOs your change will break!',
+    },
+  ],
+};
+
+export const fakeRun4_4: CheckRun = {
+  pluginName: 'f4',
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
+  checkDescription: 'Shows you the possible eliminations.',
+  checkLink: 'https://www.google.com',
+  status: RunStatus.COMPLETED,
+  statusDescription: 'Everything was eliminated already.',
+  statusLink: 'https://www.google.com',
+  attempt: 40,
+  scheduledTimestamp: new Date('2021-04-02T03:14:15'),
+  startedTimestamp: new Date('2021-04-02T04:24:25'),
+  finishedTimestamp: new Date('2021-04-02T04:25:44'),
+  isSingleAttempt: false,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f44r0',
+      category: Category.INFO,
+      summary: 'Dont be afraid. All TODOs will be eliminated.',
+      actions: [
+        {
+          name: 'Re-Run',
+          tooltip: 'More powerful run than before with a long tooltip, really.',
+          primary: true,
+          callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+        },
+      ],
+    },
+  ],
+  actions: [
+    {
+      name: 'Re-Run',
+      tooltip: 'small',
+      primary: true,
+      callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+    },
+  ],
+};
+
+export function fakeRun4CreateAttempts(from: number, to: number): CheckRun[] {
+  const runs: CheckRun[] = [];
+  for (let i = from; i < to; i++) {
+    runs.push(fakeRun4CreateAttempt(i));
+  }
+  return runs;
+}
+
+export function fakeRun4CreateAttempt(attempt: number): CheckRun {
+  return {
+    pluginName: 'f4',
+    internalRunId: 'f4',
+    checkName: 'FAKE Elimination Long Long Long Long Long',
+    status: RunStatus.COMPLETED,
+    attempt,
+    isSingleAttempt: false,
+    isLatestAttempt: false,
+    attemptDetails: [],
+    results:
+      attempt % 2 === 0
+        ? [
+            {
+              internalResultId: 'f43r0',
+              category: Category.ERROR,
+              summary:
+                'Without eliminating all the TODOs your change will break!',
+            },
+          ]
+        : [],
+  };
+}
+
+export const fakeRun4Att = [
+  fakeRun4_1,
+  fakeRun4_2,
+  fakeRun4_3,
+  ...fakeRun4CreateAttempts(5, 40),
+  fakeRun4_4,
+];
+
+export const fakeActions: Action[] = [
+  {
+    name: 'Fake Action 1',
+    primary: true,
+    disabled: true,
+    tooltip: 'Tooltip for Fake Action 1',
+    callback: () => Promise.resolve({message: 'fake action 1 triggered'}),
+  },
+  {
+    name: 'Fake Action 2',
+    primary: false,
+    disabled: true,
+    tooltip: 'Tooltip for Fake Action 2',
+    callback: () => Promise.resolve({message: 'fake action 2 triggered'}),
+  },
+  {
+    name: 'Fake Action 3',
+    summary: true,
+    primary: false,
+    tooltip: 'Tooltip for Fake Action 3',
+    callback: () => Promise.resolve({message: 'fake action 3 triggered'}),
+  },
+];
+
+export const fakeLinks: Link[] = [
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Bug Report 1',
+    icon: LinkIcon.REPORT_BUG,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Bug Report 2',
+    icon: LinkIcon.REPORT_BUG,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Link 1',
+    icon: LinkIcon.EXTERNAL,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: false,
+    tooltip: 'Fake Link 2',
+    icon: LinkIcon.EXTERNAL,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Code Link',
+    icon: LinkIcon.CODE,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Image Link',
+    icon: LinkIcon.IMAGE,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Help Link',
+    icon: LinkIcon.HELP_PAGE,
+  },
+];
+
+export const fakeRun5: CheckRun = {
+  pluginName: 'f5',
+  internalRunId: 'f5',
+  checkName: 'FAKE Of Tomorrow',
+  status: RunStatus.SCHEDULED,
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+};
+
+export function clearAllFakeRuns(model: ChecksModel) {
+  model.updateStateSetProvider('f0', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f1', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f2', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f3', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f4', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f5', ChecksPatchset.LATEST);
+  model.updateStateSetResults(
+    'f0',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f1',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f2',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f3',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f4',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f5',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+}
+
+export function setAllFakeRuns(model: ChecksModel) {
+  model.updateStateSetProvider('f0', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f1', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f2', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f3', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f4', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f5', ChecksPatchset.LATEST);
+  model.updateStateSetResults(
+    'f0',
+    [fakeRun0],
+    fakeActions,
+    fakeLinks,
+    'ETA: 1 min',
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f1',
+    [fakeRun1],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f2',
+    [fakeRun2],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f3',
+    [fakeRun3],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f4',
+    fakeRun4Att,
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f5',
+    [fakeRun5],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+}
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
new file mode 100644
index 0000000..7b3b95f
--- /dev/null
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -0,0 +1,867 @@
+/**
+ * @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 {AttemptDetail, createAttemptMap} from './checks-util';
+import {assertIsDefined} from '../../utils/common-util';
+import {select} from '../../utils/observable-util';
+import {Finalizable} from '../../services/registry';
+import {
+  BehaviorSubject,
+  combineLatest,
+  from,
+  Observable,
+  of,
+  Subject,
+  Subscription,
+  timer,
+} from 'rxjs';
+import {
+  catchError,
+  filter,
+  switchMap,
+  take,
+  takeUntil,
+  takeWhile,
+  throttleTime,
+  withLatestFrom,
+} from 'rxjs/operators';
+import {
+  Action,
+  CheckResult as CheckResultApi,
+  CheckRun as CheckRunApi,
+  Link,
+  ChangeData,
+  ChecksApiConfig,
+  ChecksProvider,
+  FetchResponse,
+  ResponseCode,
+  Category,
+  RunStatus,
+} from '../../api/checks';
+import {ChangeModel} from '../change/change-model';
+import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
+import {getCurrentRevision} from '../../utils/change-util';
+import {getShaByPatchNum} from '../../utils/patch-set-util';
+import {ReportingService} from '../../services/gr-reporting/gr-reporting';
+import {Execution, Interaction, Timing} from '../../constants/reporting';
+import {fireAlert, fireEvent} from '../../utils/event-util';
+import {RouterModel} from '../../services/router/router-model';
+import {Model} from '../model';
+import {define} from '../dependency';
+import {
+  ChecksPlugin,
+  ChecksUpdate,
+  PluginsModel,
+} from '../plugins/plugins-model';
+
+/**
+ * The checks model maintains the state of checks for two patchsets: the latest
+ * and (if different) also for the one selected in the checks tab. So we need
+ * the distinction in a lot of places for checks about whether the code affects
+ * the checks data of the LATEST or the SELECTED patchset.
+ */
+export enum ChecksPatchset {
+  LATEST = 'LATEST',
+  SELECTED = 'SELECTED',
+}
+
+export interface CheckResult extends CheckResultApi {
+  /**
+   * Internally we want to uniquely identify a run with an id, for example when
+   * efficiently re-rendering lists of runs in the UI.
+   */
+  internalResultId: string;
+}
+
+export interface CheckRun extends CheckRunApi {
+  /**
+   * For convenience we attach the name of the plugin to each run.
+   */
+  pluginName: string;
+  /**
+   * Internally we want to uniquely identify a result with an id, for example
+   * when efficiently re-rendering lists of results in the UI.
+   */
+  internalRunId: string;
+  /**
+   * Is this run attempt the latest attempt for the check, i.e. does it have
+   * the highest attempt number among all checks with the same name?
+   */
+  isLatestAttempt: boolean;
+  /**
+   * Is this the only attempt for the check, i.e. we don't have data for other
+   * attempts?
+   */
+  isSingleAttempt: boolean;
+  /**
+   * List of all attempts for the same check, ordered by attempt number.
+   */
+  attemptDetails: AttemptDetail[];
+  results?: CheckResult[];
+}
+
+// This is a convenience type for working with results, because when working
+// with a bunch of results you will typically also want to know about the run
+// properties. So you can just combine them with {...run, ...result}.
+export type RunResult = CheckRun & CheckResult;
+
+export const checksModelToken = define<ChecksModel>('checks-model');
+
+export interface ChecksProviderState {
+  pluginName: string;
+  loading: boolean;
+  /**
+   * Allows to distinguish whether loading:true is the *first* time of loading
+   * something for this provider. Or just a subsequent background update.
+   * Note that this is initially true even before loading is being set to true,
+   * so you may want to check loading && firstTimeLoad.
+   */
+  firstTimeLoad: boolean;
+  /** Presence of errorMessage implicitly means that the provider is in ERROR state. */
+  errorMessage?: string;
+  /** Presence of loginCallback implicitly means that the provider is in NOT_LOGGED_IN state. */
+  loginCallback?: () => void;
+  summaryMessage?: string;
+  runs: CheckRun[];
+  actions: Action[];
+  links: Link[];
+}
+
+interface ChecksState {
+  /**
+   * This is the patchset number selected by the user. The *latest* patchset
+   * can be picked up from the change model.
+   */
+  patchsetNumberSelected?: PatchSetNumber;
+  /** Checks data for the latest patchset. */
+  pluginStateLatest: {
+    [name: string]: ChecksProviderState;
+  };
+  /**
+   * Checks data for the selected patchset. Note that `checksSelected$` below
+   * falls back to the data for the latest patchset, if no patchset is selected.
+   */
+  pluginStateSelected: {
+    [name: string]: ChecksProviderState;
+  };
+}
+
+/**
+ * Can be used in `reduce()` to collect all results from all runs from all
+ * providers into one array.
+ */
+function collectRunResults(
+  allResults: RunResult[],
+  providerState: ChecksProviderState
+) {
+  return [
+    ...allResults,
+    ...providerState.runs.reduce((results: RunResult[], run: CheckRun) => {
+      const runResults: RunResult[] =
+        run.results?.map(r => {
+          return {...run, ...r};
+        }) ?? [];
+      return results.concat(runResults ?? []);
+    }, []),
+  ];
+}
+
+export interface ErrorMessages {
+  /* Maps plugin name to error message. */
+  [name: string]: string;
+}
+
+export class ChecksModel extends Model<ChecksState> implements Finalizable {
+  private readonly providers: {[name: string]: ChecksProvider} = {};
+
+  private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
+
+  private checkToPluginMap = new Map<string, string>();
+
+  private changeNum?: NumericChangeId;
+
+  private latestPatchNum?: PatchSetNumber;
+
+  private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
+
+  private readonly reloadListener: () => void;
+
+  private readonly visibilityChangeListener: () => void;
+
+  private subscriptions: Subscription[] = [];
+
+  public checksSelectedPatchsetNumber$ = select(
+    this.state$,
+    state => state.patchsetNumberSelected
+  );
+
+  public checksLatest$ = select(this.state$, state => state.pluginStateLatest);
+
+  public checksSelected$ = select(this.state$, state =>
+    state.patchsetNumberSelected
+      ? state.pluginStateSelected
+      : state.pluginStateLatest
+  );
+
+  public aPluginHasRegistered$ = select(
+    this.checksLatest$,
+    state => Object.keys(state).length > 0
+  );
+
+  private firstLoadCompleted$ = select(this.checksLatest$, state => {
+    const providers = Object.values(state);
+    if (providers.length === 0) return false;
+    if (providers.some(p => p.loading || p.firstTimeLoad)) return false;
+    return true;
+  });
+
+  public someProvidersAreLoadingFirstTime$ = select(this.checksLatest$, state =>
+    Object.values(state).some(
+      provider => provider.loading && provider.firstTimeLoad
+    )
+  );
+
+  public someProvidersAreLoadingLatest$ = select(this.checksLatest$, state =>
+    Object.values(state).some(providerState => providerState.loading)
+  );
+
+  public someProvidersAreLoadingSelected$ = select(
+    this.checksSelected$,
+    state => Object.values(state).some(providerState => providerState.loading)
+  );
+
+  public errorMessageLatest$ = select(
+    this.checksLatest$,
+
+    state =>
+      Object.values(state).find(
+        providerState => providerState.errorMessage !== undefined
+      )?.errorMessage
+  );
+
+  public errorMessagesLatest$ = select(this.checksLatest$, state => {
+    const errorMessages: ErrorMessages = {};
+    for (const providerState of Object.values(state)) {
+      if (providerState.errorMessage === undefined) continue;
+      errorMessages[providerState.pluginName] = providerState.errorMessage;
+    }
+    return errorMessages;
+  });
+
+  public loginCallbackLatest$ = select(
+    this.checksLatest$,
+    state =>
+      Object.values(state).find(
+        providerState => providerState.loginCallback !== undefined
+      )?.loginCallback
+  );
+
+  public topLevelActionsLatest$ = select(this.checksLatest$, state =>
+    Object.values(state).reduce(
+      (allActions: Action[], providerState: ChecksProviderState) => [
+        ...allActions,
+        ...providerState.actions,
+      ],
+      []
+    )
+  );
+
+  public topLevelMessagesLatest$ = select(this.checksLatest$, state => {
+    const messages = Object.values(state).map(
+      providerState => providerState.summaryMessage
+    );
+    return messages.filter(m => m !== undefined) as string[];
+  });
+
+  public topLevelActionsSelected$ = select(this.checksSelected$, state =>
+    Object.values(state).reduce(
+      (allActions: Action[], providerState: ChecksProviderState) => [
+        ...allActions,
+        ...providerState.actions,
+      ],
+      []
+    )
+  );
+
+  public topLevelLinksSelected$ = select(this.checksSelected$, state =>
+    Object.values(state).reduce(
+      (allLinks: Link[], providerState: ChecksProviderState) => [
+        ...allLinks,
+        ...providerState.links,
+      ],
+      []
+    )
+  );
+
+  public allRunsLatestPatchset$ = select(this.checksLatest$, state =>
+    Object.values(state).reduce(
+      (allRuns: CheckRun[], providerState: ChecksProviderState) => [
+        ...allRuns,
+        ...providerState.runs,
+      ],
+      []
+    )
+  );
+
+  public allRunsSelectedPatchset$ = select(this.checksSelected$, state =>
+    Object.values(state).reduce(
+      (allRuns: CheckRun[], providerState: ChecksProviderState) => [
+        ...allRuns,
+        ...providerState.runs,
+      ],
+      []
+    )
+  );
+
+  public allRunsLatestPatchsetLatestAttempt$ = select(
+    this.allRunsLatestPatchset$,
+    runs => runs.filter(run => run.isLatestAttempt)
+  );
+
+  public checkToPluginMap$ = select(this.checksLatest$, state => {
+    const map = new Map<string, string>();
+    for (const [pluginName, providerState] of Object.entries(state)) {
+      for (const run of providerState.runs) {
+        map.set(run.checkName, pluginName);
+      }
+    }
+    return map;
+  });
+
+  public allResultsSelected$ = select(this.checksSelected$, state =>
+    Object.values(state)
+      .reduce(collectRunResults, [])
+      .filter(r => r !== undefined)
+  );
+
+  public allResultsLatest$ = select(this.checksLatest$, state =>
+    Object.values(state)
+      .reduce(collectRunResults, [])
+      .filter(r => r !== undefined)
+  );
+
+  public allResults$ = select(
+    combineLatest([
+      this.checksSelectedPatchsetNumber$,
+      this.allResultsSelected$,
+      this.allResultsLatest$,
+    ]),
+    ([selectedPs, selected, latest]) =>
+      selectedPs ? [...selected, ...latest] : latest
+  );
+
+  constructor(
+    readonly routerModel: RouterModel,
+    readonly changeModel: ChangeModel,
+    readonly reporting: ReportingService,
+    readonly pluginsModel: PluginsModel
+  ) {
+    super({
+      pluginStateLatest: {},
+      pluginStateSelected: {},
+    });
+    this.reporting.time(Timing.CHECKS_LOAD);
+    this.subscriptions = [
+      this.changeModel.changeNum$.subscribe(x => (this.changeNum = x)),
+      this.pluginsModel.checksPlugins$.subscribe(plugins => {
+        for (const plugin of plugins) {
+          this.register(plugin);
+        }
+      }),
+      this.pluginsModel.checksAnnounce$.subscribe(a =>
+        this.reload(a.pluginName)
+      ),
+      this.pluginsModel.checksUpdate$.subscribe(u => this.updateResult(u)),
+      this.checkToPluginMap$.subscribe(map => {
+        this.checkToPluginMap = map;
+      }),
+      combineLatest([
+        this.routerModel.routerPatchNum$,
+        this.changeModel.latestPatchNum$,
+      ]).subscribe(([routerPs, latestPs]) => {
+        this.latestPatchNum = latestPs;
+        if (latestPs === undefined) {
+          this.setPatchset(undefined);
+        } else if (typeof routerPs === 'number') {
+          this.setPatchset(routerPs as PatchSetNumber);
+        } else {
+          this.setPatchset(latestPs);
+        }
+      }),
+      this.firstLoadCompleted$
+        .pipe(
+          filter(completed => !!completed),
+          take(1),
+          withLatestFrom(this.checksLatest$)
+        )
+        .subscribe(([_, state]) => this.reportStats(state)),
+    ];
+    this.visibilityChangeListener = () => {
+      this.documentVisibilityChange$.next(undefined);
+    };
+    document.addEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
+    this.reloadListener = () => this.reloadAll();
+    document.addEventListener('reload', this.reloadListener);
+  }
+
+  private reportStats(state: {[name: string]: ChecksProviderState}) {
+    const stats = {
+      providerCount: 0,
+      providerErrorCount: 0,
+      providerLoginCount: 0,
+      providerActionCount: 0,
+      providerLinkCount: 0,
+      errorCount: 0,
+      warningCount: 0,
+      infoCount: 0,
+      successCount: 0,
+      runnableCount: 0,
+      scheduledCount: 0,
+      runningCount: 0,
+      completedCount: 0,
+    };
+    const providers = Object.values(state);
+    for (const provider of providers) {
+      stats.providerCount++;
+      if (provider.errorMessage) stats.providerErrorCount++;
+      if (provider.loginCallback) stats.providerLoginCount++;
+      if (provider.actions?.length) stats.providerActionCount++;
+      if (provider.links?.length) stats.providerLinkCount++;
+      for (const run of provider.runs) {
+        if (run.status === RunStatus.RUNNABLE) stats.runnableCount++;
+        if (run.status === RunStatus.SCHEDULED) stats.scheduledCount++;
+        if (run.status === RunStatus.RUNNING) stats.runningCount++;
+        if (run.status === RunStatus.COMPLETED) stats.completedCount++;
+        for (const result of run.results ?? []) {
+          if (result.category === Category.ERROR) stats.errorCount++;
+          if (result.category === Category.WARNING) stats.warningCount++;
+          if (result.category === Category.INFO) stats.infoCount++;
+          if (result.category === Category.SUCCESS) stats.successCount++;
+        }
+      }
+    }
+    this.reporting.timeEnd(Timing.CHECKS_LOAD, stats);
+    this.reporting.reportInteraction(Interaction.CHECKS_STATS, stats);
+  }
+
+  finalize() {
+    document.removeEventListener('reload', this.reloadListener);
+    document.removeEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+    this.subject$.complete();
+  }
+
+  // Must only be used by the checks service or whatever is in control of this
+  // model.
+  updateStateSetProvider(pluginName: string, patchset: ChecksPatchset) {
+    const nextState = {...this.subject$.getValue()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    pluginState[pluginName] = {
+      pluginName,
+      loading: false,
+      firstTimeLoad: true,
+      runs: [],
+      actions: [],
+      links: [],
+    };
+    this.subject$.next(nextState);
+  }
+
+  getPluginState(
+    state: ChecksState,
+    patchset: ChecksPatchset = ChecksPatchset.LATEST
+  ) {
+    if (patchset === ChecksPatchset.LATEST) {
+      state.pluginStateLatest = {...state.pluginStateLatest};
+      return state.pluginStateLatest;
+    } else {
+      state.pluginStateSelected = {...state.pluginStateSelected};
+      return state.pluginStateSelected;
+    }
+  }
+
+  updateStateSetLoading(pluginName: string, patchset: ChecksPatchset) {
+    const nextState = {...this.subject$.getValue()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    pluginState[pluginName] = {
+      ...pluginState[pluginName],
+      loading: true,
+    };
+    this.subject$.next(nextState);
+  }
+
+  updateStateSetError(
+    pluginName: string,
+    errorMessage: string,
+    patchset: ChecksPatchset
+  ) {
+    const nextState = {...this.subject$.getValue()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    pluginState[pluginName] = {
+      ...pluginState[pluginName],
+      loading: false,
+      firstTimeLoad: false,
+      errorMessage,
+      loginCallback: undefined,
+      runs: [],
+      actions: [],
+    };
+    this.subject$.next(nextState);
+  }
+
+  updateStateSetNotLoggedIn(
+    pluginName: string,
+    loginCallback: () => void,
+    patchset: ChecksPatchset
+  ) {
+    const nextState = {...this.subject$.getValue()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    pluginState[pluginName] = {
+      ...pluginState[pluginName],
+      loading: false,
+      firstTimeLoad: false,
+      errorMessage: undefined,
+      loginCallback,
+      runs: [],
+      actions: [],
+    };
+    this.subject$.next(nextState);
+  }
+
+  updateStateSetResults(
+    pluginName: string,
+    runs: CheckRunApi[],
+    actions: Action[] = [],
+    links: Link[] = [],
+    summaryMessage: string | undefined,
+    patchset: ChecksPatchset
+  ) {
+    const attemptMap = createAttemptMap(runs);
+    for (const attemptInfo of attemptMap.values()) {
+      // Per run only one attempt can be undefined, so the '?? -1' is not really
+      // relevant for sorting.
+      attemptInfo.attempts.sort(
+        (a, b) => (a.attempt ?? -1) - (b.attempt ?? -1)
+      );
+    }
+    const nextState = {...this.subject$.getValue()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    const oldState = pluginState[pluginName];
+    pluginState[pluginName] = {
+      ...oldState,
+      loading: false,
+      firstTimeLoad: oldState.loading ? false : oldState.firstTimeLoad,
+      errorMessage: undefined,
+      loginCallback: undefined,
+      runs: runs.map(run => {
+        const runId = `${run.checkName}-${run.change}-${run.patchset}-${run.attempt}`;
+        const attemptInfo = attemptMap.get(run.checkName);
+        assertIsDefined(attemptInfo, 'attemptInfo');
+        return {
+          ...run,
+          pluginName,
+          internalRunId: runId,
+          isLatestAttempt: attemptInfo.latestAttempt === run.attempt,
+          isSingleAttempt: attemptInfo.isSingleAttempt,
+          attemptDetails: attemptInfo.attempts,
+          results: (run.results ?? []).map((result, i) => {
+            return {
+              ...result,
+              internalResultId: `${runId}-${i}`,
+            };
+          }),
+        };
+      }),
+      actions: [...actions],
+      links: [...links],
+      summaryMessage,
+    };
+    this.subject$.next(nextState);
+  }
+
+  updateStateUpdateResult(
+    pluginName: string,
+    updatedRun: CheckRunApi,
+    updatedResult: CheckResultApi,
+    patchset: ChecksPatchset
+  ) {
+    const nextState = {...this.subject$.getValue()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    let runUpdated = false;
+    const runs: CheckRun[] = pluginState[pluginName].runs.map(run => {
+      if (run.change !== updatedRun.change) return run;
+      if (run.patchset !== updatedRun.patchset) return run;
+      if (run.attempt !== updatedRun.attempt) return run;
+      if (run.checkName !== updatedRun.checkName) return run;
+      let resultUpdated = false;
+      const results: CheckResult[] = (run.results ?? []).map(result => {
+        if (
+          result.externalId &&
+          result.externalId === updatedResult.externalId
+        ) {
+          runUpdated = true;
+          resultUpdated = true;
+          return {
+            ...updatedResult,
+            internalResultId: result.internalResultId,
+          };
+        }
+        return result;
+      });
+      return resultUpdated ? {...run, results} : run;
+    });
+    if (!runUpdated) return;
+    pluginState[pluginName] = {
+      ...pluginState[pluginName],
+      runs,
+    };
+    this.subject$.next(nextState);
+  }
+
+  updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
+    const nextState = {...this.subject$.getValue()};
+    nextState.patchsetNumberSelected = patchsetNumber;
+    this.subject$.next(nextState);
+  }
+
+  setPatchset(num?: PatchSetNumber) {
+    this.updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
+  }
+
+  reload(pluginName: string) {
+    this.reloadSubjects[pluginName].next();
+  }
+
+  reloadAll() {
+    for (const key of Object.keys(this.providers)) {
+      this.reload(key);
+    }
+  }
+
+  reloadForCheck(checkName?: string) {
+    if (!checkName) return;
+    const plugin = this.checkToPluginMap.get(checkName);
+    if (plugin) this.reload(plugin);
+  }
+
+  updateResult(update: ChecksUpdate) {
+    const {pluginName, run, result} = update;
+    this.updateStateUpdateResult(
+      pluginName,
+      run,
+      result,
+      ChecksPatchset.LATEST
+    );
+    this.updateStateUpdateResult(
+      pluginName,
+      run,
+      result,
+      ChecksPatchset.SELECTED
+    );
+  }
+
+  triggerAction(action: Action, run: CheckRun | undefined, context: string) {
+    if (!action?.callback) return;
+    if (!this.changeNum) return;
+    const patchSet = run?.patchset ?? this.latestPatchNum;
+    if (!patchSet) return;
+    this.reporting.reportInteraction(Interaction.CHECKS_ACTION_TRIGGERED, {
+      checkName: run?.checkName,
+      actionName: action.name,
+      context,
+    });
+    const promise = action.callback(
+      this.changeNum,
+      patchSet,
+      run?.attempt,
+      run?.externalId,
+      run?.checkName,
+      action.name
+    );
+    // If plugins return undefined or not a promise, then show no toast.
+    if (!promise?.then) return;
+
+    fireAlert(document, `Triggering action '${action.name}' ...`);
+    from(promise)
+      // If the action takes longer than 5 seconds, then most likely the
+      // user is either not interested or the result not relevant anymore.
+      .pipe(takeUntil(timer(5000)))
+      .subscribe(result => {
+        if (result.errorMessage || result.message) {
+          fireAlert(document, `${result.message ?? result.errorMessage}`);
+        } else {
+          fireEvent(document, 'hide-alert');
+        }
+        if (result.shouldReload) {
+          this.reloadForCheck(run?.checkName);
+        }
+      });
+  }
+
+  register(checksPlugin: ChecksPlugin) {
+    const {pluginName, provider, config} = checksPlugin;
+    if (this.providers[pluginName]) {
+      console.warn(
+        `Plugin '${pluginName}' was trying to register twice as a Checks UI provider. Ignored.`
+      );
+      return;
+    }
+    this.providers[pluginName] = provider;
+    this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
+    this.updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
+    this.updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
+    this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
+    this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
+  }
+
+  initFetchingOfData(
+    pluginName: string,
+    config: ChecksApiConfig,
+    patchset: ChecksPatchset
+  ) {
+    const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
+    // Various events should trigger fetching checks from the provider:
+    // 1. Change number and patchset number changes.
+    // 2. Specific reload requests.
+    // 3. Regular polling starting with an initial fetch right now.
+    // 4. A hidden Gerrit tab becoming visible.
+    this.subscriptions.push(
+      combineLatest([
+        this.changeModel.change$,
+        patchset === ChecksPatchset.LATEST
+          ? this.changeModel.latestPatchNum$
+          : this.checksSelectedPatchsetNumber$,
+        this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
+        timer(0, pollIntervalMs),
+        this.documentVisibilityChange$,
+      ])
+        .pipe(
+          takeWhile(_ => !!this.providers[pluginName]),
+          filter(_ => document.visibilityState !== 'hidden'),
+          switchMap(([change, patchNum]): Observable<FetchResponse> => {
+            if (!change || !patchNum) return of(this.empty());
+            if (typeof patchNum !== 'number') return of(this.empty());
+            assertIsDefined(change.revisions, 'change.revisions');
+            const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
+            // Sometimes patchNum is updated earlier than change, so change
+            // revisions don't have patchNum yet
+            if (!patchsetSha) return of(this.empty());
+            const data: ChangeData = {
+              changeNumber: change?._number,
+              patchsetNumber: patchNum,
+              patchsetSha,
+              repo: change.project,
+              commitMessage: getCurrentRevision(change)?.commit?.message,
+              changeInfo: change as ChangeInfo,
+            };
+            return this.fetchResults(pluginName, data, patchset);
+          }),
+          catchError(e => {
+            // This should not happen and is really severe, because it means that
+            // the Observable has terminated and we won't recover from that. No
+            // further attempts to fetch results for this plugin will be made.
+            this.reporting.error(e, `checks-model crash for ${pluginName}`);
+            return of(this.createErrorResponse(pluginName, e));
+          })
+        )
+        .subscribe(response => {
+          switch (response.responseCode) {
+            case ResponseCode.ERROR: {
+              const message = response.errorMessage ?? '-';
+              this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
+                plugin: pluginName,
+                message,
+              });
+              this.updateStateSetError(pluginName, message, patchset);
+              break;
+            }
+            case ResponseCode.NOT_LOGGED_IN: {
+              assertIsDefined(response.loginCallback, 'loginCallback');
+              this.reporting.reportExecution(
+                Execution.CHECKS_API_NOT_LOGGED_IN,
+                {
+                  plugin: pluginName,
+                }
+              );
+              this.updateStateSetNotLoggedIn(
+                pluginName,
+                response.loginCallback,
+                patchset
+              );
+              break;
+            }
+            case ResponseCode.OK: {
+              this.updateStateSetResults(
+                pluginName,
+                response.runs ?? [],
+                response.actions ?? [],
+                response.links ?? [],
+                response.summaryMessage,
+                patchset
+              );
+              break;
+            }
+          }
+        })
+    );
+  }
+
+  private empty(): FetchResponse {
+    return {
+      responseCode: ResponseCode.OK,
+      runs: [],
+    };
+  }
+
+  private createErrorResponse(
+    pluginName: string,
+    message: object
+  ): FetchResponse {
+    return {
+      responseCode: ResponseCode.ERROR,
+      errorMessage:
+        `Error message from plugin '${pluginName}':` +
+        ` ${JSON.stringify(message)}`,
+    };
+  }
+
+  private fetchResults(
+    pluginName: string,
+    data: ChangeData,
+    patchset: ChecksPatchset
+  ): Observable<FetchResponse> {
+    this.updateStateSetLoading(pluginName, patchset);
+    const timer = this.reporting.getTimer('ChecksPluginFetch');
+    const fetchPromise = this.providers[pluginName]
+      .fetch(data)
+      .then(response => {
+        timer.end({pluginName});
+        return response;
+      });
+    return from(fetchPromise).pipe(
+      catchError(e => of(this.createErrorResponse(pluginName, e)))
+    );
+  }
+}
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
new file mode 100644
index 0000000..fd3e38b
--- /dev/null
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -0,0 +1,185 @@
+/**
+ * @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 '../../test/common-test-setup-karma';
+import './checks-model';
+import {ChecksModel, ChecksPatchset, ChecksProviderState} from './checks-model';
+import {
+  Category,
+  CheckRun,
+  ChecksApiConfig,
+  ChecksProvider,
+  ResponseCode,
+  RunStatus,
+} from '../../api/checks';
+import {getAppContext} from '../../services/app-context';
+import {createParsedChange} from '../../test/test-data-generators';
+import {waitUntil, waitUntilCalled} from '../../test/test-utils';
+import {ParsedChangeInfo} from '../../types/types';
+import {changeModelToken} from '../change/change-model';
+
+const PLUGIN_NAME = 'test-plugin';
+
+const RUNS: CheckRun[] = [
+  {
+    checkName: 'MacCheck',
+    change: 123,
+    patchset: 1,
+    attempt: 1,
+    status: RunStatus.COMPLETED,
+    results: [
+      {
+        externalId: 'id-314',
+        category: Category.WARNING,
+        summary: 'Meddle cheddle check and you are weg.',
+      },
+    ],
+  },
+];
+
+const CONFIG: ChecksApiConfig = {
+  fetchPollingIntervalSeconds: 1000,
+};
+
+function createProvider(): ChecksProvider {
+  return {
+    fetch: () =>
+      Promise.resolve({
+        responseCode: ResponseCode.OK,
+        runs: [],
+      }),
+  };
+}
+
+suite('checks-model tests', () => {
+  let model: ChecksModel;
+
+  let current: ChecksProviderState;
+
+  setup(() => {
+    model = new ChecksModel(
+      getAppContext().routerModel,
+      testResolver(changeModelToken),
+      getAppContext().reportingService,
+      getAppContext().pluginsModel
+    );
+    model.checksLatest$.subscribe(c => (current = c[PLUGIN_NAME]));
+  });
+
+  teardown(() => {
+    model.finalize();
+  });
+
+  test('register and fetch', async () => {
+    let change: ParsedChangeInfo | undefined = undefined;
+    model.changeModel.change$.subscribe(c => (change = c));
+    const provider = createProvider();
+    const fetchSpy = sinon.spy(provider, 'fetch');
+
+    model.register({pluginName: 'test-plugin', provider, config: CONFIG});
+    await waitUntil(() => change === undefined);
+
+    const testChange = createParsedChange();
+    model.changeModel.updateStateChange(testChange);
+    await waitUntil(() => change === testChange);
+    await waitUntilCalled(fetchSpy, 'fetch');
+  });
+
+  test('model.updateStateSetProvider', () => {
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.deepEqual(current, {
+      pluginName: PLUGIN_NAME,
+      loading: false,
+      firstTimeLoad: true,
+      runs: [],
+      actions: [],
+      links: [],
+    });
+  });
+
+  test('loading and first time load', () => {
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.isFalse(current.loading);
+    assert.isTrue(current.firstTimeLoad);
+    model.updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.isTrue(current.loading);
+    assert.isTrue(current.firstTimeLoad);
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      undefined,
+      ChecksPatchset.LATEST
+    );
+    assert.isFalse(current.loading);
+    assert.isFalse(current.firstTimeLoad);
+    model.updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.isTrue(current.loading);
+    assert.isFalse(current.firstTimeLoad);
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      undefined,
+      ChecksPatchset.LATEST
+    );
+    assert.isFalse(current.loading);
+    assert.isFalse(current.firstTimeLoad);
+  });
+
+  test('model.updateStateSetResults', () => {
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      undefined,
+      ChecksPatchset.LATEST
+    );
+    assert.lengthOf(current.runs, 1);
+    assert.lengthOf(current.runs[0].results!, 1);
+  });
+
+  test('model.updateStateUpdateResult', () => {
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      undefined,
+      ChecksPatchset.LATEST
+    );
+    assert.equal(
+      current.runs[0].results![0].summary,
+      RUNS[0]!.results![0].summary
+    );
+    const result = RUNS[0].results![0];
+    const updatedResult = {...result, summary: 'new'};
+    model.updateStateUpdateResult(
+      PLUGIN_NAME,
+      RUNS[0],
+      updatedResult,
+      ChecksPatchset.LATEST
+    );
+    assert.lengthOf(current.runs, 1);
+    assert.lengthOf(current.runs[0].results!, 1);
+    assert.equal(current.runs[0].results![0].summary, 'new');
+  });
+});
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
similarity index 89%
rename from polygerrit-ui/app/services/checks/checks-util.ts
rename to polygerrit-ui/app/models/checks/checks-util.ts
index 18cc076..2f2fd9e 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -107,6 +107,7 @@
   return (
     catStat === RunStatus.COMPLETED ||
     catStat === RunStatus.RUNNABLE ||
+    catStat === RunStatus.SCHEDULED ||
     catStat === RunStatus.RUNNING
   );
 }
@@ -127,6 +128,8 @@
       return 'runnable';
     case RunStatus.RUNNING:
       return 'running';
+    case RunStatus.SCHEDULED:
+      return 'scheduled';
     default:
       assertNever(catStat, `Unsupported category/status: ${catStat}`);
   }
@@ -149,6 +152,8 @@
       return 'placeholder';
     case RunStatus.RUNNING:
       return 'timelapse';
+    case RunStatus.SCHEDULED:
+      return 'scheduled';
     default:
       assertNever(catStat, `Unsupported category/status: ${catStat}`);
   }
@@ -175,6 +180,8 @@
       return 'Not run';
     case RunStatus.RUNNING:
       return 'Running';
+    case RunStatus.SCHEDULED:
+      return 'Scheduled';
     default:
       assertNever(status, `Unsupported status: ${status}`);
   }
@@ -187,6 +194,7 @@
     case RunStatus.RUNNABLE:
       return PRIMARY_STATUS_ACTIONS.RUN;
     case RunStatus.RUNNING:
+    case RunStatus.SCHEDULED:
       return undefined;
     default:
       assertNever(status, `Unsupported status: ${status}`);
@@ -218,12 +226,16 @@
   return run.status === RunStatus.COMPLETED;
 }
 
-export function isRunning(run: CheckRun) {
-  return run.status === RunStatus.RUNNING;
+export function isRunningOrScheduled(run: CheckRun) {
+  return run.status === RunStatus.RUNNING || run.status === RunStatus.SCHEDULED;
 }
 
-export function isRunningOrHasCompleted(run: CheckRun) {
-  return run.status === RunStatus.COMPLETED || run.status === RunStatus.RUNNING;
+export function isRunningScheduledOrCompleted(run: CheckRun) {
+  return (
+    run.status === RunStatus.COMPLETED ||
+    run.status === RunStatus.RUNNING ||
+    run.status === RunStatus.SCHEDULED
+  );
 }
 
 export function hasCompletedWithoutResults(run: CheckRun) {
@@ -257,10 +269,13 @@
 }
 
 export function compareByWorstCategory(a: CheckRun, b: CheckRun) {
-  return level(worstCategory(b)) - level(worstCategory(a));
+  const catComp = catLevel(worstCategory(b)) - catLevel(worstCategory(a));
+  if (catComp !== 0) return catComp;
+  const statusComp = runLevel(b.status) - runLevel(a.status);
+  return statusComp;
 }
 
-export function level(cat?: Category) {
+function catLevel(cat?: Category) {
   if (!cat) return -1;
   switch (cat) {
     case Category.SUCCESS:
@@ -274,16 +289,18 @@
   }
 }
 
-export interface ActionTriggeredEventDetail {
-  action: Action;
-  run?: CheckRun;
-}
-
-export type ActionTriggeredEvent = CustomEvent<ActionTriggeredEventDetail>;
-
-declare global {
-  interface HTMLElementEventMap {
-    'action-triggered': ActionTriggeredEvent;
+function runLevel(status: RunStatus) {
+  switch (status) {
+    case RunStatus.COMPLETED:
+      return 0;
+    case RunStatus.RUNNABLE:
+      return 1;
+    case RunStatus.RUNNING:
+      return 2;
+    case RunStatus.SCHEDULED:
+      return 3;
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
   }
 }
 
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
new file mode 100644
index 0000000..769d7af
--- /dev/null
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -0,0 +1,586 @@
+/**
+ * @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 {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
+import {
+  CommentBasics,
+  CommentInfo,
+  NumericChangeId,
+  PatchSetNum,
+  RevisionId,
+  UrlEncodedCommentId,
+  PathToCommentsInfoMap,
+  RobotCommentInfo,
+  PathToRobotCommentsInfoMap,
+} from '../../types/common';
+import {
+  addPath,
+  DraftInfo,
+  isDraft,
+  isUnsaved,
+  reportingDetails,
+  UnsavedInfo,
+} from '../../utils/comment-util';
+import {deepEqual} from '../../utils/deep-util';
+import {select} from '../../utils/observable-util';
+import {RouterModel} from '../../services/router/router-model';
+import {Finalizable} from '../../services/registry';
+import {define} from '../dependency';
+import {combineLatest, Subscription} from 'rxjs';
+import {fire, fireAlert, fireEvent} from '../../utils/event-util';
+import {CURRENT} from '../../utils/patch-set-util';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {ChangeModel} from '../change/change-model';
+import {Interaction, Timing} from '../../constants/reporting';
+import {assertIsDefined} from '../../utils/common-util';
+import {debounce, DelayedTask} from '../../utils/async-util';
+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' */
+  comments?: PathToCommentsInfoMap;
+  /** undefined means 'still loading' */
+  robotComments?: {[path: string]: RobotCommentInfo[]};
+  // All drafts are DraftInfo objects and have __draft = true set.
+  // Drafts have an id and are known to the backend. Unsaved drafts
+  // (see UnsavedInfo) do NOT belong in the application model.
+  /** undefined means 'still loading' */
+  drafts?: {[path: string]: DraftInfo[]};
+  // Ported comments only affect `CommentThread` properties, not individual
+  // comments.
+  /** undefined means 'still loading' */
+  portedComments?: PathToCommentsInfoMap;
+  /** undefined means 'still loading' */
+  portedDrafts?: PathToCommentsInfoMap;
+  /**
+   * If a draft is discarded by the user, then we temporarily keep it in this
+   * array in case the user decides to Undo the discard operation and bring the
+   * draft back. Once restored, the draft is removed from this array.
+   */
+  discardedDrafts: DraftInfo[];
+}
+
+const initialState: CommentState = {
+  comments: undefined,
+  robotComments: undefined,
+  drafts: undefined,
+  portedComments: undefined,
+  portedDrafts: undefined,
+  discardedDrafts: [],
+};
+
+const TOAST_DEBOUNCE_INTERVAL = 200;
+
+function getSavingMessage(numPending: number, requestFailed?: boolean) {
+  if (requestFailed) {
+    return 'Unable to save draft';
+  }
+  if (numPending === 0) {
+    return 'All changes saved';
+  }
+  return `Saving ${pluralize(numPending, 'draft')}...`;
+}
+
+// Private but used in tests.
+export function setComments(
+  state: CommentState,
+  comments?: {
+    [path: string]: CommentInfo[];
+  }
+): CommentState {
+  const nextState = {...state};
+  if (deepEqual(comments, nextState.comments)) return state;
+  nextState.comments = addPath(comments) || {};
+  return nextState;
+}
+
+// Private but used in tests.
+export function setRobotComments(
+  state: CommentState,
+  robotComments?: {
+    [path: string]: RobotCommentInfo[];
+  }
+): CommentState {
+  if (deepEqual(robotComments, state.robotComments)) return state;
+  const nextState = {...state};
+  nextState.robotComments = addPath(robotComments) || {};
+  return nextState;
+}
+
+// Private but used in tests.
+export function setDrafts(
+  state: CommentState,
+  drafts?: {[path: string]: DraftInfo[]}
+): CommentState {
+  if (deepEqual(drafts, state.drafts)) return state;
+  const nextState = {...state};
+  nextState.drafts = addPath(drafts);
+  return nextState;
+}
+
+// Private but used in tests.
+export function setPortedComments(
+  state: CommentState,
+  portedComments?: PathToCommentsInfoMap
+): CommentState {
+  if (deepEqual(portedComments, state.portedComments)) return state;
+  const nextState = {...state};
+  nextState.portedComments = portedComments || {};
+  return nextState;
+}
+
+// Private but used in tests.
+export function setPortedDrafts(
+  state: CommentState,
+  portedDrafts?: PathToCommentsInfoMap
+): CommentState {
+  if (deepEqual(portedDrafts, state.portedDrafts)) return state;
+  const nextState = {...state};
+  nextState.portedDrafts = portedDrafts || {};
+  return nextState;
+}
+
+// Private but used in tests.
+export function setDiscardedDraft(
+  state: CommentState,
+  draft: DraftInfo
+): CommentState {
+  const nextState = {...state};
+  nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
+  return nextState;
+}
+
+// Private but used in tests.
+export function deleteDiscardedDraft(
+  state: CommentState,
+  draftID?: string
+): CommentState {
+  const nextState = {...state};
+  const drafts = [...nextState.discardedDrafts];
+  const index = drafts.findIndex(d => d.id === draftID);
+  if (index === -1) {
+    throw new Error('discarded draft not found');
+  }
+  drafts.splice(index, 1);
+  nextState.discardedDrafts = drafts;
+  return nextState;
+}
+
+/** Adds or updates a draft. */
+export function setDraft(state: CommentState, draft: DraftInfo): CommentState {
+  const nextState = {...state};
+  if (!draft.path) throw new Error('draft path undefined');
+  if (!isDraft(draft)) throw new Error('draft is not a draft');
+  if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
+  else drafts[draft.path] = [...drafts[draft.path]];
+  const index = drafts[draft.path].findIndex(d => d.id && d.id === draft.id);
+  if (index !== -1) {
+    drafts[draft.path][index] = draft;
+  } else {
+    drafts[draft.path].push(draft);
+  }
+  return nextState;
+}
+
+export function deleteDraft(
+  state: CommentState,
+  draft: DraftInfo
+): CommentState {
+  const nextState = {...state};
+  if (!draft.path) throw new Error('draft path undefined');
+  if (!isDraft(draft)) throw new Error('draft is not a draft');
+  if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  const index = (drafts[draft.path] || []).findIndex(
+    d => d.id && d.id === draft.id
+  );
+  if (index === -1) return state;
+  const discardedDraft = drafts[draft.path][index];
+  drafts[draft.path] = [...drafts[draft.path]];
+  drafts[draft.path].splice(index, 1);
+  return setDiscardedDraft(nextState, discardedDraft);
+}
+
+export const commentsModelToken = define<CommentsModel>('comments-model');
+export class CommentsModel extends Model<CommentState> implements Finalizable {
+  public readonly commentsLoading$ = select(
+    this.state$,
+    commentState =>
+      commentState.comments === undefined ||
+      commentState.robotComments === undefined ||
+      commentState.drafts === undefined
+  );
+
+  public readonly comments$ = select(
+    this.state$,
+    commentState => commentState.comments
+  );
+
+  public readonly drafts$ = select(
+    this.state$,
+    commentState => commentState.drafts
+  );
+
+  public readonly portedComments$ = select(
+    this.state$,
+    commentState => commentState.portedComments
+  );
+
+  public readonly discardedDrafts$ = select(
+    this.state$,
+    commentState => commentState.discardedDrafts
+  );
+
+  // Emits a new value even if only a single draft is changed. Components should
+  // aim to subsribe to something more specific.
+  public readonly changeComments$ = select(
+    this.state$,
+    commentState =>
+      new ChangeComments(
+        commentState.comments,
+        commentState.robotComments,
+        commentState.drafts,
+        commentState.portedComments,
+        commentState.portedDrafts
+      )
+  );
+
+  public readonly threads$ = select(this.changeComments$, changeComments =>
+    changeComments.getAllThreadsForChange()
+  );
+
+  public thread$(id: UrlEncodedCommentId) {
+    return select(this.threads$, threads => threads.find(t => t.rootId === id));
+  }
+
+  private numPendingDraftRequests = 0;
+
+  private changeNum?: NumericChangeId;
+
+  private patchNum?: PatchSetNum;
+
+  private readonly reloadListener: () => void;
+
+  private readonly subscriptions: Subscription[] = [];
+
+  private drafts: {[path: string]: DraftInfo[]} = {};
+
+  private draftToastTask?: DelayedTask;
+
+  private discardedDrafts: DraftInfo[] = [];
+
+  constructor(
+    readonly routerModel: RouterModel,
+    readonly changeModel: ChangeModel,
+    readonly restApiService: RestApiService,
+    readonly reporting: ReportingService
+  ) {
+    super(initialState);
+    this.subscriptions.push(
+      this.discardedDrafts$.subscribe(x => (this.discardedDrafts = x))
+    );
+    this.subscriptions.push(
+      this.drafts$.subscribe(x => (this.drafts = x ?? {}))
+    );
+    this.subscriptions.push(
+      this.changeModel.currentPatchNum$.subscribe(x => (this.patchNum = x))
+    );
+    this.subscriptions.push(
+      this.routerModel.routerChangeNum$.subscribe(changeNum => {
+        this.changeNum = changeNum;
+        this.setState({...initialState});
+        this.reloadAllComments();
+      })
+    );
+    this.subscriptions.push(
+      combineLatest([
+        this.changeModel.changeNum$,
+        this.changeModel.currentPatchNum$,
+      ]).subscribe(([changeNum, patchNum]) => {
+        this.changeNum = changeNum;
+        this.patchNum = patchNum;
+        this.reloadAllPortedComments();
+      })
+    );
+    this.reloadListener = () => {
+      this.reloadAllComments();
+      this.reloadAllPortedComments();
+    };
+    document.addEventListener('reload', this.reloadListener);
+  }
+
+  finalize() {
+    document.removeEventListener('reload', this.reloadListener);
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions.splice(0, this.subscriptions.length);
+  }
+
+  // Note that this does *not* reload ported comments.
+  async reloadAllComments() {
+    if (!this.changeNum) return;
+    await Promise.all([
+      this.reloadComments(this.changeNum),
+      this.reloadRobotComments(this.changeNum),
+      this.reloadDrafts(this.changeNum),
+    ]);
+  }
+
+  async reloadAllPortedComments() {
+    if (!this.changeNum) return;
+    if (!this.patchNum) return;
+    await Promise.all([
+      this.reloadPortedComments(this.changeNum, this.patchNum),
+      this.reloadPortedDrafts(this.changeNum, this.patchNum),
+    ]);
+  }
+
+  // visible for testing
+  updateState(reducer: (state: CommentState) => CommentState) {
+    const current = this.subject$.getValue();
+    this.setState(reducer({...current}));
+  }
+
+  // visible for testing
+  setState(state: CommentState) {
+    this.subject$.next(state);
+  }
+
+  async reloadComments(changeNum: NumericChangeId): Promise<void> {
+    const comments = await this.restApiService.getDiffComments(changeNum);
+    this.updateState(s => setComments(s, comments));
+  }
+
+  async reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
+    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));
+  }
+
+  async reloadPortedComments(
+    changeNum: NumericChangeId,
+    patchNum = CURRENT as RevisionId
+  ): Promise<void> {
+    const portedComments = await this.restApiService.getPortedComments(
+      changeNum,
+      patchNum
+    );
+    this.updateState(s => setPortedComments(s, portedComments));
+  }
+
+  async reloadPortedDrafts(
+    changeNum: NumericChangeId,
+    patchNum = CURRENT as RevisionId
+  ): Promise<void> {
+    const portedDrafts = await this.restApiService.getPortedDrafts(
+      changeNum,
+      patchNum
+    );
+    this.updateState(s => setPortedDrafts(s, portedDrafts));
+  }
+
+  async restoreDraft(id: UrlEncodedCommentId) {
+    const found = this.discardedDrafts?.find(d => d.id === id);
+    if (!found) throw new Error('discarded draft not found');
+    const newDraft = {
+      ...found,
+      id: undefined,
+      updated: undefined,
+      __draft: undefined,
+      __unsaved: true,
+    };
+    await this.saveDraft(newDraft);
+    this.updateState(s => deleteDiscardedDraft(s, id));
+  }
+
+  /**
+   * Saves a new or updates an existing draft.
+   * The model will only be updated when a successful response comes back.
+   */
+  async saveDraft(
+    draft: DraftInfo | UnsavedInfo,
+    showToast = true
+  ): Promise<DraftInfo> {
+    assertIsDefined(this.changeNum, 'change number');
+    assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+    if (!draft.message?.trim()) throw new Error('Cannot save empty draft.');
+
+    // Saving the change number as to make sure that the response is still
+    // relevant when it comes back. The user maybe have navigated away.
+    const changeNum = this.changeNum;
+    this.report(Interaction.SAVE_COMMENT, draft);
+    if (showToast) this.showStartRequest();
+    const timing = isUnsaved(draft) ? Timing.DRAFT_CREATE : Timing.DRAFT_UPDATE;
+    const timer = this.reporting.getTimer(timing);
+    const result = await this.restApiService.saveDiffDraft(
+      changeNum,
+      draft.patch_set,
+      draft
+    );
+    if (changeNum !== this.changeNum) throw new Error('change changed');
+    if (!result.ok) {
+      if (showToast) this.handleFailedDraftRequest();
+      throw new Error(
+        `Failed to save draft comment: ${JSON.stringify(result)}`
+      );
+    }
+    const obj = await this.restApiService.getResponseObject(result);
+    const savedComment = obj as unknown as CommentInfo;
+    const updatedDraft = {
+      ...draft,
+      id: savedComment.id,
+      updated: savedComment.updated,
+      __draft: true,
+      __unsaved: undefined,
+    };
+    timer.end({id: updatedDraft.id});
+    if (showToast) this.showEndRequest();
+    this.updateState(s => setDraft(s, updatedDraft));
+    this.report(Interaction.COMMENT_SAVED, updatedDraft);
+    return updatedDraft;
+  }
+
+  async discardDraft(draftId: UrlEncodedCommentId) {
+    const draft = this.lookupDraft(draftId);
+    assertIsDefined(this.changeNum, 'change number');
+    assertIsDefined(draft, `draft not found by id ${draftId}`);
+    assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+
+    if (!draft.message?.trim()) throw new Error('saved draft cant be empty');
+    // Saving the change number as to make sure that the response is still
+    // relevant when it comes back. The user maybe have navigated away.
+    const changeNum = this.changeNum;
+    this.report(Interaction.DISCARD_COMMENT, draft);
+    this.showStartRequest();
+    const timer = this.reporting.getTimer(Timing.DRAFT_DISCARD);
+    const result = await this.restApiService.deleteDiffDraft(
+      changeNum,
+      draft.patch_set,
+      {id: draft.id}
+    );
+    timer.end({id: draft.id});
+    if (changeNum !== this.changeNum) throw new Error('change changed');
+    if (!result.ok) {
+      this.handleFailedDraftRequest();
+      throw new Error(
+        `Failed to discard draft comment: ${JSON.stringify(result)}`
+      );
+    }
+    this.showEndRequest();
+    this.updateState(s => deleteDraft(s, draft));
+    // We don't store empty discarded drafts and don't need an UNDO then.
+    if (draft.message?.trim()) {
+      fire(document, 'show-alert', {
+        message: 'Draft Discarded',
+        action: 'Undo',
+        callback: () => this.restoreDraft(draft.id),
+      });
+    }
+    this.report(Interaction.COMMENT_DISCARDED, draft);
+  }
+
+  private report(interaction: Interaction, comment: CommentBasics) {
+    const details = reportingDetails(comment);
+    this.reporting.reportInteraction(interaction, details);
+  }
+
+  private showStartRequest() {
+    this.numPendingDraftRequests += 1;
+    this.updateRequestToast();
+  }
+
+  private showEndRequest() {
+    this.numPendingDraftRequests -= 1;
+    this.updateRequestToast();
+  }
+
+  private handleFailedDraftRequest() {
+    this.numPendingDraftRequests -= 1;
+    this.updateRequestToast(/* requestFailed=*/ true);
+  }
+
+  private updateRequestToast(requestFailed?: boolean) {
+    if (this.numPendingDraftRequests === 0 && !requestFailed) {
+      fireEvent(document, 'hide-alert');
+      return;
+    }
+    const message = getSavingMessage(
+      this.numPendingDraftRequests,
+      requestFailed
+    );
+    this.draftToastTask = debounce(
+      this.draftToastTask,
+      () => {
+        // Note: the event is fired on the body rather than this element because
+        // this element may not be attached by the time this executes, in which
+        // case the event would not bubble.
+        fireAlert(document.body, message);
+      },
+      TOAST_DEBOUNCE_INTERVAL
+    );
+  }
+
+  private lookupDraft(id: UrlEncodedCommentId): DraftInfo | undefined {
+    return Object.values(this.drafts)
+      .flat()
+      .find(d => d.id === id);
+  }
+}
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
new file mode 100644
index 0000000..01e2f6b
--- /dev/null
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -0,0 +1,128 @@
+/**
+ * @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 '../../test/common-test-setup-karma';
+import {createDraft} from '../../test/test-data-generators';
+import {UrlEncodedCommentId} from '../../types/common';
+import './comments-model';
+import {CommentsModel} from './comments-model';
+import {deleteDraft} from './comments-model';
+import {Subscription} from 'rxjs';
+import '../../test/common-test-setup-karma';
+import {
+  createComment,
+  createParsedChange,
+  TEST_NUMERIC_CHANGE_ID,
+} from '../../test/test-data-generators';
+import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
+import {getAppContext} from '../../services/app-context';
+import {GerritView} from '../../services/router/router-model';
+import {PathToCommentsInfoMap} from '../../types/common';
+import {changeModelToken} from '../change/change-model';
+
+suite('comments model tests', () => {
+  test('updateStateDeleteDraft', () => {
+    const draft = createDraft();
+    draft.id = '1' as UrlEncodedCommentId;
+    const state = {
+      comments: {},
+      robotComments: {},
+      drafts: {
+        [draft.path!]: [draft],
+      },
+      portedComments: {},
+      portedDrafts: {},
+      discardedDrafts: [],
+    };
+    const output = deleteDraft(state, draft);
+    assert.deepEqual(output, {
+      comments: {},
+      robotComments: {},
+      drafts: {
+        'abc.txt': [],
+      },
+      portedComments: {},
+      portedDrafts: {},
+      discardedDrafts: [{...draft}],
+    });
+  });
+});
+
+suite('change service tests', () => {
+  let subscriptions: Subscription[] = [];
+
+  teardown(() => {
+    for (const s of subscriptions) {
+      s.unsubscribe();
+    }
+    subscriptions = [];
+  });
+
+  test('loads comments', async () => {
+    const model = new CommentsModel(
+      getAppContext().routerModel,
+      testResolver(changeModelToken),
+      getAppContext().restApiService,
+      getAppContext().reportingService
+    );
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+      Promise.resolve({'foo.c': [createComment()]})
+    );
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+      Promise.resolve({})
+    );
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+      Promise.resolve({})
+    );
+    const portedCommentsSpy = stubRestApi('getPortedComments').returns(
+      Promise.resolve({'foo.c': [createComment()]})
+    );
+    const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
+      Promise.resolve({})
+    );
+    let comments: PathToCommentsInfoMap = {};
+    subscriptions.push(model.comments$.subscribe(c => (comments = c ?? {})));
+    let portedComments: PathToCommentsInfoMap = {};
+    subscriptions.push(
+      model.portedComments$.subscribe(c => (portedComments = c ?? {}))
+    );
+
+    model.routerModel.updateState({
+      view: GerritView.CHANGE,
+      changeNum: TEST_NUMERIC_CHANGE_ID,
+    });
+    model.changeModel.updateStateChange(createParsedChange());
+
+    await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
+    await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
+    await waitUntilCalled(diffDraftsSpy, 'diffDraftsSpy');
+    await waitUntilCalled(portedCommentsSpy, 'portedCommentsSpy');
+    await waitUntilCalled(portedDraftsSpy, 'portedDraftsSpy');
+    await waitUntil(
+      () => Object.keys(comments).length > 0,
+      'comment in model not set'
+    );
+    await waitUntil(
+      () => Object.keys(portedComments).length > 0,
+      'ported comment in model not set'
+    );
+
+    assert.equal(comments['foo.c'].length, 1);
+    assert.equal(comments['foo.c'][0].id, '12345');
+    assert.equal(portedComments['foo.c'].length, 1);
+    assert.equal(portedComments['foo.c'][0].id, '12345');
+  });
+});
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
new file mode 100644
index 0000000..2fd8a41
--- /dev/null
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -0,0 +1,89 @@
+/**
+ * @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 {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
+import {from, of, Subscription} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+import {Finalizable} from '../../services/registry';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {ChangeModel} from '../change/change-model';
+import {select} from '../../utils/observable-util';
+import {Model} from '../model';
+import {define} from '../dependency';
+
+export interface ConfigState {
+  repoConfig?: ConfigInfo;
+  serverConfig?: ServerInfo;
+}
+
+export const configModelToken = define<ConfigModel>('config-model');
+export class ConfigModel extends Model<ConfigState> implements Finalizable {
+  public repoConfig$ = select(
+    this.state$,
+    configState => configState.repoConfig
+  );
+
+  public repoCommentLinks$ = select(
+    this.repoConfig$,
+    repoConfig => repoConfig?.commentlinks ?? {}
+  );
+
+  public serverConfig$ = select(
+    this.state$,
+    configState => configState.serverConfig
+  );
+
+  private subscriptions: Subscription[];
+
+  constructor(
+    readonly changeModel: ChangeModel,
+    readonly restApiService: RestApiService
+  ) {
+    super({});
+    this.subscriptions = [
+      from(this.restApiService.getConfig()).subscribe((config?: ServerInfo) => {
+        this.updateServerConfig(config);
+      }),
+      this.changeModel.repo$
+        .pipe(
+          switchMap((repo?: RepoName) => {
+            if (repo === undefined) return of(undefined);
+            return from(this.restApiService.getProjectConfig(repo));
+          })
+        )
+        .subscribe((repoConfig?: ConfigInfo) => {
+          this.updateRepoConfig(repoConfig);
+        }),
+    ];
+  }
+
+  updateRepoConfig(repoConfig?: ConfigInfo) {
+    const current = this.subject$.getValue();
+    this.subject$.next({...current, repoConfig});
+  }
+
+  updateServerConfig(serverConfig?: ServerInfo) {
+    const current = this.subject$.getValue();
+    this.subject$.next({...current, serverConfig});
+  }
+
+  finalize() {
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+  }
+}
diff --git a/polygerrit-ui/app/models/dependency.ts b/polygerrit-ui/app/models/dependency.ts
new file mode 100644
index 0000000..e7ac242c
--- /dev/null
+++ b/polygerrit-ui/app/models/dependency.ts
@@ -0,0 +1,332 @@
+/**
+ * @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 {ReactiveController, ReactiveControllerHost} from 'lit';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+
+/**
+ * This module provides the ability to do dependency injection in components.
+ * It provides 3 functions that are for the purpose of dependency injection.
+ *
+ * Definitions
+ * ---
+ * A component's "connected lifetime" consists of the span between
+ * `super.connectedCallback` and `super.disconnectedCallback`.
+ *
+ * Dependency Definition
+ * ---
+ *
+ * A token for a dependency of type FooService is defined as follows:
+ *
+ *   const fooToken = define<FooService>('some name');
+ *
+ * Dependency Resolution
+ * ---
+ *
+ * To get the value of a dependency, a component requests a resolved dependency
+ *
+ * ```
+ *   private readonly serviceRef = resolve(this, fooToken);
+ * ```
+ *
+ * A resolved dependency is a function that when called will return the actual
+ * value for that dependency.
+ *
+ * A resolved dependency is guaranteed to be resolved during a components
+ * connected lifetime. If no ancestor provided a value for the dependency, then
+ * the resolved dependency will throw an error if the value is accessed.
+ * Therefore, the following is safe-by-construction as long as it happens
+ * within a components connected lifetime:
+ *
+ * ```
+ *    serviceRef().fooServiceMethod()
+ * ```
+ *
+ * Dependency Injection
+ * ---
+ *
+ * Ancestor components will inject the dependencies that a child component
+ * requires by providing factories for those values.
+ *
+ *
+ * To provide a dependency, a component needs to specify the following prior
+ * to finishing its connectedCallback:
+ *
+ * ```
+ *   provide(this, fooToken, () => new FooImpl())
+ * ```
+ * Dependencies are injected as factories in case the construction of them
+ * depends on other dependencies further up the component chain.  For instance,
+ * if the construction of FooImpl needed a BarService, then it could look
+ * something like this:
+ *
+ * ```
+ *   const barRef = resolve(this, barToken);
+ *   provide(this, fooToken, () => new FooImpl(barRef()));
+ * ```
+ *
+ * Lifetime guarantees
+ * ---
+ * A resolved dependency is valid for the duration of its component's connected
+ * lifetime.
+ *
+ * Internally, this is guaranteed by the following:
+ *
+ *   - Dependency injection relies on using dom-events which work synchronously.
+ *   - Dependency injection leverages ReactiveControllers whose lifetime
+ *     mirror that of the component
+ *   - Parent components' connected lifetime is guaranteed to include the
+ *     connected lifetime of child components.
+ *   - Dependency provider factories are only called during the lifetime of the
+ *     component that provides the value.
+ *
+ * Best practices
+ * ===
+ *  - Provide dependencies in or before connectedCallback
+ *  - Verify that isConnected is true when accessing a dependency after an
+ *    await.
+ *
+ * Type Safety
+ * ---
+ *
+ * Dependency injection is guaranteed npmtype-safe by construction due to the
+ * typing of the token used to tie together dependency providers and dependency
+ * consumers.
+ *
+ * Two tokens can never be equal because of how they are created. And both the
+ * consumer and provider logic of dependencies relies on the type of dependency
+ * token.
+ */
+
+/**
+ * A dependency-token is a unique key. It's typed by the type of the value the
+ * dependency needs.
+ */
+export type DependencyToken<ValueType> = symbol & {__type__: ValueType};
+
+/**
+ * Defines a unique dependency token for a given type.  The string provided
+ * is purely for debugging and does not need to be unique.
+ *
+ * Example usage:
+ *   const token = define<FooService>('foo-service');
+ */
+export function define<ValueType>(name: string) {
+  return Symbol(name) as unknown as DependencyToken<ValueType>;
+}
+
+/**
+ * A provider for a value.
+ */
+export type Provider<T> = () => T;
+
+/**
+ * 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,
+  dependency: DependencyToken<T>,
+  provider: Provider<T>
+) {
+  host.addController(new DependencyProvider<T>(host, dependency, provider));
+}
+
+/**
+ * A consumer of a service will resolve a given dependency token. The resolved
+ * dependency is returned as a simple function that can be called to access
+ * the injected value.
+ */
+export function resolve<T>(
+  host: ReactiveControllerHost & HTMLElement,
+  dependency: DependencyToken<T>
+): Provider<T> {
+  const controller = new DependencySubscriber(host, dependency);
+  host.addController(controller);
+  return () => controller.get();
+}
+
+/**
+ * Because Polymer doesn't (yet) depend on ReactiveControllerHost, this adds a
+ * work-around base-class to make this work for Polymer.
+ */
+export class DIPolymerElement
+  extends PolymerElement
+  implements ReactiveControllerHost
+{
+  private readonly ___controllers: ReactiveController[] = [];
+
+  override connectedCallback() {
+    for (const c of this.___controllers) {
+      c.hostConnected?.();
+    }
+    super.connectedCallback();
+  }
+
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    for (const c of this.___controllers) {
+      c.hostDisconnected?.();
+    }
+  }
+
+  addController(controller: ReactiveController) {
+    this.___controllers.push(controller);
+
+    if (this.isConnected) controller.hostConnected?.();
+  }
+
+  removeController(controller: ReactiveController) {
+    const idx = this.___controllers.indexOf(controller);
+    if (idx < 0) return;
+    this.___controllers?.splice(idx, 1);
+  }
+
+  requestUpdate() {}
+
+  get updateComplete(): Promise<boolean> {
+    return Promise.resolve(true);
+  }
+}
+
+/**
+ * A callback for a value.
+ */
+type Callback<T> = (value: T) => void;
+
+/**
+ * A Dependency Request gets sent by an element to ask for a dependency.
+ */
+export interface DependencyRequest<T> {
+  readonly dependency: DependencyToken<T>;
+  readonly callback: Callback<T>;
+}
+
+declare global {
+  interface HTMLElementEventMap {
+    /**
+     * An 'request-dependency' can be emitted by any element which desires a
+     * dependency to be injected by an external provider.
+     */
+    'request-dependency': DependencyRequestEvent<unknown>;
+  }
+  interface DocumentEventMap {
+    /**
+     * An 'request-dependency' can be emitted by any element which desires a
+     * dependency to be injected by an external provider.
+     */
+    'request-dependency': DependencyRequestEvent<unknown>;
+  }
+}
+
+/**
+ * Dependency Consumers fire DependencyRequests in the form of
+ * DependencyRequestEvent
+ */
+export class DependencyRequestEvent<T>
+  extends Event
+  implements DependencyRequest<T>
+{
+  public constructor(
+    public readonly dependency: DependencyToken<T>,
+    public readonly callback: Callback<T>
+  ) {
+    super('request-dependency', {bubbles: true, composed: true});
+  }
+}
+
+/**
+ * A resolved dependency is valid within the econnectd lifetime of a component,
+ * namely between connectedCallback and disconnectedCallback.
+ */
+interface ResolvedDependency<T> {
+  get(): T;
+}
+
+export class DependencyError<T> extends Error {
+  constructor(public readonly dependency: DependencyToken<T>, message: string) {
+    super(message);
+  }
+}
+
+class DependencySubscriber<T>
+  implements ReactiveController, ResolvedDependency<T>
+{
+  private value?: T;
+
+  private resolved = false;
+
+  constructor(
+    private readonly host: ReactiveControllerHost & HTMLElement,
+    private readonly dependency: DependencyToken<T>
+  ) {}
+
+  get() {
+    this.checkResolved();
+    return this.value!;
+  }
+
+  hostConnected() {
+    this.host.dispatchEvent(
+      new DependencyRequestEvent(this.dependency, (value: T) => {
+        this.resolved = true;
+        this.value = value;
+      })
+    );
+    this.checkResolved();
+  }
+
+  checkResolved() {
+    if (this.resolved) return;
+    const dep = this.dependency.description;
+    const tag = this.host.tagName;
+    const msg = `Could not resolve dependency '${dep}' in '${tag}'`;
+    throw new DependencyError(this.dependency, msg);
+  }
+
+  hostDisconnected() {
+    this.value = undefined;
+    this.resolved = false;
+  }
+}
+
+class DependencyProvider<T> implements ReactiveController {
+  private value?: T;
+
+  constructor(
+    private readonly host: ReactiveControllerHost & HTMLElement,
+    private readonly dependency: DependencyToken<T>,
+    private readonly provider: Provider<T>
+  ) {}
+
+  hostConnected() {
+    // Delay construction in case the provider has its own dependencies.
+    this.value = this.provider();
+    this.host.addEventListener('request-dependency', this.fullfill);
+  }
+
+  hostDisconnected() {
+    this.host.removeEventListener('request-dependency', this.fullfill);
+    this.value = undefined;
+  }
+
+  private readonly fullfill = (ev: DependencyRequestEvent<unknown>) => {
+    if (ev.dependency !== this.dependency) return;
+    ev.stopPropagation();
+    ev.preventDefault();
+    ev.callback(this.value!);
+  };
+}
diff --git a/polygerrit-ui/app/models/dependency_test.ts b/polygerrit-ui/app/models/dependency_test.ts
new file mode 100644
index 0000000..fa7cc29
--- /dev/null
+++ b/polygerrit-ui/app/models/dependency_test.ts
@@ -0,0 +1,199 @@
+/**
+ * @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 {define, provide, resolve, DIPolymerElement} from './dependency';
+import {html, LitElement} from 'lit';
+import {customElement as polyCustomElement} from '@polymer/decorators';
+import {html as polyHtml} from '@polymer/polymer/lib/utils/html-tag';
+import {customElement, property, query} from 'lit/decorators';
+import '../test/common-test-setup-karma.js';
+
+interface FooService {
+  value: string;
+}
+const fooToken = define<FooService>('foo');
+
+interface BarService {
+  value: string;
+}
+
+const barToken = define<BarService>('bar');
+
+class FooImpl implements FooService {
+  constructor(public readonly value: string) {}
+}
+
+class BarImpl implements BarService {
+  constructor(private readonly foo: FooService) {}
+
+  get value() {
+    return this.foo.value;
+  }
+}
+
+@customElement('lit-foo-provider')
+export class LitFooProviderElement extends LitElement {
+  @query('bar-provider')
+  bar?: BarProviderElement;
+
+  @property({type: Boolean})
+  public showBarProvider = true;
+
+  constructor() {
+    super();
+    provide(this, fooToken, () => new FooImpl('foo'));
+  }
+
+  override render() {
+    if (this.showBarProvider) {
+      return html`<bar-provider></bar-provider>`;
+    } else {
+      return undefined;
+    }
+  }
+}
+
+@polyCustomElement('polymer-foo-provider')
+export class PolymerFooProviderElement extends DIPolymerElement {
+  bar() {
+    return this.$.bar as BarProviderElement;
+  }
+
+  override connectedCallback() {
+    provide(this, fooToken, () => new FooImpl('foo'));
+    super.connectedCallback();
+  }
+
+  static get template() {
+    return polyHtml`<bar-provider id="bar"></bar-provider>`;
+  }
+}
+
+@customElement('bar-provider')
+export class BarProviderElement extends LitElement {
+  @query('leaf-lit-element')
+  litChild?: LeafLitElement;
+
+  @query('leaf-polymer-element')
+  polymerChild?: LeafPolymerElement;
+
+  @property({type: Boolean})
+  public showLit = true;
+
+  override connectedCallback() {
+    super.connectedCallback();
+    provide(this, barToken, () => this.create());
+  }
+
+  private create() {
+    const fooRef = resolve(this, fooToken);
+    assert.isDefined(fooRef());
+    return new BarImpl(fooRef());
+  }
+
+  override render() {
+    if (this.showLit) {
+      return html`<leaf-lit-element></leaf-lit-element>`;
+    } else {
+      return html`<leaf-polymer-element></leaf-polymer-element>`;
+    }
+  }
+}
+
+@customElement('leaf-lit-element')
+export class LeafLitElement extends LitElement {
+  readonly barRef = resolve(this, barToken);
+
+  override connectedCallback() {
+    super.connectedCallback();
+    assert.isDefined(this.barRef());
+  }
+
+  override render() {
+    return html`${this.barRef().value}`;
+  }
+}
+
+@polyCustomElement('leaf-polymer-element')
+export class LeafPolymerElement extends DIPolymerElement {
+  readonly barRef = resolve(this, barToken);
+
+  override connectedCallback() {
+    super.connectedCallback();
+    assert.isDefined(this.barRef());
+  }
+
+  static get template() {
+    return polyHtml`Hello`;
+  }
+}
+
+suite('Dependency', () => {
+  test('It instantiates', async () => {
+    const fixture = fixtureFromElement('lit-foo-provider');
+    const element = fixture.instantiate();
+    await element.updateComplete;
+    assert.isDefined(element.bar?.litChild?.barRef());
+  });
+
+  test('It instantiates in polymer', async () => {
+    const fixture = fixtureFromElement('polymer-foo-provider');
+    const element = fixture.instantiate();
+    await element.bar().updateComplete;
+    assert.isDefined(element.bar().litChild?.barRef());
+  });
+
+  test('It works by connecting and reconnecting', async () => {
+    const fixture = fixtureFromElement('lit-foo-provider');
+    const element = fixture.instantiate();
+    await element.updateComplete;
+    assert.isDefined(element.bar?.litChild?.barRef());
+
+    element.showBarProvider = false;
+    await element.updateComplete;
+    assert.isNull(element.bar);
+
+    element.showBarProvider = true;
+    await element.updateComplete;
+    assert.isDefined(element.bar?.litChild?.barRef());
+  });
+
+  test('It works by connecting and reconnecting Polymer', async () => {
+    const fixture = fixtureFromElement('lit-foo-provider');
+    const element = fixture.instantiate();
+    await element.updateComplete;
+
+    const beta = element.bar;
+    assert.isDefined(beta);
+    assert.isNotNull(beta);
+    assert.isDefined(element.bar?.litChild?.barRef());
+
+    beta!.showLit = false;
+    await element.updateComplete;
+    assert.isDefined(element.bar?.polymerChild?.barRef());
+  });
+});
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'lit-foo-provider': LitFooProviderElement;
+    'polymer-foo-provider': PolymerFooProviderElement;
+    'bar-provider': BarProviderElement;
+    'leaf-lit-element': LeafLitElement;
+    'leaf-polymer-element': LeafPolymerElement;
+  }
+}
diff --git a/polygerrit-ui/app/models/di-provider-element.ts b/polygerrit-ui/app/models/di-provider-element.ts
new file mode 100644
index 0000000..88b2786
--- /dev/null
+++ b/polygerrit-ui/app/models/di-provider-element.ts
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, TemplateResult} from 'lit';
+import {customElement, property, query} from 'lit/decorators';
+import {DependencyToken, provide} from './dependency';
+
+/**
+ * Example usage:
+ *
+ *   const providerElement = await fixture<DIProviderElement>(
+ *     wrapInProvider(
+ *       html`<my-element-to-test></my-element-to-test>`,
+ *       myModelToken,
+ *       myModel,
+ *     )
+ *   );
+ *   const element = providerElement.element as MyElementToTest;
+ *
+ * For injecting multiple tokens, make nested calls to `wrapInProvider` such as:
+ *
+ *   wrapInProvider(wrapInProvider(html`...`, token1, value1), token2, value2);
+ */
+export function wrapInProvider<T>(
+  template: TemplateResult,
+  token: DependencyToken<T>,
+  value: T
+) {
+  return html`
+    <di-provider-element .token=${token} .value=${value}
+      >${template}</di-provider-element
+    >
+  `;
+}
+
+/**
+ * Use `.element` to get the wrapped element for assertions.
+ */
+@customElement('di-provider-element')
+export class DIProviderElement extends LitElement {
+  @property({type: Object})
+  token?: DependencyToken<unknown>;
+
+  @property({type: Object})
+  value?: unknown;
+
+  get element() {
+    return this.slotElement.assignedElements()[0];
+  }
+
+  private isProvided = false;
+
+  @query('slot')
+  private slotElement!: HTMLSlotElement;
+
+  static override get styles() {
+    return css`
+      :host() {
+        display: contents;
+      }
+    `;
+  }
+
+  /** Only calls `provide` even after reconnection. */
+  override connectedCallback(): void {
+    super.connectedCallback();
+    if (!this.token || this.isProvided) return;
+    this.isProvided = true;
+    provide(this, this.token, () => this.value);
+  }
+
+  override render() {
+    return html`<slot></slot>`;
+  }
+}
diff --git a/polygerrit-ui/app/models/di-provider-element_test.ts b/polygerrit-ui/app/models/di-provider-element_test.ts
new file mode 100644
index 0000000..83feac7
--- /dev/null
+++ b/polygerrit-ui/app/models/di-provider-element_test.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement} from 'lit';
+import {customElement, state} from 'lit/decorators';
+import {define, resolve} from './dependency';
+import '../test/common-test-setup-karma.js';
+import {fixture} from '@open-wc/testing-helpers';
+import {DIProviderElement, wrapInProvider} from './di-provider-element';
+import {BehaviorSubject} from 'rxjs';
+import {waitUntilObserved} from '../test/test-utils';
+import {subscribe} from '../elements/lit/subscription-controller';
+
+const modelToken = define<BehaviorSubject<string>>('token');
+
+/**
+ * This is an example element-under-test. It is expecting a model injected
+ * using a token, and then always displaying the value from that model.
+ */
+@customElement('consumer-element')
+class ConsumerElement extends LitElement {
+  readonly getModel = resolve(this, modelToken);
+
+  @state()
+  private injectedValue = '';
+
+  override connectedCallback() {
+    super.connectedCallback();
+    subscribe(this, this.getModel(), value => (this.injectedValue = value));
+  }
+
+  override render() {
+    return html`<div>${this.injectedValue}</div>`;
+  }
+}
+
+suite('di-provider-element', () => {
+  let injectedModel: BehaviorSubject<string>;
+  let element: ConsumerElement;
+
+  setup(async () => {
+    // The injected value and fixture are created inside `setup` to prevent
+    // tests from leaking into each other.
+    injectedModel = new BehaviorSubject('foo');
+    element = (
+      await fixture<DIProviderElement>(
+        wrapInProvider(
+          html`<consumer-element></consumer-element>`,
+          modelToken,
+          injectedModel
+        )
+      )
+    ).element as ConsumerElement;
+  });
+
+  test('provides values to the wrapped element', () => {
+    expect(element).shadowDom.to.equal('<div>foo</div>');
+  });
+
+  test('enables the test to control the injected dependency', async () => {
+    injectedModel.next('bar');
+    await waitUntilObserved(injectedModel, value => value === 'bar');
+
+    expect(element).shadowDom.to.equal('<div>bar</div>');
+  });
+});
diff --git a/polygerrit-ui/app/models/model.ts b/polygerrit-ui/app/models/model.ts
new file mode 100644
index 0000000..4a7f5ac
--- /dev/null
+++ b/polygerrit-ui/app/models/model.ts
@@ -0,0 +1,42 @@
+/**
+ * @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 {BehaviorSubject, Observable} from 'rxjs';
+
+/**
+ * A Model stores a value <T> and controls changes to that value via `subject$`
+ * while allowing others to subscribe to value updates via the `state$`
+ * Observable.
+ *
+ * Typically a given Model subclass will provide:
+ *   1. an initial value
+ *   2. "reducers": functions for users to request changes to the value
+ *   3. "selectors": convenient sub-Observables that only contain updates for a
+ *          nested property from the value
+ *
+ *  Any new subscriber will immediately receive the current value.
+ */
+export abstract class Model<T> {
+  protected subject$: BehaviorSubject<T>;
+
+  public state$: Observable<T>;
+
+  constructor(initialState: T) {
+    this.subject$ = new BehaviorSubject(initialState);
+    this.state$ = this.subject$.asObservable();
+  }
+}
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
new file mode 100644
index 0000000..7b58cd1
--- /dev/null
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -0,0 +1,117 @@
+/**
+ * @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 {Finalizable} from '../../services/registry';
+import {Observable, Subject} from 'rxjs';
+import {
+  CheckResult,
+  CheckRun,
+  ChecksApiConfig,
+  ChecksProvider,
+} from '../../api/checks';
+import {Model} from '../model';
+import {define} from '../dependency';
+import {select} from '../../utils/observable-util';
+
+export interface ChecksPlugin {
+  pluginName: string;
+  provider: ChecksProvider;
+  config: ChecksApiConfig;
+}
+
+export interface ChecksUpdate {
+  pluginName: string;
+  run: CheckRun;
+  result: CheckResult;
+}
+
+/** Application wide state of plugins. */
+interface PluginsState {
+  /**
+   * List of plugins that have called checks().register().
+   */
+  checksPlugins: ChecksPlugin[];
+}
+
+export const pluginsModelToken = define<PluginsModel>('plugins-model');
+
+export class PluginsModel extends Model<PluginsState> implements Finalizable {
+  /** Private version of the event bus below. */
+  private checksAnnounceSubject$ = new Subject<ChecksPlugin>();
+
+  /** Event bus for telling the checks models that announce() was called. */
+  public checksAnnounce$: Observable<ChecksPlugin> =
+    this.checksAnnounceSubject$.asObservable();
+
+  /** Private version of the event bus below. */
+  private checksUpdateSubject$ = new Subject<ChecksUpdate>();
+
+  /** Event bus for telling the checks models that updateResult() was called. */
+  public checksUpdate$: Observable<ChecksUpdate> =
+    this.checksUpdateSubject$.asObservable();
+
+  public checksPlugins$ = select(this.state$, state => state.checksPlugins);
+
+  constructor() {
+    super({
+      checksPlugins: [],
+    });
+  }
+
+  finalize() {
+    this.subject$.complete();
+  }
+
+  checksRegister(plugin: ChecksPlugin) {
+    const nextState = {...this.subject$.getValue()};
+    nextState.checksPlugins = [...nextState.checksPlugins];
+    const alreadysRegistered = nextState.checksPlugins.some(
+      p => p.pluginName === plugin.pluginName
+    );
+    if (alreadysRegistered) {
+      console.warn(
+        `${plugin.pluginName} tried to register twice as a checks provider. Ignored.`
+      );
+      return;
+    }
+    nextState.checksPlugins.push(plugin);
+    this.subject$.next(nextState);
+  }
+
+  checksUpdate(update: ChecksUpdate) {
+    const plugins = this.subject$.getValue().checksPlugins;
+    const plugin = plugins.find(p => p.pluginName === update.pluginName);
+    if (!plugin) {
+      console.warn(
+        `Plugin '${update.pluginName}' not found. checksUpdate() ignored.`
+      );
+      return;
+    }
+    this.checksUpdateSubject$.next(update);
+  }
+
+  checksAnnounce(pluginName: string) {
+    const plugins = this.subject$.getValue().checksPlugins;
+    const plugin = plugins.find(p => p.pluginName === pluginName);
+    if (!plugin) {
+      console.warn(
+        `Plugin '${pluginName}' not found. checksAnnounce() ignored.`
+      );
+      return;
+    }
+    this.checksAnnounceSubject$.next(plugin);
+  }
+}
diff --git a/polygerrit-ui/app/models/plugins/plugins-model_test.ts b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
new file mode 100644
index 0000000..8927772
--- /dev/null
+++ b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
@@ -0,0 +1,94 @@
+/**
+ * @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 '../../test/common-test-setup-karma';
+import './plugins-model';
+import {ChecksApiConfig, ChecksProvider, ResponseCode} from '../../api/checks';
+import {ChecksPlugin, ChecksUpdate, PluginsModel} from './plugins-model';
+import {createRunResult} from '../../test/test-data-generators';
+
+const PLUGIN_NAME = 'test-plugin';
+
+const CONFIG: ChecksApiConfig = {
+  fetchPollingIntervalSeconds: 1000,
+};
+
+function createProvider(): ChecksProvider {
+  return {
+    fetch: () =>
+      Promise.resolve({
+        responseCode: ResponseCode.OK,
+        runs: [],
+      }),
+  };
+}
+
+suite('plugins-model tests', () => {
+  let model: PluginsModel;
+  let checksPlugins: ChecksPlugin[] = [];
+  const register = function () {
+    model.checksRegister({
+      pluginName: PLUGIN_NAME,
+      provider: createProvider(),
+      config: CONFIG,
+    });
+  };
+
+  setup(() => {
+    model = new PluginsModel();
+    model.state$.subscribe(s => {
+      checksPlugins = s.checksPlugins;
+    });
+  });
+
+  teardown(() => {
+    model.finalize();
+  });
+
+  test('checksRegister', async () => {
+    assert.isFalse(checksPlugins.some(p => p.pluginName === PLUGIN_NAME));
+
+    register();
+
+    assert.isTrue(checksPlugins.some(p => p.pluginName === PLUGIN_NAME));
+  });
+
+  test('checksAnnounce', async () => {
+    let announcement: ChecksPlugin | undefined;
+    model.checksAnnounce$.subscribe(a => (announcement = a));
+    assert.isUndefined(announcement?.pluginName);
+
+    register();
+    model.checksAnnounce(PLUGIN_NAME);
+
+    assert.equal(announcement?.pluginName, PLUGIN_NAME);
+  });
+
+  test('checksUpdate', async () => {
+    let update: ChecksUpdate | undefined;
+    model.checksUpdate$.subscribe(u => (update = u));
+    assert.isUndefined(update?.pluginName);
+
+    register();
+    model.checksUpdate({
+      pluginName: PLUGIN_NAME,
+      run: createRunResult(),
+      result: createRunResult(),
+    });
+
+    assert.equal(update?.pluginName, PLUGIN_NAME);
+  });
+});
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
new file mode 100644
index 0000000..fb18928
--- /dev/null
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -0,0 +1,219 @@
+/**
+ * @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 {from, of, Observable, Subscription} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+import {
+  DiffPreferencesInfo as DiffPreferencesInfoAPI,
+  DiffViewMode,
+} from '../../api/diff';
+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';
+import {Finalizable} from '../../services/registry';
+import {select} from '../../utils/observable-util';
+import {Model} from '../model';
+
+export interface UserState {
+  /**
+   * Keeps being defined even when credentials have expired.
+   */
+  account?: AccountDetailInfo;
+  preferences: PreferencesInfo;
+  diffPreferences: DiffPreferencesInfo;
+  editPreferences: EditPreferencesInfo;
+  capabilities?: AccountCapabilityInfo;
+}
+
+export class UserModel extends Model<UserState> implements Finalizable {
+  readonly account$: Observable<AccountDetailInfo | undefined> = select(
+    this.state$,
+    userState => userState.account
+  );
+
+  /** Note that this may still be true, even if credentials have expired. */
+  readonly loggedIn$: Observable<boolean> = select(
+    this.account$,
+    account => !!account
+  );
+
+  readonly capabilities$: Observable<AccountCapabilityInfo | undefined> =
+    select(this.state$, userState => userState.capabilities);
+
+  readonly isAdmin$: Observable<boolean> = select(
+    this.capabilities$,
+    capabilities => capabilities?.administrateServer ?? false
+  );
+
+  readonly preferences$: Observable<PreferencesInfo> = select(
+    this.state$,
+    userState => userState.preferences
+  );
+
+  readonly diffPreferences$: Observable<DiffPreferencesInfo> = select(
+    this.state$,
+    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
+  );
+
+  private subscriptions: Subscription[] = [];
+
+  constructor(readonly restApiService: RestApiService) {
+    super({
+      preferences: createDefaultPreferences(),
+      diffPreferences: createDefaultDiffPrefs(),
+      editPreferences: createDefaultEditPrefs(),
+    });
+    this.subscriptions = [
+      from(this.restApiService.getAccount()).subscribe(
+        (account?: AccountDetailInfo) => {
+          this.setAccount(account);
+        }
+      ),
+      this.account$
+        .pipe(
+          switchMap(account => {
+            if (!account) return of(createDefaultPreferences());
+            return from(this.restApiService.getPreferences());
+          })
+        )
+        .subscribe((preferences?: PreferencesInfo) => {
+          this.setPreferences(preferences ?? createDefaultPreferences());
+        }),
+      this.account$
+        .pipe(
+          switchMap(account => {
+            if (!account) return of(createDefaultDiffPrefs());
+            return from(this.restApiService.getDiffPreferences());
+          })
+        )
+        .subscribe((diffPrefs?: DiffPreferencesInfoAPI) => {
+          this.setDiffPreferences(diffPrefs ?? createDefaultDiffPrefs());
+        }),
+      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());
+          })
+        )
+        .subscribe((capabilities?: AccountCapabilityInfo) => {
+          this.setCapabilities(capabilities);
+        }),
+    ];
+  }
+
+  finalize() {
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+  }
+
+  updatePreferences(prefs: Partial<PreferencesInfo>) {
+    this.restApiService
+      .savePreferences(prefs)
+      .then((newPrefs: PreferencesInfo | undefined) => {
+        if (!newPrefs) return;
+        this.setPreferences(newPrefs);
+      });
+  }
+
+  updateDiffPreference(diffPrefs: DiffPreferencesInfo) {
+    return this.restApiService
+      .saveDiffPreferences(diffPrefs)
+      .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);
+        });
+      });
+  }
+
+  getDiffPreferences() {
+    return this.restApiService.getDiffPreferences().then(prefs => {
+      if (!prefs) return;
+      this.setDiffPreferences(prefs);
+    });
+  }
+
+  setPreferences(preferences: PreferencesInfo) {
+    const current = this.subject$.getValue();
+    this.subject$.next({...current, preferences});
+  }
+
+  setDiffPreferences(diffPreferences: DiffPreferencesInfo) {
+    const current = this.subject$.getValue();
+    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});
+  }
+
+  private setAccount(account?: AccountDetailInfo) {
+    const current = this.subject$.getValue();
+    this.subject$.next({...current, account});
+  }
+}
diff --git a/polygerrit-ui/app/node_modules_licenses/BUILD b/polygerrit-ui/app/node_modules_licenses/BUILD
index a2fddb1..77400c6 100644
--- a/polygerrit-ui/app/node_modules_licenses/BUILD
+++ b/polygerrit-ui/app/node_modules_licenses/BUILD
@@ -17,8 +17,7 @@
     tsconfig = "tsconfig.json",
     deps = [
         "//tools/node_tools/node_modules_licenses:licenses-map",
-        "@tools_npm//@bazel/typescript",
-        "@tools_npm//@types/node",
+        "@tools_npm//:node_modules",
     ],
 )
 
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index ede84ff..188bf79 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -426,7 +426,33 @@
       type: LicenseTypes.Mit,
       packageLicenseFile: "LICENSE",
     }
-  }
+  },
+  {
+    name: "highlight.js",
+    license: {
+      name: "highlight.js",
+      type: LicenseTypes.Bsd3,
+      packageLicenseFile: "LICENSE",
+    },
+    nonPackages: ["es"]
+  },
+  {
+    name: "highlightjs-closure-templates",
+    license: {
+      name: "highlightjs-closure-templates",
+      type: LicenseTypes.Bsd3,
+      packageLicenseFile: "LICENSE",
+    },
+  },
+  {
+    name: "highlightjs-structured-text",
+    license: {
+      name: "highlightjs-structured-text",
+      type: LicenseTypes.Bsd3,
+      packageLicenseFile: "LICENSE",
+    },
+  },
+
 ];
 
 export default packages;
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 281a1eb..37f7a9f 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -35,9 +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",
-    "lit": "2.0.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.2.3",
     "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
     "polymer-resin": "^2.0.1",
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 401c0c3..a0f7d34 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -26,6 +26,20 @@
         config_file = ":rollup.config.js",
         entry_point = entry_point,
         rollup_bin = "//tools/node_tools:rollup-bin",
+        silent = True,
+        sourcemap = "hidden",
+        deps = [
+            "@tools_npm//rollup-plugin-node-resolve",
+        ],
+    )
+
+    rollup_bundle(
+        name = "syntax-worker",
+        srcs = [app_name + "-full-src"],
+        config_file = ":rollup.config.js",
+        entry_point = "_pg_ts_out/workers/syntax-worker.js",
+        rollup_bin = "//tools/node_tools:rollup-bin",
+        silent = True,
         sourcemap = "hidden",
         deps = [
             "@tools_npm//rollup-plugin-node-resolve",
@@ -45,6 +59,13 @@
     )
 
     native.filegroup(
+        name = name + "_worker_sources",
+        srcs = [
+            "syntax-worker.js",
+        ],
+    )
+
+    native.filegroup(
         name = name + "_top_sources",
         srcs = [
             "favicon.ico",
@@ -59,6 +80,7 @@
             name + "_app_sources",
             name + "_css_sources",
             name + "_top_sources",
+            name + "_worker_sources",
             "//lib/fonts:robotofonts",
             "//lib/js:highlightjs__files",
             "@ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js",
@@ -70,11 +92,12 @@
         outs = outs,
         cmd = " && ".join([
             "FONT_DIR=$$(dirname $(location @ui_npm//:node_modules/@polymer/font-roboto-local/package.json))/fonts",
-            "mkdir -p $$TMP/polygerrit_ui/{styles/themes,fonts/{roboto,robotomono},bower_components/{highlightjs,webcomponentsjs,resemblejs},elements}",
+            "mkdir -p $$TMP/polygerrit_ui/{workers,styles/themes,fonts/{roboto,robotomono},bower_components/{highlightjs,webcomponentsjs,resemblejs},elements}",
             "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/" + app_name + ".$$ext; done",
             "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
             "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
             "for f in $(locations " + name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
+            "for f in $(locations " + name + "_worker_sources); do cp $$f $$TMP/polygerrit_ui/workers; done",
             "for f in $(locations //lib/js:highlightjs__files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
             "cp $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js) $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-lite.js",
             "cp $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js.map) $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-lite.js.map",
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_test.js b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.ts
similarity index 71%
rename from polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
rename to polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.ts
index 989bafb..465ba3f 100644
--- 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.ts
@@ -15,30 +15,31 @@
  * limitations under the License.
  */
 
-import '../../test/common-test-setup-karma.js';
-import {GrEmailSuggestionsProvider} from './gr-email-suggestions-provider.js';
-import {appContext} from '../../services/app-context.js';
-import {stubRestApi} from '../../test/test-utils.js';
+import '../../test/common-test-setup-karma';
+import {GrEmailSuggestionsProvider} from './gr-email-suggestions-provider';
+import {getAppContext} from '../../services/app-context';
+import {stubRestApi} from '../../test/test-utils';
+import {AccountId, EmailAddress} from '../../types/common';
 
 suite('GrEmailSuggestionsProvider tests', () => {
-  let provider;
+  let provider: GrEmailSuggestionsProvider;
   const account1 = {
     name: 'Some name',
-    email: 'some@example.com',
+    email: 'some@example.com' as EmailAddress,
   };
   const account2 = {
-    email: 'other@example.com',
-    _account_id: 3,
+    email: 'other@example.com' as EmailAddress,
+    _account_id: 3 as AccountId,
   };
 
   setup(() => {
-    provider = new GrEmailSuggestionsProvider(appContext.restApiService);
+    provider = new GrEmailSuggestionsProvider(getAppContext().restApiService);
   });
 
   test('getSuggestions', async () => {
-    const getSuggestedAccountsStub =
-        stubRestApi('getSuggestedAccounts').returns(
-            Promise.resolve([account1, account2]));
+    const getSuggestedAccountsStub = stubRestApi(
+      'getSuggestedAccounts'
+    ).returns(Promise.resolve([account1, account2]));
 
     const res = await provider.getSuggestions('Some input');
     assert.deepEqual(res, [account1, account2]);
@@ -64,4 +65,3 @@
     });
   });
 });
-
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.ts
similarity index 60%
rename from polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
rename to polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.ts
index 67f9433..41441f3 100644
--- 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.ts
@@ -15,34 +15,35 @@
  * limitations under the License.
  */
 
-import '../../test/common-test-setup-karma.js';
-import {GrGroupSuggestionsProvider} from './gr-group-suggestions-provider.js';
-import {appContext} from '../../services/app-context.js';
-import {stubRestApi} from '../../test/test-utils.js';
+import '../../test/common-test-setup-karma';
+import {GrGroupSuggestionsProvider} from './gr-group-suggestions-provider';
+import {getAppContext} from '../../services/app-context';
+import {stubRestApi} from '../../test/test-utils';
+import {GroupId, GroupName} from '../../types/common';
 
 suite('GrGroupSuggestionsProvider tests', () => {
-  let provider;
+  let provider: GrGroupSuggestionsProvider;
   const group1 = {
-    name: 'Some name',
-    id: 1,
+    name: 'Some name' as GroupName,
+    id: '1' as GroupId,
   };
   const group2 = {
-    name: 'Other name',
-    id: 3,
+    name: 'Other name' as GroupName,
+    id: '3' as GroupId,
     url: 'abcd',
   };
 
   setup(() => {
-    provider = new GrGroupSuggestionsProvider(appContext.restApiService);
+    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 getSuggestedAccountsStub = stubRestApi('getSuggestedGroups').returns(
+      Promise.resolve({
+        'Some name': {id: '1' as GroupId},
+        'Other name': {id: '3' as GroupId, url: 'abcd'},
+      })
+    );
 
     const res = await provider.getSuggestions('Some input');
     assert.deepEqual(res, [group1, group2]);
@@ -52,24 +53,23 @@
 
   test('makeSuggestionItem', () => {
     assert.deepEqual(provider.makeSuggestionItem(group1), {
-      name: 'Some name',
+      name: 'Some name' as GroupName,
       value: {
         group: {
-          name: 'Some name',
-          id: 1,
+          name: 'Some name' as GroupName,
+          id: '1' as GroupId,
         },
       },
     });
 
     assert.deepEqual(provider.makeSuggestionItem(group2), {
-      name: 'Other name',
+      name: 'Other name' as GroupName,
       value: {
         group: {
-          name: 'Other name',
-          id: 3,
+          name: 'Other name' as GroupName,
+          id: '3' as GroupId,
         },
       },
     });
   });
 });
-
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..78cff25 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
@@ -25,10 +25,10 @@
   isReviewerGroupSuggestion,
   NumericChangeId,
   ServerInfo,
-  SuggestedReviewerInfo,
   Suggestion,
 } from '../../types/common';
 import {assertNever} from '../../utils/common-util';
+import {AutocompleteSuggestion} from '../../elements/shared/gr-autocomplete/gr-autocomplete';
 
 // TODO(TS): enum name doesn't follow typescript style guid rules
 // Rename it
@@ -44,15 +44,10 @@
 
 type ApiCallCallback = (input: string) => Promise<Suggestion[] | void>;
 
-export interface SuggestionItem {
-  name: string;
-  value: SuggestedReviewerInfo;
-}
-
 export interface ReviewerSuggestionsProvider {
   init(): void;
   getSuggestions(input: string): Promise<Suggestion[]>;
-  makeSuggestionItem(suggestion: Suggestion): SuggestionItem;
+  makeSuggestionItem(suggestion: Suggestion): AutocompleteSuggestion;
 }
 
 export class GrReviewerSuggestionsProvider
@@ -120,12 +115,15 @@
     return this._apiCall(input).then(reviewers => reviewers || []);
   }
 
-  makeSuggestionItem(suggestion: Suggestion): SuggestionItem {
+  // this can be retyped to AutocompleteSuggestion<SuggestedReviewerInfo> but
+  // this would need to change generics of gr-autocomplete.
+  makeSuggestionItem(suggestion: Suggestion): AutocompleteSuggestion {
     if (isReviewerAccountSuggestion(suggestion)) {
       // Reviewer is an account suggestion from getChangeSuggestedReviewers.
       return {
         name: getAccountDisplayName(this.config, suggestion.account),
-        value: suggestion,
+        // TODO(TS) this is temporary hack to avoid cascade of ts issues
+        value: suggestion as unknown as string,
       };
     }
 
@@ -133,7 +131,8 @@
       // Reviewer is a group suggestion from getChangeSuggestedReviewers.
       return {
         name: getGroupDisplayName(suggestion.group),
-        value: suggestion,
+        // TODO(TS) this is temporary hack to avoid cascade of ts issues
+        value: suggestion as unknown as string,
       };
     }
 
@@ -141,7 +140,8 @@
       // Reviewer is an account suggestion from getSuggestedAccounts.
       return {
         name: getAccountDisplayName(this.config, suggestion),
-        value: {account: suggestion, count: 1},
+        // TODO(TS) this is temporary hack to avoid cascade of ts issues
+        value: {account: suggestion, count: 1} as unknown as string,
       };
     }
     assertNever(suggestion, 'Received an incorrect suggestion');
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
index 762d36c..1916822 100644
--- 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
@@ -17,7 +17,7 @@
 
 import '../../test/common-test-setup-karma.js';
 import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from './gr-reviewer-suggestions-provider.js';
-import {appContext} from '../../services/app-context.js';
+import {getAppContext} from '../../services/app-context.js';
 import {stubRestApi} from '../../test/test-utils.js';
 
 suite('GrReviewerSuggestionsProvider tests', () => {
@@ -84,7 +84,7 @@
   suite('allowAnyUser set to false', () => {
     setup(async () => {
       provider = GrReviewerSuggestionsProvider.create(
-          appContext.restApiService, change._number,
+          getAppContext().restApiService, change._number,
           SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
       await provider.init();
     });
@@ -204,7 +204,7 @@
   suite('allowAnyUser set to true', () => {
     setup(async () => {
       provider = GrReviewerSuggestionsProvider.create(
-          appContext.restApiService, change._number,
+          getAppContext().restApiService, change._number,
           SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
       await provider.init();
     });
diff --git a/polygerrit-ui/app/services/README.md b/polygerrit-ui/app/services/README.md
index b88532b..126db83 100644
--- a/polygerrit-ui/app/services/README.md
+++ b/polygerrit-ui/app/services/README.md
@@ -9,20 +9,17 @@
 Regarding all stateful should be considered as services or not, it's still TBD. Will update as soon
 as it's finalized.
 
-## How to access service
+## How to access a service
 
 We use AppContext to access instance of service. It helps in mocking service in tests as well.
 We prefer setting instance of service in constructor and then accessing it from variable. We also
-allow access straight from appContext especially in static methods.
+allow access straight from getAppContext() especially in static methods.
 
 ```
-import {appContext} from '../../../services/app-context.js';
+import {getAppContext()} from '../../../services/app-context.js';
 
 class T {
-  constructor() {
-    super();
-    this.flagsService = appContext.flagsService;
-  }
+  private readonly flagsService = getAppContext().flagsService;
 
   action1() {
     if (this.flagsService.isEnabled('test)) {
@@ -45,10 +42,10 @@
 'flags' is a service to provide easy access to all enabled experiments.
 
 ```
-import {appContext} from '../../../services/app-context.js';
+import {getAppContext} from '../../../services/app-context.js';
 
 // check if an experiment is enabled or not
-if (appContext.flagsService.isEnabled('test')) {
+if (getAppContext().flagsService.isEnabled('test')) {
   // do something
 }
-```
\ No newline at end of file
+```
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 3a6f7c5..cc52fc8 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -14,75 +14,109 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {appContext, AppContext} from './app-context';
+import {AppContext} from './app-context';
+import {create, Finalizable, Registry} from './registry';
+import {DependencyToken} from '../models/dependency';
 import {FlagsServiceImplementation} from './flags/flags_impl';
 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 {GrRestApiInterface} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
-import {ChangeService} from './change/change-service';
-import {ChecksService} from './checks/checks-service';
+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';
 import {GrStorageService} from './storage/gr-storage_impl';
-import {ConfigService} from './config/config-service';
-import {UserService} from './user/user-service';
-import {CommentsService} from './comments/comments-service';
+import {UserModel} from '../models/user/user-model';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../models/comments/comments-model';
+import {RouterModel} from './router/router-model';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
-
-type ServiceName = keyof AppContext;
-type ServiceCreator<T> = () => T;
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const initializedServices: Map<ServiceName, any> = new Map<ServiceName, any>();
-
-function getService<K extends ServiceName>(
-  serviceName: K,
-  serviceCreator: ServiceCreator<AppContext[K]>
-): AppContext[K] {
-  if (!initializedServices.has(serviceName)) {
-    initializedServices.set(serviceName, serviceCreator());
-  }
-  return initializedServices.get(serviceName);
-}
+import {assertIsDefined} from '../utils/common-util';
+import {ConfigModel, configModelToken} from '../models/config/config-model';
+import {BrowserModel, browserModelToken} from '../models/browser/browser-model';
+import {PluginsModel} from '../models/plugins/plugins-model';
+import {HighlightService} from './highlight/highlight-service';
 
 /**
  * The AppContext lazy initializator for all services
  */
-export function initAppContext() {
-  function populateAppContext(
-    serviceCreators: {[P in ServiceName]: ServiceCreator<AppContext[P]>}
-  ) {
-    const registeredServices = Object.keys(serviceCreators).reduce(
-      (registeredServices, key) => {
-        const serviceName = key as ServiceName;
-        const serviceCreator = serviceCreators[serviceName];
-        registeredServices[serviceName] = {
-          configurable: true, // Tests can mock properties
-          get() {
-            return getService(serviceName, serviceCreator);
-          },
-        };
-        return registeredServices;
-      },
-      {} as PropertyDescriptorMap
-    );
-    Object.defineProperties(appContext, registeredServices);
-  }
+export function createAppContext(): AppContext & Finalizable {
+  const appRegistry: Registry<AppContext> = {
+    routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
+    flagsService: (_ctx: Partial<AppContext>) =>
+      new FlagsServiceImplementation(),
+    reportingService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.flagsService, '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);
+    },
+    restApiService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.authService, 'authService');
+      return new GrRestApiServiceImpl(ctx.authService);
+    },
+    jsApiService: (ctx: Partial<AppContext>) => {
+      const reportingService = ctx.reportingService;
+      assertIsDefined(reportingService, 'reportingService');
+      return new GrJsApiInterface(reportingService);
+    },
+    storageService: (_ctx: Partial<AppContext>) => new GrStorageService(),
+    userModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.restApiService, '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);
+    },
+    pluginsModel: (_ctx: Partial<AppContext>) => new PluginsModel(),
+    highlightService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.reportingService, 'reportingService');
+      return new HighlightService(ctx.reportingService);
+    },
+  };
+  return create<AppContext>(appRegistry);
+}
 
-  populateAppContext({
-    flagsService: () => new FlagsServiceImplementation(),
-    reportingService: () => new GrReporting(appContext.flagsService),
-    eventEmitter: () => new EventEmitter(),
-    authService: () => new Auth(appContext.eventEmitter),
-    restApiService: () =>
-      new GrRestApiInterface(appContext.authService, appContext.flagsService),
-    changeService: () => new ChangeService(),
-    commentsService: () => new CommentsService(appContext.restApiService),
-    checksService: () => new ChecksService(appContext.reportingService),
-    jsApiService: () => new GrJsApiInterface(),
-    storageService: () => new GrStorageService(),
-    configService: () => new ConfigService(),
-    userService: () => new UserService(appContext.restApiService),
-    shortcutsService: () => new ShortcutsService(appContext.reportingService),
-  });
+export function createAppDependencies(
+  appContext: AppContext
+): Map<DependencyToken<unknown>, Finalizable> {
+  const dependencies = new Map<DependencyToken<unknown>, Finalizable>();
+  const browserModel = new BrowserModel(appContext.userModel);
+  dependencies.set(browserModelToken, browserModel);
+
+  const changeModel = new ChangeModel(
+    appContext.routerModel,
+    appContext.restApiService,
+    appContext.userModel
+  );
+  dependencies.set(changeModelToken, changeModel);
+
+  const commentsModel = new CommentsModel(
+    appContext.routerModel,
+    changeModel,
+    appContext.restApiService,
+    appContext.reportingService
+  );
+  dependencies.set(commentsModelToken, commentsModel);
+
+  const configModel = new ConfigModel(changeModel, appContext.restApiService);
+  dependencies.set(configModelToken, configModel);
+
+  const checksModel = new ChecksModel(
+    appContext.routerModel,
+    changeModel,
+    appContext.reportingService,
+    appContext.pluginsModel
+  );
+
+  dependencies.set(checksModelToken, checksModel);
+
+  return dependencies;
 }
diff --git a/polygerrit-ui/app/services/app-context-init_test.js b/polygerrit-ui/app/services/app-context-init_test.ts
similarity index 67%
rename from polygerrit-ui/app/services/app-context-init_test.js
rename to polygerrit-ui/app/services/app-context-init_test.ts
index 9d22ec2..626c571 100644
--- a/polygerrit-ui/app/services/app-context-init_test.js
+++ b/polygerrit-ui/app/services/app-context-init_test.ts
@@ -16,20 +16,27 @@
  */
 
 import '../test/common-test-setup-karma.js';
-import {appContext} from './app-context.js';
-import {initAppContext} from './app-context-init.js';
+import {AppContext} from './app-context.js';
+import {Finalizable} from './registry';
+import {createTestAppContext} from '../test/test-app-context-init.js';
+
 suite('app context initializer tests', () => {
+  let appContext: AppContext & Finalizable;
   setup(() => {
-    initAppContext();
+    appContext = createTestAppContext();
+  });
+
+  teardown(() => {
+    appContext.finalize();
   });
 
   test('all services initialized and are singletons', () => {
     Object.keys(appContext).forEach(serviceName => {
-      const service = appContext[serviceName];
+      const service = appContext[serviceName as keyof AppContext];
+      assert.isDefined(service);
       assert.isNotNull(service);
-      const service2 = appContext[serviceName];
+      const service2 = appContext[serviceName as keyof AppContext];
       assert.strictEqual(service, service2);
     });
   });
 });
-
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index e5828d6..ba21734 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -14,43 +14,52 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {Finalizable} from './registry';
 import {FlagsService} from './flags/flags';
 import {EventEmitterService} from './gr-event-interface/gr-event-interface';
 import {ReportingService} from './gr-reporting/gr-reporting';
 import {AuthService} from './gr-auth/gr-auth';
 import {RestApiService} from './gr-rest-api/gr-rest-api';
-import {ChangeService} from './change/change-service';
-import {ChecksService} from './checks/checks-service';
 import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
 import {StorageService} from './storage/gr-storage';
-import {ConfigService} from './config/config-service';
-import {UserService} from './user/user-service';
-import {CommentsService} from './comments/comments-service';
+import {UserModel} from '../models/user/user-model';
+import {RouterModel} from './router/router-model';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
+import {PluginsModel} from '../models/plugins/plugins-model';
+import {HighlightService} from './highlight/highlight-service';
 
 export interface AppContext {
+  routerModel: RouterModel;
   flagsService: FlagsService;
   reportingService: ReportingService;
   eventEmitter: EventEmitterService;
   authService: AuthService;
   restApiService: RestApiService;
-  changeService: ChangeService;
-  commentsService: CommentsService;
-  checksService: ChecksService;
   jsApiService: JsApiService;
   storageService: StorageService;
-  configService: ConfigService;
-  userService: UserService;
+  userModel: UserModel;
   shortcutsService: ShortcutsService;
+  pluginsModel: PluginsModel;
+  highlightService: HighlightService;
 }
 
 /**
- * The AppContext holds immortal singleton instances of services. It's a
- * convenient way to provide singletons that can be swapped out for testing.
+ * The AppContext holds instances of services. It's a convenient way to provide
+ * singletons that can be swapped out for testing.
  *
  * AppContext is initialized in ./app-context-init.js
  *
  * It is guaranteed that all fields in appContext are always initialized
  * (except for shared gr-diff)
  */
-export const appContext: AppContext = {} as AppContext;
+let appContext: (AppContext & Finalizable) | undefined = undefined;
+
+export function injectAppContext(ctx: AppContext & Finalizable) {
+  appContext?.finalize();
+  appContext = ctx;
+}
+
+export function getAppContext() {
+  if (!appContext) throw new Error('App context has not been injected');
+  return appContext;
+}
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
deleted file mode 100644
index 962ef4d..0000000
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ /dev/null
@@ -1,120 +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 {PatchSetNum} from '../../types/common';
-import {BehaviorSubject, combineLatest, Observable} from 'rxjs';
-import {
-  map,
-  filter,
-  withLatestFrom,
-  distinctUntilChanged,
-} from 'rxjs/operators';
-import {routerPatchNum$, routerState$} from '../router/router-model';
-import {
-  computeAllPatchSets,
-  computeLatestPatchNum,
-} from '../../utils/patch-set-util';
-import {ParsedChangeInfo} from '../../types/types';
-
-interface ChangeState {
-  change?: ParsedChangeInfo;
-}
-
-// TODO: Figure out how to best enforce immutability of all states. Use Immer?
-// Use DeepReadOnly?
-const initialState: ChangeState = {};
-
-const privateState$ = new BehaviorSubject(initialState);
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const changeState$: Observable<ChangeState> = privateState$;
-
-// Must only be used by the change service or whatever is in control of this
-// model.
-export function updateState(change?: ParsedChangeInfo) {
-  const current = privateState$.getValue();
-  // We want to make it easy for subscribers to react to change changes, so we
-  // are explicitly emitting an additional `undefined` when the change number
-  // changes. So if you are subscribed to the latestPatchsetNumber for example,
-  // then you can rely on emissions even if the old and the new change have the
-  // same latestPatchsetNumber.
-  if (change !== undefined && current.change !== undefined) {
-    if (change._number !== current.change._number) {
-      privateState$.next({...current, change: undefined});
-    }
-  }
-  privateState$.next({...current, change});
-}
-
-/**
- * If you depend on both, router and change state, then you want to filter out
- * inconsistent state, e.g. router changeNum already updated, change not yet
- * reset to undefined.
- */
-export const changeAndRouterConsistent$ = combineLatest([
-  routerState$,
-  changeState$,
-]).pipe(
-  filter(([routerState, changeState]) => {
-    const changeNum = changeState.change?._number;
-    const routerChangeNum = routerState.changeNum;
-    return changeNum === undefined || changeNum === routerChangeNum;
-  }),
-  distinctUntilChanged()
-);
-
-export const change$ = changeState$.pipe(
-  map(changeState => changeState.change),
-  distinctUntilChanged()
-);
-
-export const changeNum$ = change$.pipe(
-  map(change => change?._number),
-  distinctUntilChanged()
-);
-
-export const repo$ = change$.pipe(
-  map(change => change?.project),
-  distinctUntilChanged()
-);
-
-export const labels$ = change$.pipe(
-  map(change => change?.labels),
-  distinctUntilChanged()
-);
-
-export const latestPatchNum$ = change$.pipe(
-  map(change => computeLatestPatchNum(computeAllPatchSets(change))),
-  distinctUntilChanged()
-);
-
-/**
- * Emits the current patchset number. If the route does not define the current
- * patchset num, then this selector waits for the change to be defined and
- * returns the number of the latest patchset.
- *
- * Note that this selector can emit a patchNum without the change being
- * available!
- */
-export const currentPatchNum$: Observable<PatchSetNum | undefined> =
-  changeAndRouterConsistent$.pipe(
-    withLatestFrom(routerPatchNum$, latestPatchNum$),
-    map(
-      ([_, routerPatchNum, latestPatchNum]) => routerPatchNum || latestPatchNum
-    ),
-    distinctUntilChanged()
-  );
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
deleted file mode 100644
index 0b9a1f2..0000000
--- a/polygerrit-ui/app/services/change/change-service.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 {routerChangeNum$} from '../router/router-model';
-import {change$, updateState} from './change-model';
-import {ParsedChangeInfo} from '../../types/types';
-import {appContext} from '../app-context';
-import {ChangeInfo} from '../../types/common';
-import {
-  computeAllPatchSets,
-  computeLatestPatchNum,
-} from '../../utils/patch-set-util';
-
-export class ChangeService {
-  private change?: ParsedChangeInfo;
-
-  private readonly restApiService = appContext.restApiService;
-
-  constructor() {
-    // TODO: In the future we will want to make restApiService.getChangeDetail()
-    // calls from a switchMap() here. For now just make sure to invalidate the
-    // change when no changeNum is set.
-    routerChangeNum$.subscribe(changeNum => {
-      if (!changeNum) updateState(undefined);
-    });
-    change$.subscribe(change => {
-      this.change = change;
-    });
-  }
-
-  /**
-   * This is a temporary indirection between change-view, which currently
-   * manages what the current change is, and the change-model, which will
-   * become the source of truth in the future. We will extract a substantial
-   * amount of code from change-view and move it into this change-service. This
-   * will take some time ...
-   */
-  updateChange(change: ParsedChangeInfo) {
-    updateState(change);
-  }
-
-  /**
-   * Typically you would just subscribe to change$ yourself to get updates. But
-   * sometimes it is nice to also be able to get the current ChangeInfo on
-   * demand. So here it is for your convenience.
-   */
-  getChange() {
-    return this.change;
-  }
-
-  /**
-   * Check whether there is no newer patch than the latest patch that was
-   * available when this change was loaded.
-   *
-   * @return A promise that yields true if the latest patch
-   *     has been loaded, and false if a newer patch has been uploaded in the
-   *     meantime. The promise is rejected on network error.
-   */
-  fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
-    const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
-    return this.restApiService.getChangeDetail(change._number).then(detail => {
-      if (!detail) {
-        const error = new Error('Change detail not found.');
-        return Promise.reject(error);
-      }
-      const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
-      if (!actualLatest || !knownLatest) {
-        const error = new Error('Unable to check for latest patchset.');
-        return Promise.reject(error);
-      }
-      return {
-        isLatest: actualLatest <= knownLatest,
-        newStatus: change.status !== detail.status ? detail.status : null,
-        newMessages:
-          (change.messages || []).length < (detail.messages || []).length
-            ? detail.messages![detail.messages!.length - 1]
-            : undefined,
-      };
-    });
-  }
-}
diff --git a/polygerrit-ui/app/services/change/change-services_test.ts b/polygerrit-ui/app/services/change/change-services_test.ts
deleted file mode 100644
index 3e427ff..0000000
--- a/polygerrit-ui/app/services/change/change-services_test.ts
+++ /dev/null
@@ -1,108 +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 {ChangeStatus} from '../../constants/constants';
-import '../../test/common-test-setup-karma';
-import {
-  createChange,
-  createChangeMessageInfo,
-  createRevision,
-} from '../../test/test-data-generators';
-import {stubRestApi} from '../../test/test-utils';
-import {CommitId, PatchSetNum} from '../../types/common';
-import {ParsedChangeInfo} from '../../types/types';
-import {ChangeService} from './change-service';
-
-suite('change service tests', () => {
-  let changeService: ChangeService;
-  let knownChange: ParsedChangeInfo;
-  setup(() => {
-    changeService = new ChangeService();
-    knownChange = {
-      ...createChange(),
-      revisions: {
-        sha1: {
-          ...createRevision(1),
-          description: 'patch 1',
-          _number: 1 as PatchSetNum,
-        },
-        sha2: {
-          ...createRevision(2),
-          description: 'patch 2',
-          _number: 2 as PatchSetNum,
-        },
-      },
-      status: ChangeStatus.NEW,
-      current_revision: 'abc' as CommitId,
-      messages: [],
-    };
-  });
-
-  test('changeService.fetchChangeUpdates on latest', async () => {
-    stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
-    assert.isTrue(result.isLatest);
-    assert.isNotOk(result.newStatus);
-    assert.isNotOk(result.newMessages);
-  });
-
-  test('changeService.fetchChangeUpdates not on latest', async () => {
-    const actualChange = {
-      ...knownChange,
-      revisions: {
-        ...knownChange.revisions,
-        sha3: {
-          ...createRevision(3),
-          description: 'patch 3',
-          _number: 3 as PatchSetNum,
-        },
-      },
-    };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
-    assert.isFalse(result.isLatest);
-    assert.isNotOk(result.newStatus);
-    assert.isNotOk(result.newMessages);
-  });
-
-  test('changeService.fetchChangeUpdates new status', async () => {
-    const actualChange = {
-      ...knownChange,
-      status: ChangeStatus.MERGED,
-    };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
-    assert.isTrue(result.isLatest);
-    assert.equal(result.newStatus, ChangeStatus.MERGED);
-    assert.isNotOk(result.newMessages);
-  });
-
-  test('changeService.fetchChangeUpdates new messages', async () => {
-    const actualChange = {
-      ...knownChange,
-      messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
-    };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
-    assert.isTrue(result.isLatest);
-    assert.isNotOk(result.newStatus);
-    assert.deepEqual(result.newMessages, {
-      ...createChangeMessageInfo(),
-      message: 'blah blah',
-    });
-  });
-});
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
deleted file mode 100644
index 75c24b6..0000000
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ /dev/null
@@ -1,892 +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 {BehaviorSubject, Observable} from 'rxjs';
-import {
-  Action,
-  Category,
-  CheckResult as CheckResultApi,
-  CheckRun as CheckRunApi,
-  Link,
-  LinkIcon,
-  RunStatus,
-  TagColor,
-} from '../../api/checks';
-import {distinctUntilChanged, map} from 'rxjs/operators';
-import {PatchSetNumber} from '../../types/common';
-import {AttemptDetail, createAttemptMap} from './checks-util';
-import {assertIsDefined} from '../../utils/common-util';
-import {deepEqualStringDict, equalArray} from '../../utils/compare-util';
-
-/**
- * The checks model maintains the state of checks for two patchsets: the latest
- * and (if different) also for the one selected in the checks tab. So we need
- * the distinction in a lot of places for checks about whether the code affects
- * the checks data of the LATEST or the SELECTED patchset.
- */
-export enum ChecksPatchset {
-  LATEST = 'LATEST',
-  SELECTED = 'SELECTED',
-}
-
-export interface CheckResult extends CheckResultApi {
-  /**
-   * Internally we want to uniquely identify a run with an id, for example when
-   * efficiently re-rendering lists of runs in the UI.
-   */
-  internalResultId: string;
-}
-
-export interface CheckRun extends CheckRunApi {
-  /**
-   * For convenience we attach the name of the plugin to each run.
-   */
-  pluginName: string;
-  /**
-   * Internally we want to uniquely identify a result with an id, for example
-   * when efficiently re-rendering lists of results in the UI.
-   */
-  internalRunId: string;
-  /**
-   * Is this run attempt the latest attempt for the check, i.e. does it have
-   * the highest attempt number among all checks with the same name?
-   */
-  isLatestAttempt: boolean;
-  /**
-   * Is this the only attempt for the check, i.e. we don't have data for other
-   * attempts?
-   */
-  isSingleAttempt: boolean;
-  /**
-   * List of all attempts for the same check, ordered by attempt number.
-   */
-  attemptDetails: AttemptDetail[];
-  results?: CheckResult[];
-}
-
-// This is a convenience type for working with results, because when working
-// with a bunch of results you will typically also want to know about the run
-// properties. So you can just combine them with {...run, ...result}.
-export type RunResult = CheckRun & CheckResult;
-
-interface ChecksProviderState {
-  pluginName: string;
-  loading: boolean;
-  /**
-   * Allows to distinguish whether loading:true is the *first* time of loading
-   * something for this provider. Or just a subsequent background update.
-   * Note that this is initially true even before loading is being set to true,
-   * so you may want to check loading && firstTimeLoad.
-   */
-  firstTimeLoad: boolean;
-  /** Presence of errorMessage implicitly means that the provider is in ERROR state. */
-  errorMessage?: string;
-  /** Presence of loginCallback implicitly means that the provider is in NOT_LOGGED_IN state. */
-  loginCallback?: () => void;
-  runs: CheckRun[];
-  actions: Action[];
-  links: Link[];
-}
-
-interface ChecksState {
-  /**
-   * This is the patchset number selected by the user. The *latest* patchset
-   * can be picked up from the change model.
-   */
-  patchsetNumberSelected?: PatchSetNumber;
-  /** Checks data for the latest patchset. */
-  pluginStateLatest: {
-    [name: string]: ChecksProviderState;
-  };
-  /**
-   * Checks data for the selected patchset. Note that `checksSelected$` below
-   * falls back to the data for the latest patchset, if no patchset is selected.
-   */
-  pluginStateSelected: {
-    [name: string]: ChecksProviderState;
-  };
-}
-
-const initialState: ChecksState = {
-  pluginStateLatest: {},
-  pluginStateSelected: {},
-};
-
-// Mutable for testing
-let privateState$ = new BehaviorSubject(initialState);
-
-export function _testOnly_resetState() {
-  privateState$ = new BehaviorSubject(initialState);
-}
-
-export function _testOnly_setState(state: ChecksState) {
-  privateState$.next(state);
-}
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
-}
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const checksState$: Observable<ChecksState> = privateState$;
-
-export const checksSelectedPatchsetNumber$ = checksState$.pipe(
-  map(state => state.patchsetNumberSelected),
-  distinctUntilChanged()
-);
-
-export const checksLatest$ = checksState$.pipe(
-  map(state => state.pluginStateLatest),
-  distinctUntilChanged()
-);
-
-export const checksSelected$ = checksState$.pipe(
-  map(state =>
-    state.patchsetNumberSelected
-      ? state.pluginStateSelected
-      : state.pluginStateLatest
-  ),
-  distinctUntilChanged()
-);
-
-export const aPluginHasRegistered$ = checksLatest$.pipe(
-  map(state => Object.keys(state).length > 0),
-  distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingFirstTime$ = checksLatest$.pipe(
-  map(state =>
-    Object.values(state).some(
-      provider => provider.loading && provider.firstTimeLoad
-    )
-  ),
-  distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingLatest$ = checksLatest$.pipe(
-  map(state =>
-    Object.values(state).some(providerState => providerState.loading)
-  ),
-  distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingSelected$ = checksSelected$.pipe(
-  map(state =>
-    Object.values(state).some(providerState => providerState.loading)
-  ),
-  distinctUntilChanged()
-);
-
-export const errorMessageLatest$ = checksLatest$.pipe(
-  map(
-    state =>
-      Object.values(state).find(
-        providerState => providerState.errorMessage !== undefined
-      )?.errorMessage
-  ),
-  distinctUntilChanged()
-);
-
-export interface ErrorMessages {
-  /* Maps plugin name to error message. */
-  [name: string]: string;
-}
-
-export const errorMessagesLatest$ = checksLatest$.pipe(
-  map(state => {
-    const errorMessages: ErrorMessages = {};
-    for (const providerState of Object.values(state)) {
-      if (providerState.errorMessage === undefined) continue;
-      errorMessages[providerState.pluginName] = providerState.errorMessage;
-    }
-    return errorMessages;
-  }),
-  distinctUntilChanged(deepEqualStringDict)
-);
-
-export const loginCallbackLatest$ = checksLatest$.pipe(
-  map(
-    state =>
-      Object.values(state).find(
-        providerState => providerState.loginCallback !== undefined
-      )?.loginCallback
-  ),
-  distinctUntilChanged()
-);
-
-export const topLevelActionsLatest$ = checksLatest$.pipe(
-  map(state =>
-    Object.values(state).reduce(
-      (allActions: Action[], providerState: ChecksProviderState) => [
-        ...allActions,
-        ...providerState.actions,
-      ],
-      []
-    )
-  ),
-  distinctUntilChanged<Action[]>(equalArray)
-);
-
-export const topLevelActionsSelected$ = checksSelected$.pipe(
-  map(state =>
-    Object.values(state).reduce(
-      (allActions: Action[], providerState: ChecksProviderState) => [
-        ...allActions,
-        ...providerState.actions,
-      ],
-      []
-    )
-  ),
-  distinctUntilChanged<Action[]>(equalArray)
-);
-
-export const topLevelLinksSelected$ = checksSelected$.pipe(
-  map(state =>
-    Object.values(state).reduce(
-      (allLinks: Link[], providerState: ChecksProviderState) => [
-        ...allLinks,
-        ...providerState.links,
-      ],
-      []
-    )
-  ),
-  distinctUntilChanged<Link[]>(equalArray)
-);
-
-export const allRunsLatestPatchset$ = checksLatest$.pipe(
-  map(state =>
-    Object.values(state).reduce(
-      (allRuns: CheckRun[], providerState: ChecksProviderState) => [
-        ...allRuns,
-        ...providerState.runs,
-      ],
-      []
-    )
-  ),
-  distinctUntilChanged<CheckRun[]>(equalArray)
-);
-
-export const allRunsSelectedPatchset$ = checksSelected$.pipe(
-  map(state =>
-    Object.values(state).reduce(
-      (allRuns: CheckRun[], providerState: ChecksProviderState) => [
-        ...allRuns,
-        ...providerState.runs,
-      ],
-      []
-    )
-  ),
-  distinctUntilChanged<CheckRun[]>(equalArray)
-);
-
-export const allRunsLatestPatchsetLatestAttempt$ = allRunsLatestPatchset$.pipe(
-  map(runs => runs.filter(run => run.isLatestAttempt))
-);
-
-export const checkToPluginMap$ = checksLatest$.pipe(
-  map(state => {
-    const map = new Map<string, string>();
-    for (const [pluginName, providerState] of Object.entries(state)) {
-      for (const run of providerState.runs) {
-        map.set(run.checkName, pluginName);
-      }
-    }
-    return map;
-  })
-);
-
-export const allResultsSelected$ = checksSelected$.pipe(
-  map(state =>
-    Object.values(state)
-      .reduce(
-        (allResults: CheckResult[], providerState: ChecksProviderState) => [
-          ...allResults,
-          ...providerState.runs.reduce(
-            (results: CheckResult[], run: CheckRun) =>
-              results.concat(run.results ?? []),
-            []
-          ),
-        ],
-        []
-      )
-      .filter(r => r !== undefined)
-  )
-);
-
-// Must only be used by the checks service or whatever is in control of this
-// model.
-export function updateStateSetProvider(
-  pluginName: string,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    pluginName,
-    loading: false,
-    firstTimeLoad: true,
-    runs: [],
-    actions: [],
-    links: [],
-  };
-  privateState$.next(nextState);
-}
-
-// TODO(brohlfs): Remove all fake runs once the Checks UI is fully launched.
-//  They are just making it easier to develop the UI and always see all the
-//  different types/states of runs and results.
-
-export const fakeRun0: CheckRun = {
-  pluginName: 'f0',
-  internalRunId: 'f0',
-  checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder Finder',
-  labelName: 'Presubmit',
-  isSingleAttempt: true,
-  isLatestAttempt: true,
-  attemptDetails: [],
-  results: [
-    {
-      internalResultId: 'f0r0',
-      category: Category.ERROR,
-      summary: 'I would like to point out this error: 1 is not equal to 2!',
-      links: [
-        {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL},
-      ],
-      tags: [{name: 'OBSOLETE'}, {name: 'E2E'}],
-    },
-    {
-      internalResultId: 'f0r1',
-      category: Category.ERROR,
-      summary: 'Running the mighty test has failed by crashing.',
-      message: 'Btw, 1 is also not equal to 3. Did you know?',
-      actions: [
-        {
-          name: 'Ignore',
-          tooltip: 'Ignore this result',
-          primary: true,
-          callback: () => Promise.resolve({message: 'fake "ignore" triggered'}),
-        },
-        {
-          name: 'Flag',
-          tooltip: 'Flag this result as totally absolutely really not useful',
-          primary: true,
-          disabled: true,
-          callback: () => Promise.resolve({message: 'flag "flag" triggered'}),
-        },
-        {
-          name: 'Upload',
-          tooltip: 'Upload the result to the super cloud.',
-          primary: false,
-          callback: () => Promise.resolve({message: 'fake "upload" triggered'}),
-        },
-      ],
-      tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
-      links: [
-        {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
-        {
-          primary: true,
-          url: 'https://google.com',
-          icon: LinkIcon.DOWNLOAD_MOBILE,
-        },
-        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
-        {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
-      ],
-    },
-  ],
-  status: RunStatus.COMPLETED,
-};
-
-export const fakeRun1: CheckRun = {
-  pluginName: 'f1',
-  internalRunId: 'f1',
-  checkName: 'FAKE Super Check',
-  statusLink: 'https://www.google.com/',
-  patchset: 1,
-  labelName: 'Verified',
-  isSingleAttempt: true,
-  isLatestAttempt: true,
-  attemptDetails: [],
-  results: [
-    {
-      internalResultId: 'f1r0',
-      category: Category.WARNING,
-      summary: 'We think that you could improve this.',
-      message: `There is a lot to be said. A lot. I say, a lot.\n
-                So please keep reading.`,
-      tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
-      codePointers: [
-        {
-          path: '/COMMIT_MSG',
-          range: {
-            start_line: 10,
-            start_character: 0,
-            end_line: 10,
-            end_character: 0,
-          },
-        },
-        {
-          path: 'polygerrit-ui/app/api/checks.ts',
-          range: {
-            start_line: 5,
-            start_character: 0,
-            end_line: 7,
-            end_character: 0,
-          },
-        },
-      ],
-      links: [
-        {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
-        {
-          primary: true,
-          url: 'https://google.com',
-          icon: LinkIcon.DOWNLOAD_MOBILE,
-        },
-        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
-        {
-          primary: false,
-          url: 'https://google.com',
-          tooltip: 'look at this',
-          icon: LinkIcon.IMAGE,
-        },
-        {
-          primary: false,
-          url: 'https://google.com',
-          tooltip: 'not at this',
-          icon: LinkIcon.IMAGE,
-        },
-      ],
-    },
-  ],
-  status: RunStatus.RUNNING,
-};
-
-export const fakeRun2: CheckRun = {
-  pluginName: 'f2',
-  internalRunId: 'f2',
-  checkName: 'FAKE Mega Analysis',
-  statusDescription: 'This run is nearly completed, but not quite.',
-  statusLink: 'https://www.google.com/',
-  checkDescription:
-    'From what the title says you can tell that this check analyses.',
-  checkLink: 'https://www.google.com/',
-  scheduledTimestamp: new Date('2021-04-01T03:14:15'),
-  startedTimestamp: new Date('2021-04-01T04:24:25'),
-  finishedTimestamp: new Date('2021-04-01T04:44:44'),
-  isSingleAttempt: true,
-  isLatestAttempt: true,
-  attemptDetails: [],
-  actions: [
-    {
-      name: 'Re-Run',
-      tooltip: 'More powerful run than before',
-      primary: true,
-      callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
-    },
-    {
-      name: 'Monetize',
-      primary: true,
-      disabled: true,
-      callback: () => Promise.resolve({message: 'fake "monetize" triggered'}),
-    },
-    {
-      name: 'Delete',
-      primary: true,
-      callback: () => Promise.resolve({message: 'fake "delete" triggered'}),
-    },
-  ],
-  results: [
-    {
-      internalResultId: 'f2r0',
-      category: Category.INFO,
-      summary: 'This is looking a bit too large.',
-      message: `We are still looking into how large exactly. Stay tuned.
-And have a look at https://www.google.com!
-
-Or have a look at change 30000.
-Example code:
-  const constable = '';
-  var variable = '';`,
-      tags: [{name: 'FLAKY'}, {name: 'MAC-OS'}],
-    },
-  ],
-  status: RunStatus.COMPLETED,
-};
-
-export const fakeRun3: CheckRun = {
-  pluginName: 'f3',
-  internalRunId: 'f3',
-  checkName: 'FAKE Critical Observations',
-  status: RunStatus.RUNNABLE,
-  isSingleAttempt: true,
-  isLatestAttempt: true,
-  attemptDetails: [],
-};
-
-export const fakeRun4_1: CheckRun = {
-  pluginName: 'f4',
-  internalRunId: 'f4',
-  checkName: 'FAKE Elimination Long Long Long Long Long',
-  status: RunStatus.RUNNABLE,
-  attempt: 1,
-  isSingleAttempt: false,
-  isLatestAttempt: false,
-  attemptDetails: [],
-};
-
-export const fakeRun4_2: CheckRun = {
-  pluginName: 'f4',
-  internalRunId: 'f4',
-  checkName: 'FAKE Elimination Long Long Long Long Long',
-  status: RunStatus.COMPLETED,
-  attempt: 2,
-  isSingleAttempt: false,
-  isLatestAttempt: false,
-  attemptDetails: [],
-  results: [
-    {
-      internalResultId: 'f42r0',
-      category: Category.INFO,
-      summary: 'Please eliminate all the TODOs!',
-    },
-  ],
-};
-
-export const fakeRun4_3: CheckRun = {
-  pluginName: 'f4',
-  internalRunId: 'f4',
-  checkName: 'FAKE Elimination Long Long Long Long Long',
-  status: RunStatus.COMPLETED,
-  attempt: 3,
-  isSingleAttempt: false,
-  isLatestAttempt: false,
-  attemptDetails: [],
-  results: [
-    {
-      internalResultId: 'f43r0',
-      category: Category.ERROR,
-      summary: 'Without eliminating all the TODOs your change will break!',
-    },
-  ],
-};
-
-export const fakeRun4_4: CheckRun = {
-  pluginName: 'f4',
-  internalRunId: 'f4',
-  checkName: 'FAKE Elimination Long Long Long Long Long',
-  checkDescription: 'Shows you the possible eliminations.',
-  checkLink: 'https://www.google.com',
-  status: RunStatus.COMPLETED,
-  statusDescription: 'Everything was eliminated already.',
-  statusLink: 'https://www.google.com',
-  attempt: 40,
-  scheduledTimestamp: new Date('2021-04-02T03:14:15'),
-  startedTimestamp: new Date('2021-04-02T04:24:25'),
-  finishedTimestamp: new Date('2021-04-02T04:25:44'),
-  isSingleAttempt: false,
-  isLatestAttempt: true,
-  attemptDetails: [],
-  results: [
-    {
-      internalResultId: 'f44r0',
-      category: Category.INFO,
-      summary: 'Dont be afraid. All TODOs will be eliminated.',
-      actions: [
-        {
-          name: 'Re-Run',
-          tooltip: 'More powerful run than before with a long tooltip, really.',
-          primary: true,
-          callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
-        },
-      ],
-    },
-  ],
-  actions: [
-    {
-      name: 'Re-Run',
-      tooltip: 'small',
-      primary: true,
-      callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
-    },
-  ],
-};
-
-export function fakeRun4CreateAttempts(from: number, to: number): CheckRun[] {
-  const runs: CheckRun[] = [];
-  for (let i = from; i < to; i++) {
-    runs.push(fakeRun4CreateAttempt(i));
-  }
-  return runs;
-}
-
-export function fakeRun4CreateAttempt(attempt: number): CheckRun {
-  return {
-    pluginName: 'f4',
-    internalRunId: 'f4',
-    checkName: 'FAKE Elimination Long Long Long Long Long',
-    status: RunStatus.COMPLETED,
-    attempt,
-    isSingleAttempt: false,
-    isLatestAttempt: false,
-    attemptDetails: [],
-    results:
-      attempt % 2 === 0
-        ? [
-            {
-              internalResultId: 'f43r0',
-              category: Category.ERROR,
-              summary:
-                'Without eliminating all the TODOs your change will break!',
-            },
-          ]
-        : [],
-  };
-}
-
-export const fakeRun4Att = [
-  fakeRun4_1,
-  fakeRun4_2,
-  fakeRun4_3,
-  ...fakeRun4CreateAttempts(5, 40),
-  fakeRun4_4,
-];
-
-export const fakeActions: Action[] = [
-  {
-    name: 'Fake Action 1',
-    primary: true,
-    disabled: true,
-    tooltip: 'Tooltip for Fake Action 1',
-    callback: () => Promise.resolve({message: 'fake action 1 triggered'}),
-  },
-  {
-    name: 'Fake Action 2',
-    primary: false,
-    disabled: true,
-    tooltip: 'Tooltip for Fake Action 2',
-    callback: () => Promise.resolve({message: 'fake action 2 triggered'}),
-  },
-  {
-    name: 'Fake Action 3',
-    summary: true,
-    primary: false,
-    tooltip: 'Tooltip for Fake Action 3',
-    callback: () => Promise.resolve({message: 'fake action 3 triggered'}),
-  },
-];
-
-export const fakeLinks: Link[] = [
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Bug Report 1',
-    icon: LinkIcon.REPORT_BUG,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Bug Report 2',
-    icon: LinkIcon.REPORT_BUG,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Link 1',
-    icon: LinkIcon.EXTERNAL,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: false,
-    tooltip: 'Fake Link 2',
-    icon: LinkIcon.EXTERNAL,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Code Link',
-    icon: LinkIcon.CODE,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Image Link',
-    icon: LinkIcon.IMAGE,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Help Link',
-    icon: LinkIcon.HELP_PAGE,
-  },
-];
-
-export function getPluginState(
-  state: ChecksState,
-  patchset: ChecksPatchset = ChecksPatchset.LATEST
-) {
-  if (patchset === ChecksPatchset.LATEST) {
-    state.pluginStateLatest = {...state.pluginStateLatest};
-    return state.pluginStateLatest;
-  } else {
-    state.pluginStateSelected = {...state.pluginStateSelected};
-    return state.pluginStateSelected;
-  }
-}
-
-export function updateStateSetLoading(
-  pluginName: string,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    loading: true,
-  };
-  privateState$.next(nextState);
-}
-
-export function updateStateSetError(
-  pluginName: string,
-  errorMessage: string,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    loading: false,
-    firstTimeLoad: false,
-    errorMessage,
-    loginCallback: undefined,
-    runs: [],
-    actions: [],
-  };
-  privateState$.next(nextState);
-}
-
-export function updateStateSetNotLoggedIn(
-  pluginName: string,
-  loginCallback: () => void,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    loading: false,
-    firstTimeLoad: false,
-    errorMessage: undefined,
-    loginCallback,
-    runs: [],
-    actions: [],
-  };
-  privateState$.next(nextState);
-}
-
-export function updateStateSetResults(
-  pluginName: string,
-  runs: CheckRunApi[],
-  actions: Action[] = [],
-  links: Link[] = [],
-  patchset: ChecksPatchset
-) {
-  const attemptMap = createAttemptMap(runs);
-  for (const attemptInfo of attemptMap.values()) {
-    // Per run only one attempt can be undefined, so the '?? -1' is not really
-    // relevant for sorting.
-    attemptInfo.attempts.sort((a, b) => (a.attempt ?? -1) - (b.attempt ?? -1));
-  }
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    loading: false,
-    firstTimeLoad: false,
-    errorMessage: undefined,
-    loginCallback: undefined,
-    runs: runs.map(run => {
-      const runId = `${run.checkName}-${run.change}-${run.patchset}-${run.attempt}`;
-      const attemptInfo = attemptMap.get(run.checkName);
-      assertIsDefined(attemptInfo, 'attemptInfo');
-      return {
-        ...run,
-        pluginName,
-        internalRunId: runId,
-        isLatestAttempt: attemptInfo.latestAttempt === run.attempt,
-        isSingleAttempt: attemptInfo.isSingleAttempt,
-        attemptDetails: attemptInfo.attempts,
-        results: (run.results ?? []).map((result, i) => {
-          return {
-            ...result,
-            internalResultId: `${runId}-${i}`,
-          };
-        }),
-      };
-    }),
-    actions: [...actions],
-    links: [...links],
-  };
-  privateState$.next(nextState);
-}
-
-export function updateStateUpdateResult(
-  pluginName: string,
-  updatedRun: CheckRunApi,
-  updatedResult: CheckResultApi,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  let runUpdated = false;
-  const runs: CheckRun[] = pluginState[pluginName].runs.map(run => {
-    if (run.change !== updatedRun.change) return run;
-    if (run.patchset !== updatedRun.patchset) return run;
-    if (run.attempt !== updatedRun.attempt) return run;
-    if (run.checkName !== updatedRun.checkName) return run;
-    let resultUpdated = false;
-    const results: CheckResult[] = (run.results ?? []).map(result => {
-      if (result.externalId && result.externalId === updatedResult.externalId) {
-        runUpdated = true;
-        resultUpdated = true;
-        return {
-          ...updatedResult,
-          internalResultId: result.internalResultId,
-        };
-      }
-      return result;
-    });
-    return resultUpdated ? {...run, results} : run;
-  });
-  if (!runUpdated) return;
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    runs,
-  };
-  privateState$.next(nextState);
-}
-
-export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
-  const nextState = {...privateState$.getValue()};
-  nextState.patchsetNumberSelected = patchsetNumber;
-  privateState$.next(nextState);
-}
diff --git a/polygerrit-ui/app/services/checks/checks-model_test.ts b/polygerrit-ui/app/services/checks/checks-model_test.ts
deleted file mode 100644
index dbd3f86..0000000
--- a/polygerrit-ui/app/services/checks/checks-model_test.ts
+++ /dev/null
@@ -1,112 +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 '../../test/common-test-setup-karma';
-import './checks-model';
-import {
-  _testOnly_getState,
-  _testOnly_resetState,
-  ChecksPatchset,
-  updateStateSetLoading,
-  updateStateSetProvider,
-  updateStateSetResults,
-  updateStateUpdateResult,
-} from './checks-model';
-import {Category, CheckRun, RunStatus} from '../../api/checks';
-
-const PLUGIN_NAME = 'test-plugin';
-
-const RUNS: CheckRun[] = [
-  {
-    checkName: 'MacCheck',
-    change: 123,
-    patchset: 1,
-    attempt: 1,
-    status: RunStatus.COMPLETED,
-    results: [
-      {
-        externalId: 'id-314',
-        category: Category.WARNING,
-        summary: 'Meddle cheddle check and you are weg.',
-      },
-    ],
-  },
-];
-
-function current() {
-  return _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
-}
-
-suite('checks-model tests', () => {
-  test('updateStateSetProvider', () => {
-    _testOnly_resetState();
-    updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
-    assert.deepEqual(current(), {
-      pluginName: PLUGIN_NAME,
-      loading: false,
-      firstTimeLoad: true,
-      runs: [],
-      actions: [],
-      links: [],
-    });
-  });
-
-  test('loading and first time load', () => {
-    _testOnly_resetState();
-    updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
-    assert.isFalse(current().loading);
-    assert.isTrue(current().firstTimeLoad);
-    updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
-    assert.isTrue(current().loading);
-    assert.isTrue(current().firstTimeLoad);
-    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
-    assert.isFalse(current().loading);
-    assert.isFalse(current().firstTimeLoad);
-    updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
-    assert.isTrue(current().loading);
-    assert.isFalse(current().firstTimeLoad);
-    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
-    assert.isFalse(current().loading);
-    assert.isFalse(current().firstTimeLoad);
-  });
-
-  test('updateStateSetResults', () => {
-    _testOnly_resetState();
-    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
-    assert.lengthOf(current().runs, 1);
-    assert.lengthOf(current().runs[0].results!, 1);
-  });
-
-  test('updateStateUpdateResult', () => {
-    _testOnly_resetState();
-    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
-    assert.equal(
-      current().runs[0].results![0].summary,
-      RUNS[0]!.results![0].summary
-    );
-    const result = RUNS[0].results![0];
-    const updatedResult = {...result, summary: 'new'};
-    updateStateUpdateResult(
-      PLUGIN_NAME,
-      RUNS[0],
-      updatedResult,
-      ChecksPatchset.LATEST
-    );
-    assert.lengthOf(current().runs, 1);
-    assert.lengthOf(current().runs[0].results!, 1);
-    assert.equal(current().runs[0].results![0].summary, 'new');
-  });
-});
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
deleted file mode 100644
index 5ebc13c..0000000
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ /dev/null
@@ -1,305 +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 {
-  catchError,
-  filter,
-  switchMap,
-  takeUntil,
-  takeWhile,
-  throttleTime,
-  withLatestFrom,
-} from 'rxjs/operators';
-import {
-  Action,
-  ChangeData,
-  CheckResult,
-  CheckRun,
-  ChecksApiConfig,
-  ChecksProvider,
-  FetchResponse,
-  ResponseCode,
-} from '../../api/checks';
-import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
-import {
-  ChecksPatchset,
-  checksSelectedPatchsetNumber$,
-  checkToPluginMap$,
-  updateStateSetError,
-  updateStateSetLoading,
-  updateStateSetNotLoggedIn,
-  updateStateSetPatchset,
-  updateStateSetProvider,
-  updateStateSetResults,
-  updateStateUpdateResult,
-} from './checks-model';
-import {
-  BehaviorSubject,
-  combineLatest,
-  from,
-  Observable,
-  of,
-  Subject,
-  timer,
-} from 'rxjs';
-import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
-import {getCurrentRevision} from '../../utils/change-util';
-import {getShaByPatchNum} from '../../utils/patch-set-util';
-import {assertIsDefined} from '../../utils/common-util';
-import {ReportingService} from '../gr-reporting/gr-reporting';
-import {routerPatchNum$} from '../router/router-model';
-import {Execution} from '../../constants/reporting';
-import {fireAlert, fireEvent} from '../../utils/event-util';
-
-export class ChecksService {
-  private readonly providers: {[name: string]: ChecksProvider} = {};
-
-  private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
-
-  private checkToPluginMap = new Map<string, string>();
-
-  private changeNum?: NumericChangeId;
-
-  private latestPatchNum?: PatchSetNumber;
-
-  private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
-
-  constructor(readonly reporting: ReportingService) {
-    changeNum$.subscribe(x => (this.changeNum = x));
-    checkToPluginMap$.subscribe(map => {
-      this.checkToPluginMap = map;
-    });
-    combineLatest([routerPatchNum$, latestPatchNum$]).subscribe(
-      ([routerPs, latestPs]) => {
-        this.latestPatchNum = latestPs;
-        if (latestPs === undefined) {
-          this.setPatchset(undefined);
-        } else if (typeof routerPs === 'number') {
-          this.setPatchset(routerPs);
-        } else {
-          this.setPatchset(latestPs);
-        }
-      }
-    );
-    document.addEventListener('visibilitychange', () => {
-      this.documentVisibilityChange$.next(undefined);
-    });
-    document.addEventListener('reload', () => {
-      this.reloadAll();
-    });
-  }
-
-  setPatchset(num?: PatchSetNumber) {
-    updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
-  }
-
-  reload(pluginName: string) {
-    this.reloadSubjects[pluginName].next();
-  }
-
-  reloadAll() {
-    Object.keys(this.providers).forEach(key => this.reload(key));
-  }
-
-  reloadForCheck(checkName?: string) {
-    if (!checkName) return;
-    const plugin = this.checkToPluginMap.get(checkName);
-    if (plugin) this.reload(plugin);
-  }
-
-  updateResult(pluginName: string, run: CheckRun, result: CheckResult) {
-    updateStateUpdateResult(pluginName, run, result, ChecksPatchset.LATEST);
-    updateStateUpdateResult(pluginName, run, result, ChecksPatchset.SELECTED);
-  }
-
-  triggerAction(action?: Action, run?: CheckRun) {
-    if (!action?.callback) return;
-    if (!this.changeNum) return;
-    const patchSet = run?.patchset ?? this.latestPatchNum;
-    if (!patchSet) return;
-    const promise = action.callback(
-      this.changeNum,
-      patchSet,
-      run?.attempt,
-      run?.externalId,
-      run?.checkName,
-      action.name
-    );
-    // If plugins return undefined or not a promise, then show no toast.
-    if (!promise?.then) return;
-
-    fireAlert(document, `Triggering action '${action.name}' ...`);
-    from(promise)
-      // If the action takes longer than 5 seconds, then most likely the
-      // user is either not interested or the result not relevant anymore.
-      .pipe(takeUntil(timer(5000)))
-      .subscribe(result => {
-        if (result.errorMessage || result.message) {
-          fireAlert(document, `${result.message ?? result.errorMessage}`);
-        } else {
-          fireEvent(document, 'hide-alert');
-        }
-        if (result.shouldReload) {
-          this.reloadForCheck(run?.checkName);
-        }
-      });
-  }
-
-  register(
-    pluginName: string,
-    provider: ChecksProvider,
-    config: ChecksApiConfig
-  ) {
-    if (this.providers[pluginName]) {
-      console.warn(
-        `Plugin '${pluginName}' was trying to register twice as a Checks UI provider. Ignored.`
-      );
-      return;
-    }
-    this.providers[pluginName] = provider;
-    this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
-    updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
-    updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
-    this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
-    this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
-  }
-
-  initFetchingOfData(
-    pluginName: string,
-    config: ChecksApiConfig,
-    patchset: ChecksPatchset
-  ) {
-    const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
-    // Various events should trigger fetching checks from the provider:
-    // 1. Change number and patchset number changes.
-    // 2. Specific reload requests.
-    // 3. Regular polling starting with an initial fetch right now.
-    // 4. A hidden Gerrit tab becoming visible.
-    combineLatest([
-      changeNum$,
-      patchset === ChecksPatchset.LATEST
-        ? latestPatchNum$
-        : checksSelectedPatchsetNumber$,
-      this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
-      timer(0, pollIntervalMs),
-      this.documentVisibilityChange$,
-    ])
-      .pipe(
-        takeWhile(_ => !!this.providers[pluginName]),
-        filter(_ => document.visibilityState !== 'hidden'),
-        withLatestFrom(change$),
-        switchMap(
-          ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
-            if (!change || !changeNum || !patchNum) return of(this.empty());
-            if (typeof patchNum !== 'number') return of(this.empty());
-            assertIsDefined(change.revisions, 'change.revisions');
-            const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
-            // Sometimes patchNum is updated earlier than change, so change
-            // revisions don't have patchNum yet
-            if (!patchsetSha) return of(this.empty());
-            const data: ChangeData = {
-              changeNumber: changeNum,
-              patchsetNumber: patchNum,
-              patchsetSha,
-              repo: change.project,
-              commitMessage: getCurrentRevision(change)?.commit?.message,
-              changeInfo: change as ChangeInfo,
-            };
-            return this.fetchResults(pluginName, data, patchset);
-          }
-        ),
-        catchError(e => {
-          // This should not happen and is really severe, because it means that
-          // the Observable has terminated and we won't recover from that. No
-          // further attempts to fetch results for this plugin will be made.
-          this.reporting.error(e, `checks-service crash for ${pluginName}`);
-          return of(this.createErrorResponse(pluginName, e));
-        })
-      )
-      .subscribe(response => {
-        switch (response.responseCode) {
-          case ResponseCode.ERROR: {
-            const message = response.errorMessage ?? '-';
-            this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
-              plugin: pluginName,
-              message,
-            });
-            updateStateSetError(pluginName, message, patchset);
-            break;
-          }
-          case ResponseCode.NOT_LOGGED_IN: {
-            assertIsDefined(response.loginCallback, 'loginCallback');
-            this.reporting.reportExecution(Execution.CHECKS_API_NOT_LOGGED_IN, {
-              plugin: pluginName,
-            });
-            updateStateSetNotLoggedIn(
-              pluginName,
-              response.loginCallback,
-              patchset
-            );
-            break;
-          }
-          case ResponseCode.OK: {
-            updateStateSetResults(
-              pluginName,
-              response.runs ?? [],
-              response.actions ?? [],
-              response.links ?? [],
-              patchset
-            );
-            break;
-          }
-        }
-      });
-  }
-
-  private empty(): FetchResponse {
-    return {
-      responseCode: ResponseCode.OK,
-      runs: [],
-    };
-  }
-
-  private createErrorResponse(
-    pluginName: string,
-    message: object
-  ): FetchResponse {
-    return {
-      responseCode: ResponseCode.ERROR,
-      errorMessage:
-        `Error message from plugin '${pluginName}':` +
-        ` ${JSON.stringify(message)}`,
-    };
-  }
-
-  private fetchResults(
-    pluginName: string,
-    data: ChangeData,
-    patchset: ChecksPatchset
-  ): Observable<FetchResponse> {
-    updateStateSetLoading(pluginName, patchset);
-    const timer = this.reporting.getTimer('ChecksPluginFetch');
-    const fetchPromise = this.providers[pluginName]
-      .fetch(data)
-      .then(response => {
-        timer.end({pluginName});
-        return response;
-      });
-    return from(fetchPromise).pipe(
-      catchError(e => of(this.createErrorResponse(pluginName, e)))
-    );
-  }
-}
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
deleted file mode 100644
index 850acbc..0000000
--- a/polygerrit-ui/app/services/comments/comments-model.ts
+++ /dev/null
@@ -1,206 +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 {BehaviorSubject, Observable} from 'rxjs';
-import {distinctUntilChanged, map} from 'rxjs/operators';
-import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
-import {
-  CommentInfo,
-  PathToCommentsInfoMap,
-  RobotCommentInfo,
-} from '../../types/common';
-import {addPath, DraftInfo} from '../../utils/comment-util';
-
-interface CommentState {
-  comments: PathToCommentsInfoMap;
-  robotComments: {[path: string]: RobotCommentInfo[]};
-  drafts: {[path: string]: DraftInfo[]};
-  portedComments: PathToCommentsInfoMap;
-  portedDrafts: PathToCommentsInfoMap;
-  /**
-   * If a draft is discarded by the user, then we temporarily keep it in this
-   * array in case the user decides to Undo the discard operation and bring the
-   * draft back. Once restored, the draft is removed from this array.
-   */
-  discardedDrafts: DraftInfo[];
-}
-
-const initialState: CommentState = {
-  comments: {},
-  robotComments: {},
-  drafts: {},
-  portedComments: {},
-  portedDrafts: {},
-  discardedDrafts: [],
-};
-
-const privateState$ = new BehaviorSubject(initialState);
-
-export function _testOnly_resetState() {
-  privateState$.next(initialState);
-}
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const commentState$: Observable<CommentState> = privateState$;
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
-}
-
-export function _testOnly_setState(state: CommentState) {
-  privateState$.next(state);
-}
-
-export const drafts$ = commentState$.pipe(
-  map(commentState => commentState.drafts),
-  distinctUntilChanged()
-);
-
-export const discardedDrafts$ = commentState$.pipe(
-  map(commentState => commentState.discardedDrafts),
-  distinctUntilChanged()
-);
-
-// Emits a new value even if only a single draft is changed. Components should
-// aim to subsribe to something more specific.
-export const changeComments$ = commentState$.pipe(
-  map(
-    commentState =>
-      new ChangeComments(
-        commentState.comments,
-        commentState.robotComments,
-        commentState.drafts,
-        commentState.portedComments,
-        commentState.portedDrafts
-      )
-  ),
-  distinctUntilChanged()
-);
-
-function publishState(state: CommentState) {
-  privateState$.next(state);
-}
-
-export function updateStateComments(comments?: {
-  [path: string]: CommentInfo[];
-}) {
-  const nextState = {...privateState$.getValue()};
-  nextState.comments = addPath(comments) || {};
-  publishState(nextState);
-}
-
-export function updateStateRobotComments(robotComments?: {
-  [path: string]: RobotCommentInfo[];
-}) {
-  const nextState = {...privateState$.getValue()};
-  nextState.robotComments = addPath(robotComments) || {};
-  publishState(nextState);
-}
-
-export function updateStateDrafts(drafts?: {[path: string]: DraftInfo[]}) {
-  const nextState = {...privateState$.getValue()};
-  nextState.drafts = addPath(drafts) || {};
-  publishState(nextState);
-}
-
-export function updateStatePortedComments(
-  portedComments?: PathToCommentsInfoMap
-) {
-  const nextState = {...privateState$.getValue()};
-  nextState.portedComments = portedComments || {};
-  publishState(nextState);
-}
-
-export function updateStatePortedDrafts(portedDrafts?: PathToCommentsInfoMap) {
-  const nextState = {...privateState$.getValue()};
-  nextState.portedDrafts = portedDrafts || {};
-  publishState(nextState);
-}
-
-export function updateStateAddDiscardedDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
-  nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
-  publishState(nextState);
-}
-
-export function updateStateUndoDiscardedDraft(draftID?: string) {
-  const nextState = {...privateState$.getValue()};
-  const drafts = [...nextState.discardedDrafts];
-  const index = drafts.findIndex(d => d.id === draftID);
-  if (index === -1) {
-    throw new Error('discarded draft not found');
-  }
-  drafts.splice(index, 1);
-  nextState.discardedDrafts = drafts;
-  publishState(nextState);
-}
-
-export function updateStateAddDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
-  if (!draft.path) throw new Error('draft path undefined');
-  nextState.drafts = {...nextState.drafts};
-  const drafts = nextState.drafts;
-  if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
-  else drafts[draft.path] = [...drafts[draft.path]];
-  const index = drafts[draft.path].findIndex(
-    d =>
-      (d.__draftID && d.__draftID === draft.__draftID) ||
-      (d.id && d.id === draft.id)
-  );
-  if (index !== -1) {
-    drafts[draft.path][index] = draft;
-  } else {
-    drafts[draft.path].push(draft);
-  }
-  publishState(nextState);
-}
-
-export function updateStateUpdateDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
-  if (!draft.path) throw new Error('draft path undefined');
-  nextState.drafts = {...nextState.drafts};
-  const drafts = nextState.drafts;
-  if (!drafts[draft.path])
-    throw new Error('draft: trying to edit non-existent draft');
-  drafts[draft.path] = [...drafts[draft.path]];
-  const index = drafts[draft.path].findIndex(
-    d =>
-      (d.__draftID && d.__draftID === draft.__draftID) ||
-      (d.id && d.id === draft.id)
-  );
-  if (index === -1) return;
-  drafts[draft.path][index] = draft;
-  publishState(nextState);
-}
-
-export function updateStateDeleteDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
-  if (!draft.path) throw new Error('draft path undefined');
-  nextState.drafts = {...nextState.drafts};
-  const drafts = nextState.drafts;
-  const index = (drafts[draft.path] || []).findIndex(
-    d =>
-      (d.__draftID && d.__draftID === draft.__draftID) ||
-      (d.id && d.id === draft.id)
-  );
-  if (index === -1) return;
-  const discardedDraft = drafts[draft.path][index];
-  drafts[draft.path] = [...drafts[draft.path]];
-  drafts[draft.path].splice(index, 1);
-  publishState(nextState);
-  updateStateAddDiscardedDraft(discardedDraft);
-}
diff --git a/polygerrit-ui/app/services/comments/comments-model_test.ts b/polygerrit-ui/app/services/comments/comments-model_test.ts
deleted file mode 100644
index e389254..0000000
--- a/polygerrit-ui/app/services/comments/comments-model_test.ts
+++ /dev/null
@@ -1,56 +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 '../../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 {
-  updateStateDeleteDraft,
-  _testOnly_getState,
-  _testOnly_resetState,
-  _testOnly_setState,
-} from './comments-model';
-
-suite('comments model tests', () => {
-  test('updateStateDeleteDraft', () => {
-    _testOnly_resetState();
-    const draft = createDraft();
-    draft.id = '1' as UrlEncodedCommentId;
-    _testOnly_setState({
-      comments: {},
-      robotComments: {},
-      drafts: {
-        [draft.path!]: [draft as DraftInfo],
-      },
-      portedComments: {},
-      portedDrafts: {},
-      discardedDrafts: [],
-    });
-    updateStateDeleteDraft(draft);
-    assert.deepEqual(_testOnly_getState(), {
-      comments: {},
-      robotComments: {},
-      drafts: {
-        'abc.txt': [],
-      },
-      portedComments: {},
-      portedDrafts: {},
-      discardedDrafts: [{...draft}],
-    });
-  });
-});
diff --git a/polygerrit-ui/app/services/comments/comments-service.ts b/polygerrit-ui/app/services/comments/comments-service.ts
deleted file mode 100644
index 16ee2f7..0000000
--- a/polygerrit-ui/app/services/comments/comments-service.ts
+++ /dev/null
@@ -1,111 +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 {NumericChangeId, PatchSetNum, RevisionId} from '../../types/common';
-import {DraftInfo, UIDraft} from '../../utils/comment-util';
-import {fireAlert} from '../../utils/event-util';
-import {CURRENT} from '../../utils/patch-set-util';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {
-  updateStateAddDraft,
-  updateStateDeleteDraft,
-  updateStateUpdateDraft,
-  updateStateComments,
-  updateStateRobotComments,
-  updateStateDrafts,
-  updateStatePortedComments,
-  updateStatePortedDrafts,
-  updateStateUndoDiscardedDraft,
-  discardedDrafts$,
-} from './comments-model';
-
-export class CommentsService {
-  private discardedDrafts?: UIDraft[] = [];
-
-  constructor(readonly restApiService: RestApiService) {
-    discardedDrafts$.subscribe(
-      discardedDrafts => (this.discardedDrafts = discardedDrafts)
-    );
-  }
-
-  /**
-   * Load all comments (with drafts and robot comments) for the given change
-   * number. The returned promise resolves when the comments have loaded, but
-   * does not yield the comment data.
-   */
-  // TODO(dhruvsri): listen to changeNum changes or reload event to update
-  // automatically
-  loadAll(changeNum: NumericChangeId, patchNum = CURRENT as RevisionId) {
-    const revision = patchNum;
-    this.restApiService
-      .getDiffComments(changeNum)
-      .then(comments => updateStateComments(comments));
-    this.restApiService
-      .getDiffRobotComments(changeNum)
-      .then(robotComments => updateStateRobotComments(robotComments));
-    this.restApiService
-      .getDiffDrafts(changeNum)
-      .then(drafts => updateStateDrafts(drafts));
-    this.restApiService
-      .getPortedComments(changeNum, revision)
-      .then(portedComments => updateStatePortedComments(portedComments));
-    this.restApiService
-      .getPortedDrafts(changeNum, revision)
-      .then(portedDrafts => updateStatePortedDrafts(portedDrafts));
-  }
-
-  restoreDraft(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    draftID: string
-  ) {
-    const draft = {...this.discardedDrafts?.find(d => d.id === draftID)};
-    if (!draft) throw new Error('discarded draft not found');
-    // delete draft ID since we want to treat this as a new draft creation
-    delete draft.id;
-    this.restApiService
-      .saveDiffDraft(changeNum, patchNum, draft)
-      .then(result => {
-        if (!result.ok) {
-          fireAlert(document, 'Unable to restore draft');
-          return;
-        }
-        this.restApiService.getResponseObject(result).then(obj => {
-          const resComment = obj as unknown as DraftInfo;
-          resComment.patch_set = draft.patch_set;
-          updateStateAddDraft(resComment);
-          updateStateUndoDiscardedDraft(draftID);
-        });
-      });
-  }
-
-  addDraft(draft: DraftInfo) {
-    updateStateAddDraft(draft);
-  }
-
-  cancelDraft(draft: DraftInfo) {
-    updateStateUpdateDraft(draft);
-  }
-
-  editDraft(draft: DraftInfo) {
-    updateStateUpdateDraft(draft);
-  }
-
-  deleteDraft(draft: DraftInfo) {
-    updateStateDeleteDraft(draft);
-  }
-}
diff --git a/polygerrit-ui/app/services/comments/comments-service_test.ts b/polygerrit-ui/app/services/comments/comments-service_test.ts
deleted file mode 100644
index 604b5c4..0000000
--- a/polygerrit-ui/app/services/comments/comments-service_test.ts
+++ /dev/null
@@ -1,126 +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 '../../test/common-test-setup-karma';
-import {
-  createComment,
-  createFixSuggestionInfo,
-} from '../../test/test-data-generators';
-import {stubRestApi} from '../../test/test-utils';
-import {
-  NumericChangeId,
-  RobotId,
-  RobotRunId,
-  Timestamp,
-  UrlEncodedCommentId,
-} from '../../types/common';
-import {appContext} from '../app-context';
-import {CommentsService} from './comments-service';
-
-suite('change service tests', () => {
-  let commentsService: CommentsService;
-
-  test('loads logged-out', () => {
-    const changeNum = 1234 as NumericChangeId;
-    commentsService = new CommentsService(appContext.restApiService);
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
-      Promise.resolve({
-        'foo.c': [
-          {
-            ...createComment(),
-            id: '123' as UrlEncodedCommentId,
-            message: 'Done',
-            updated: '2017-02-08 16:40:49' as Timestamp,
-          },
-        ],
-      })
-    );
-    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
-      Promise.resolve({
-        'foo.c': [
-          {
-            ...createComment(),
-            id: '321' as UrlEncodedCommentId,
-            message: 'Done',
-            updated: '2017-02-08 16:40:49' as Timestamp,
-            robot_id: 'robot_1' as RobotId,
-            robot_run_id: 'run_1' as RobotRunId,
-            properties: {},
-            fix_suggestions: [
-              createFixSuggestionInfo('fix_1'),
-              createFixSuggestionInfo('fix_2'),
-            ],
-          },
-        ],
-      })
-    );
-    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
-      Promise.resolve({})
-    );
-
-    commentsService.loadAll(changeNum);
-    assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
-    assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
-    assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
-  });
-
-  test('loads logged-in', () => {
-    const changeNum = 1234 as NumericChangeId;
-
-    stubRestApi('getLoggedIn').returns(Promise.resolve(true));
-    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
-      Promise.resolve({
-        'foo.c': [
-          {
-            ...createComment(),
-            id: '123' as UrlEncodedCommentId,
-            message: 'Done',
-            updated: '2017-02-08 16:40:49' as Timestamp,
-          },
-        ],
-      })
-    );
-    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
-      Promise.resolve({
-        'foo.c': [
-          {
-            ...createComment(),
-            id: '321' as UrlEncodedCommentId,
-            message: 'Done',
-            updated: '2017-02-08 16:40:49' as Timestamp,
-            robot_id: 'robot_1' as RobotId,
-            robot_run_id: 'run_1' as RobotRunId,
-            properties: {},
-            fix_suggestions: [
-              createFixSuggestionInfo('fix_1'),
-              createFixSuggestionInfo('fix_2'),
-            ],
-          },
-        ],
-      })
-    );
-    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
-      Promise.resolve({})
-    );
-
-    commentsService.loadAll(changeNum);
-    assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
-    assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
-    assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
-  });
-});
diff --git a/polygerrit-ui/app/services/config/config-model.ts b/polygerrit-ui/app/services/config/config-model.ts
deleted file mode 100644
index f5e10c5..0000000
--- a/polygerrit-ui/app/services/config/config-model.ts
+++ /dev/null
@@ -1,53 +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 {ConfigInfo, ServerInfo} from '../../types/common';
-import {BehaviorSubject, Observable} from 'rxjs';
-import {map, distinctUntilChanged} from 'rxjs/operators';
-
-interface ConfigState {
-  repoConfig?: ConfigInfo;
-  serverConfig?: ServerInfo;
-}
-
-// TODO: Figure out how to best enforce immutability of all states. Use Immer?
-// Use DeepReadOnly?
-const initialState: ConfigState = {};
-
-const privateState$ = new BehaviorSubject(initialState);
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const configState$: Observable<ConfigState> = privateState$;
-
-export function updateRepoConfig(repoConfig?: ConfigInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, repoConfig});
-}
-
-export function updateServerConfig(serverConfig?: ServerInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, serverConfig});
-}
-
-export const repoConfig$ = configState$.pipe(
-  map(configState => configState.repoConfig),
-  distinctUntilChanged()
-);
-
-export const serverConfig$ = configState$.pipe(
-  map(configState => configState.serverConfig),
-  distinctUntilChanged()
-);
diff --git a/polygerrit-ui/app/services/config/config-service.ts b/polygerrit-ui/app/services/config/config-service.ts
deleted file mode 100644
index 7cd1538..0000000
--- a/polygerrit-ui/app/services/config/config-service.ts
+++ /dev/null
@@ -1,42 +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 {updateRepoConfig, updateServerConfig} from './config-model';
-import {repo$} from '../change/change-model';
-import {appContext} from '../app-context';
-import {switchMap} from 'rxjs/operators';
-import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
-import {from, of} from 'rxjs';
-
-export class ConfigService {
-  private readonly restApiService = appContext.restApiService;
-
-  constructor() {
-    from(this.restApiService.getConfig()).subscribe((config?: ServerInfo) => {
-      updateServerConfig(config);
-    });
-    repo$
-      .pipe(
-        switchMap((repo?: RepoName) => {
-          if (repo === undefined) return of(undefined);
-          return from(this.restApiService.getProjectConfig(repo));
-        })
-      )
-      .subscribe((repoConfig?: ConfigInfo) => {
-        updateRepoConfig(repoConfig);
-      });
-  }
-}
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 863f95f..3ddff60 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -15,16 +15,21 @@
  * limitations under the License.
  */
 
-export interface FlagsService {
+import {Finalizable} from '../registry';
+
+export interface FlagsService extends Finalizable {
   isEnabled(experimentId: string): boolean;
   enabledExperiments: string[];
 }
 
 /**
- * @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',
 }
diff --git a/polygerrit-ui/app/services/flags/flags_impl.ts b/polygerrit-ui/app/services/flags/flags_impl.ts
index 18e225b..9767d1a 100644
--- a/polygerrit-ui/app/services/flags/flags_impl.ts
+++ b/polygerrit-ui/app/services/flags/flags_impl.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import {FlagsService} from './flags';
+import {Finalizable} from '../registry';
 
 declare global {
   interface Window {
@@ -27,7 +28,7 @@
  *
  * Provides all related methods / properties regarding on feature flags.
  */
-export class FlagsServiceImplementation implements FlagsService {
+export class FlagsServiceImplementation implements FlagsService, Finalizable {
   private readonly _experiments: Set<string>;
 
   constructor() {
@@ -35,6 +36,8 @@
     this._experiments = this._loadExperiments();
   }
 
+  finalize() {}
+
   isEnabled(experimentId: string): boolean {
     return this._experiments.has(experimentId);
   }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth.ts b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
index f7fdadf..ac63d6a 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
+import {Finalizable} from '../registry';
 export enum AuthType {
   XSRF_TOKEN = 'xsrf_token',
   ACCESS_TOKEN = 'access_token',
@@ -45,7 +45,7 @@
   headers?: Headers;
 }
 
-export interface AuthService {
+export interface AuthService extends Finalizable {
   baseUrl: string;
   isAuthed: boolean;
 
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 c254284..5f77e8a 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -16,6 +16,7 @@
  */
 import {getBaseUrl} from '../../utils/url-util';
 import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
+import {Finalizable} from '../registry';
 import {
   AuthRequestInit,
   AuthService,
@@ -44,7 +45,7 @@
 /**
  * Auth class.
  */
-export class Auth implements AuthService {
+export class Auth implements AuthService, Finalizable {
   // TODO(dmfilippov): Remove Type and Status properties, expose AuthType and
   // AuthStatus to API
   static TYPE = {
@@ -88,6 +89,8 @@
     return getBaseUrl();
   }
 
+  finalize() {}
+
   /**
    * Returns if user is authed or not.
    */
@@ -188,7 +191,8 @@
     }
   }
 
-  private _getCookie(name: string): string {
+  // private but used in test
+  _getCookie(name: string): string {
     const key = name + '=';
     let result = '';
     document.cookie.split(';').some(c => {
@@ -202,7 +206,8 @@
     return result;
   }
 
-  private _isTokenValid(token: Token | null): token is ValidToken {
+  // private but used in test
+  _isTokenValid(token: Token | null): token is ValidToken {
     if (!token) {
       return false;
     }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
index 3dbb4c3..e5331f1 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
@@ -40,6 +40,8 @@
     return this._status === Auth.STATUS.AUTHED;
   }
 
+  finalize() {}
+
   private _setStatus(status: AuthStatus) {
     if (this._status === status) return;
     if (this._status === AuthStatus.AUTHED) {
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.js b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
similarity index 75%
rename from polygerrit-ui/app/services/gr-auth/gr-auth_test.js
rename to polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
index debba6d..2b54bde 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
@@ -15,22 +15,28 @@
  * limitations under the License.
  */
 
-import '../../test/common-test-setup-karma.js';
-import {Auth} from './gr-auth_impl.js';
-import {appContext} from '../app-context.js';
-import {stubBaseUrl} from '../../test/test-utils.js';
+import '../../test/common-test-setup-karma';
+import {Auth} from './gr-auth_impl';
+import {getAppContext} from '../app-context';
+import {stubBaseUrl} from '../../test/test-utils';
+import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
+import {SinonFakeTimers} from 'sinon';
+import {AuthRequestInit, DefaultAuthOptions} from './gr-auth';
 
 suite('gr-auth', () => {
-  let auth;
+  let auth: Auth;
+  let eventEmitter: EventEmitterService;
 
   setup(() => {
-    auth = new Auth(appContext.eventEmitter);
+    // TODO(poucet): Mock the eventEmitter completely instead of getting it
+    // from appContext.
+    eventEmitter = getAppContext().eventEmitter;
+    auth = new Auth(eventEmitter);
   });
 
   suite('Auth class methods', () => {
-    let fakeFetch;
+    let fakeFetch: sinon.SinonStub;
     setup(() => {
-      auth = new Auth(appContext.eventEmitter);
       fakeFetch = sinon.stub(window, 'fetch');
     });
 
@@ -64,10 +70,9 @@
   });
 
   suite('cache and events behavior', () => {
-    let fakeFetch;
-    let clock;
+    let fakeFetch: sinon.SinonStub;
+    let clock: SinonFakeTimers;
     setup(() => {
-      auth = new Auth(appContext.eventEmitter);
       clock = sinon.useFakeTimers();
       fakeFetch = sinon.stub(window, 'fetch');
     });
@@ -124,7 +129,7 @@
       assert.equal(auth.status, Auth.STATUS.AUTHED);
       clock.tick(1000 * 10000);
       fakeFetch.returns(Promise.resolve({status: 403}));
-      const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+      const emitStub = sinon.stub(eventEmitter, 'emit');
       const authed2 = await auth.authCheck();
       assert.isFalse(authed2);
       assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
@@ -138,7 +143,7 @@
       assert.equal(auth.status, Auth.STATUS.AUTHED);
       clock.tick(1000 * 10000);
       fakeFetch.returns(Promise.reject(new Error('random error')));
-      const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+      const emitStub = sinon.stub(eventEmitter, 'emit');
       const authed2 = await auth.authCheck();
       assert.isFalse(authed2);
       assert.isTrue(emitStub.called);
@@ -152,7 +157,7 @@
       assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
       clock.tick(1000 * 10000);
       fakeFetch.returns(Promise.resolve({status: 204}));
-      const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+      const emitStub = sinon.stub(eventEmitter, 'emit');
       const authed2 = await auth.authCheck();
       assert.isTrue(authed2);
       assert.isFalse(emitStub.called);
@@ -166,7 +171,7 @@
       assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
       clock.tick(1000 * 10000);
       fakeFetch.returns(Promise.reject(new Error('random error')));
-      const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+      const emitStub = sinon.stub(eventEmitter, 'emit');
       const authed2 = await auth.authCheck();
       assert.isFalse(authed2);
       assert.isFalse(emitStub.called);
@@ -175,23 +180,25 @@
   });
 
   suite('default (xsrf token header)', () => {
+    let fakeFetch: sinon.SinonStub;
+
     setup(() => {
-      sinon.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
+      fakeFetch = sinon
+        .stub(window, 'fetch')
+        .returns(Promise.resolve({...new Response(), ok: true}));
     });
 
     test('GET', async () => {
-      await auth.fetch('/url', {bar: 'bar'});
-      const [url, options] = fetch.lastCall.args;
+      await auth.fetch('/url', {bar: 'bar'} as AuthRequestInit);
+      const [url, options] = fakeFetch.lastCall.args;
       assert.equal(url, '/url');
       assert.equal(options.credentials, 'same-origin');
     });
 
     test('POST', async () => {
-      sinon.stub(auth, '_getCookie')
-          .withArgs('XSRF_TOKEN')
-          .returns('foobar');
+      sinon.stub(auth, '_getCookie').withArgs('XSRF_TOKEN').returns('foobar');
       await auth.fetch('/url', {method: 'POST'});
-      const [url, options] = fetch.lastCall.args;
+      const [url, options] = fakeFetch.lastCall.args;
       assert.equal(url, '/url');
       assert.equal(options.credentials, 'same-origin');
       assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
@@ -199,13 +206,17 @@
   });
 
   suite('cors (access token)', () => {
+    let fakeFetch: sinon.SinonStub;
+
     setup(() => {
-      sinon.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
+      fakeFetch = sinon
+        .stub(window, 'fetch')
+        .returns(Promise.resolve({...new Response(), ok: true}));
     });
 
-    let getToken;
+    let getToken: sinon.SinonStub;
 
-    const makeToken = opt_accessToken => {
+    const makeToken = (opt_accessToken?: string) => {
       return {
         access_token: opt_accessToken || 'zbaz',
         expires_at: new Date(Date.now() + 10e8).getTime(),
@@ -215,29 +226,32 @@
     setup(() => {
       getToken = sinon.stub();
       getToken.returns(Promise.resolve(makeToken()));
-      auth.setup(getToken);
+      const defaultOptions: DefaultAuthOptions = {
+        credentials: 'include',
+      };
+      auth.setup(getToken, defaultOptions);
     });
 
     test('base url support', async () => {
       const baseUrl = 'http://foo';
       stubBaseUrl(baseUrl);
-      await auth.fetch(baseUrl + '/url', {bar: 'bar'});
-      const [url] = fetch.lastCall.args;
+      await auth.fetch(baseUrl + '/url', {bar: 'bar'} as AuthRequestInit);
+      const [url] = fakeFetch.lastCall.args;
       assert.equal(url, 'http://foo/a/url?access_token=zbaz');
     });
 
     test('fetch not signed in', async () => {
       getToken.returns(Promise.resolve());
-      await auth.fetch('/url', {bar: 'bar'});
-      const [url, options] = fetch.lastCall.args;
+      await auth.fetch('/url', {bar: 'bar'} as AuthRequestInit);
+      const [url, options] = fakeFetch.lastCall.args;
       assert.equal(url, '/url');
       assert.equal(options.bar, 'bar');
       assert.equal(Object.keys(options.headers).length, 0);
     });
 
     test('fetch signed in', async () => {
-      await auth.fetch('/url', {bar: 'bar'});
-      const [url, options] = fetch.lastCall.args;
+      await auth.fetch('/url', {bar: 'bar'} as AuthRequestInit);
+      const [url, options] = fakeFetch.lastCall.args;
       assert.equal(url, '/a/url?access_token=zbaz');
       assert.equal(options.bar, 'bar');
     });
@@ -248,42 +262,47 @@
     });
 
     test('getToken refreshes token', async () => {
-      sinon.stub(auth, '_isTokenValid');
-      auth._isTokenValid
-          .onFirstCall().returns(true)
-          .onSecondCall()
-          .returns(false)
-          .onThirdCall()
-          .returns(true);
+      const isTokenValidStub = sinon.stub(auth, '_isTokenValid');
+      isTokenValidStub
+        .onFirstCall()
+        .returns(true)
+        .onSecondCall()
+        .returns(false)
+        .onThirdCall()
+        .returns(true);
       await auth.fetch('/url-one');
       getToken.returns(Promise.resolve(makeToken('bzzbb')));
       await auth.fetch('/url-two');
 
-      const [[firstUrl], [secondUrl]] = fetch.args;
+      const [[firstUrl], [secondUrl]] = fakeFetch.args;
       assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
       assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
     });
 
     test('signed in token error falls back to anonymous', async () => {
       getToken.returns(Promise.resolve('rubbish'));
-      await auth.fetch('/url', {bar: 'bar'});
-      const [url, options] = fetch.lastCall.args;
+      await auth.fetch('/url', {bar: 'bar'} as AuthRequestInit);
+      const [url, options] = fakeFetch.lastCall.args;
       assert.equal(url, '/url');
       assert.equal(options.bar, 'bar');
     });
 
     test('_isTokenValid', () => {
-      assert.isFalse(auth._isTokenValid());
+      assert.isFalse(auth._isTokenValid(null));
       assert.isFalse(auth._isTokenValid({}));
       assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
-      assert.isFalse(auth._isTokenValid({
-        access_token: 'foo',
-        expires_at: Date.now()/1000 - 1,
-      }));
-      assert.isTrue(auth._isTokenValid({
-        access_token: 'foo',
-        expires_at: Date.now()/1000 + 1,
-      }));
+      assert.isFalse(
+        auth._isTokenValid({
+          access_token: 'foo',
+          expires_at: `${Date.now() / 1000 - 1}`,
+        })
+      );
+      assert.isTrue(
+        auth._isTokenValid({
+          access_token: 'foo',
+          expires_at: `${Date.now() / 1000 + 1}`,
+        })
+      );
     });
 
     test('HTTP PUT with content type', async () => {
@@ -293,7 +312,7 @@
       };
       await auth.fetch('/url', originalOptions);
       assert.isTrue(getToken.called);
-      const [url, options] = fetch.lastCall.args;
+      const [url, options] = fakeFetch.lastCall.args;
       assert.include(url, '$ct=mail%2Fpigeon');
       assert.include(url, '$m=PUT');
       assert.include(url, 'access_token=zbaz');
@@ -307,7 +326,7 @@
       };
       await auth.fetch('/url', originalOptions);
       assert.isTrue(getToken.called);
-      const [url, options] = fetch.lastCall.args;
+      const [url, options] = fakeFetch.lastCall.args;
       assert.include(url, '$ct=text%2Fplain');
       assert.include(url, '$m=PUT');
       assert.include(url, 'access_token=zbaz');
@@ -316,4 +335,3 @@
     });
   });
 });
-
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 e540029..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
@@ -14,12 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
+import {Finalizable} from '../registry';
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventCallback = (...args: any) => void;
 export type UnsubscribeMethod = () => void;
 
-export interface EventEmitterService {
+export interface EventEmitterService extends Finalizable {
   /**
    * Register an event listener to an event.
    */
@@ -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 d8c5d77..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
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
+import {Finalizable} from '../registry';
 import {
   EventCallback,
   EventEmitterService,
@@ -34,9 +34,13 @@
  * }
  *
  */
-export class EventEmitter implements EventEmitterService {
+export class EventEmitter implements EventEmitterService, Finalizable {
   private _listenersMap = new Map<string, EventCallback[]>();
 
+  finalize() {
+    this.removeAllListeners();
+  }
+
   /**
    * Register an event listener to an event.
    */
@@ -95,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 {
@@ -123,7 +127,7 @@
    *
    * @param eventName if not provided, will remove all
    */
-  removeAllListeners(eventName: string): void {
+  removeAllListeners(eventName?: string): void {
     if (eventName) {
       this._listenersMap.set(eventName, []);
     } else {
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 06f1a0c..aeb1614 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -14,9 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
+import {Finalizable} from '../registry';
 import {NumericChangeId} from '../../types/common';
-import {EventDetails} from '../../api/reporting';
+import {EventDetails, ReportingOptions} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
 import {
   Execution,
@@ -33,7 +33,7 @@
   withMaximum(maximum: number): this;
 }
 
-export interface ReportingService {
+export interface ReportingService extends Finalizable {
   reporter(
     type: string,
     category: string,
@@ -51,7 +51,6 @@
   changeDisplayed(eventDetails?: EventDetails): void;
   changeFullyLoaded(): void;
   diffViewDisplayed(): void;
-  diffViewFullyLoaded(): void;
   diffViewContentDisplayed(): void;
   fileListDisplayed(): void;
   reportExtension(name: string): void;
@@ -68,20 +67,6 @@
    */
   timeEnd(name: Timing, eventDetails?: EventDetails): void;
   /**
-   * Reports just line timeEnd, but additionally reports an average given a
-   * denominator and a separate reporting name for the average.
-   *
-   * @param name Timing name.
-   * @param averageName Average timing name.
-   * @param denominator Number by which to divide the total to
-   *     compute the average.
-   */
-  timeEndWithAverage(
-    name: Timing,
-    averageName: Timing,
-    denominator: number
-  ): void;
-  /**
    * Get a timer object for reporting a user timing. The start time will be
    * the time that the object has been created, and the end time will be the
    * time that the "end" method is called on the object.
@@ -113,13 +98,9 @@
   ): void;
   reportInteraction(
     eventName: string | Interaction,
-    details?: EventDetails
+    details?: EventDetails,
+    options?: ReportingOptions
   ): void;
-  /**
-   * A draft interaction was started. Update the time-between-draft-actions
-   * timer.
-   */
-  recordDraftInteraction(): void;
   reportErrorDialog(message: string): void;
   setRepoName(repoName: string): void;
   setChangeId(changeId: NumericChangeId): 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 65a5784..f27ab96 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -14,13 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {AppContext} from '../app-context';
 import {FlagsService} from '../flags/flags';
 import {EventValue, ReportingService, Timer} from './gr-reporting';
 import {hasOwnProperty} from '../../utils/common-util';
 import {NumericChangeId} from '../../types/common';
-import {EventDetails} from '../../api/reporting';
+import {Deduping, EventDetails, ReportingOptions} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
+import {Finalizable} from '../registry';
 import {
   Execution,
   Interaction,
@@ -91,20 +91,15 @@
   [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.
   [Timing.WEB_COMPONENTS_READY]: 0,
 };
 
-const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
-const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
 const SLOW_RPC_THRESHOLD = 500;
 
-export function initErrorReporter(appContext: AppContext) {
-  const reportingService = appContext.reportingService;
-
+export function initErrorReporter(reportingService: ReportingService) {
   const normalizeError = (err: Error | unknown) => {
     if (err instanceof Error) {
       return err;
@@ -169,8 +164,7 @@
   return {catchErrors};
 }
 
-export function initPerformanceReporter(appContext: AppContext) {
-  const reportingService = appContext.reportingService;
+export function initPerformanceReporter(reportingService: ReportingService) {
   // PerformanceObserver interface is a browser API.
   if (window.PerformanceObserver) {
     const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || [];
@@ -196,8 +190,7 @@
   }
 }
 
-export function initVisibilityReporter(appContext: AppContext) {
-  const reportingService = appContext.reportingService;
+export function initVisibilityReporter(reportingService: ReportingService) {
   document.addEventListener('visibilitychange', () => {
     reportingService.onVisibilityChange();
   });
@@ -277,7 +270,7 @@
 
 type PendingReportInfo = [EventInfo, boolean | undefined];
 
-export class GrReporting implements ReportingService {
+export class GrReporting implements ReportingService, Finalizable {
   private readonly _flagsService: FlagsService;
 
   private readonly _baselines = STARTUP_TIMERS;
@@ -286,19 +279,15 @@
 
   private reportChangeId: NumericChangeId | undefined;
 
-  private timers: {timeBetweenDraftActions: Timer | null} = {
-    timeBetweenDraftActions: null,
-  };
-
   private pending: PendingReportInfo[] = [];
 
   private slowRpcList: SlowRpcCall[] = [];
 
   /**
-   * Keeps track of which ids were already reported to have been executed.
-   * Execution ids should only be reported once per session.
+   * Keeps track of which ids were already reported for events that should only
+   * be reported once per session.
    */
-  private executionReported = new Set<string>();
+  private reportedIds = new Set<string>();
 
   public readonly hiddenDurationTimer = new HiddenDurationTimer();
 
@@ -328,6 +317,8 @@
     );
   }
 
+  finalize() {}
+
   /**
    * Reporter reports events. Events will be queued if metrics plugin is not
    * yet installed.
@@ -371,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}`);
       }
     }
   }
@@ -498,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;
@@ -549,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(
@@ -632,7 +616,7 @@
       LifeCycle.PLUGINS_INSTALLED,
       undefined,
       {pluginsList: pluginsList || []},
-      true
+      false
     );
   }
 
@@ -644,7 +628,7 @@
       LifeCycle.PLUGINS_FAILED,
       undefined,
       {pluginsList: pluginsList || []},
-      true
+      false
     );
   }
 
@@ -679,30 +663,6 @@
   }
 
   /**
-   * Reports just line timeEnd, but additionally reports an average given a
-   * denominator and a separate reporting name for the average.
-   *
-   * @param name Timing name.
-   * @param averageName Average timing name.
-   * @param denominator Number by which to divide the total to
-   *     compute the average.
-   */
-  timeEndWithAverage(name: Timing, averageName: Timing, denominator: number) {
-    if (!hasOwnProperty(this._baselines, name)) {
-      return;
-    }
-    const baseTime = this._baselines[name];
-    this.timeEnd(name);
-
-    // Guard against division by zero.
-    if (!denominator) {
-      return;
-    }
-    const time = now() - baseTime;
-    this._reportTiming(averageName, time / denominator);
-  }
-
-  /**
    * Send a timing report with an arbitrary time value.
    *
    * @param name Timing name.
@@ -797,7 +757,7 @@
       eventName,
       undefined,
       details,
-      true
+      false
     );
   }
 
@@ -808,7 +768,7 @@
       eventName,
       undefined,
       details,
-      true
+      false
     );
   }
 
@@ -823,21 +783,55 @@
     );
   }
 
-  reportInteraction(eventName: string | Interaction, details: EventDetails) {
+  /**
+   * Returns true when the event was deduped and thus should not be reported.
+   */
+  _dedup(
+    eventName: string | Interaction,
+    details: EventDetails,
+    deduping?: Deduping
+  ): boolean {
+    if (!deduping) return false;
+    let id = '';
+    switch (deduping) {
+      case Deduping.DETAILS_ONCE_PER_CHANGE:
+        id = `${eventName}-${this.reportChangeId}-${JSON.stringify(details)}`;
+        break;
+      case Deduping.DETAILS_ONCE_PER_SESSION:
+        id = `${eventName}-${JSON.stringify(details)}`;
+        break;
+      case Deduping.EVENT_ONCE_PER_CHANGE:
+        id = `${eventName}-${this.reportChangeId}`;
+        break;
+      case Deduping.EVENT_ONCE_PER_SESSION:
+        id = `${eventName}`;
+        break;
+      default:
+        throw new Error(`Invalid 'deduping' option '${deduping}'.`);
+    }
+    if (this.reportedIds.has(id)) return true;
+    this.reportedIds.add(id);
+    return false;
+  }
+
+  reportInteraction(
+    eventName: string | Interaction,
+    details: EventDetails,
+    options?: ReportingOptions
+  ) {
+    if (this._dedup(eventName, details, options?.deduping)) return;
     this.reporter(
       INTERACTION.TYPE,
       INTERACTION.CATEGORY.DEFAULT,
       eventName,
       undefined,
       details,
-      true
+      false
     );
   }
 
   reportExecution(name: Execution, details?: EventDetails) {
-    const id = `${name}${JSON.stringify(details)}`;
-    if (this.executionReported.has(id)) return;
-    this.executionReported.add(id);
+    if (this._dedup(name, details, Deduping.DETAILS_ONCE_PER_SESSION)) return;
     this.reporter(
       LIFECYCLE.TYPE,
       LIFECYCLE.CATEGORY.EXECUTION,
@@ -857,27 +851,6 @@
     this.reportExecution(Execution.PLUGIN_API, {plugin, object, method});
   }
 
-  /**
-   * A draft interaction was started. Update the time-between-draft-actions
-   * Timing.
-   */
-  recordDraftInteraction() {
-    // If there is no timer defined, then this is the first interaction.
-    // Set up the timer so that it's ready to record the intervening time when
-    // called again.
-    const timer = this.timers.timeBetweenDraftActions;
-    if (!timer) {
-      // Create a timer with a maximum length.
-      this.timers.timeBetweenDraftActions = this.getTimer(
-        DRAFT_ACTION_TIMER
-      ).withMaximum(DRAFT_ACTION_TIMER_MAX);
-      return;
-    }
-
-    // Mark the time and reinitialize the timer.
-    timer.end().reset();
-  }
-
   error(error: Error, errorSource?: string, details?: EventDetails) {
     const eventDetails = details ?? {};
     const message = `${errorSource ? errorSource + ': ' : ''}${error.message}`;
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 337cf2f..f361782 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -18,6 +18,7 @@
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
 import {Execution, Interaction} from '../../constants/reporting';
+import {Finalizable} from '../registry';
 
 export class MockTimer implements Timer {
   end(): this {
@@ -37,7 +38,7 @@
   console.info(`ReportingMock.${msg}`);
 };
 
-export const grReportingMock: ReportingService = {
+export const grReportingMock: ReportingService & Finalizable = {
   appStarted: () => {},
   beforeLocationChanged: () => {},
   changeDisplayed: () => {},
@@ -45,8 +46,8 @@
   dashboardDisplayed: () => {},
   diffViewContentDisplayed: () => {},
   diffViewDisplayed: () => {},
-  diffViewFullyLoaded: () => {},
   fileListDisplayed: () => {},
+  finalize: () => {},
   getTimer: () => new MockTimer(),
   locationChanged: (page: string) => {
     log(`locationChanged: ${page}`);
@@ -57,7 +58,6 @@
   pluginLoaded: () => {},
   pluginsLoaded: () => {},
   pluginsFailed: () => {},
-  recordDraftInteraction: () => {},
   reporter: () => {},
   reportErrorDialog: (message: string) => {
     log(`reportErrorDialog: ${message}`);
@@ -65,20 +65,13 @@
   error: () => {
     log('error');
   },
-  reportExecution: (id: Execution, details?: EventDetails) => {
-    log(`reportExecution '${id}': ${JSON.stringify(details)}`);
-  },
-  trackApi: (pluginApi: PluginApi, object: string, method: string) => {
-    const plugin = pluginApi?.getPluginName() ?? 'unknown';
-    log(`trackApi '${plugin}', ${object}, ${method}`);
-  },
+  reportExecution: (_id: Execution, _details?: EventDetails) => {},
+  trackApi: (_pluginApi: PluginApi, _object: string, _method: string) => {},
   reportExtension: () => {},
   reportInteraction: (
-    eventName: string | Interaction,
-    details?: EventDetails
-  ) => {
-    log(`reportInteraction '${eventName}': ${JSON.stringify(details)}`);
-  },
+    _eventName: string | Interaction,
+    _details?: EventDetails
+  ) => {},
   reportLifeCycle: () => {},
   reportPluginLifeCycleLog: () => {},
   reportPluginInteractionLog: () => {},
@@ -87,5 +80,4 @@
   setChangeId: () => {},
   time: () => {},
   timeEnd: () => {},
-  timeEndWithAverage: () => {},
 };
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.js
deleted file mode 100644
index 9b71908..0000000
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
+++ /dev/null
@@ -1,499 +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 {GrReporting, DEFAULT_STARTUP_TIMERS, initErrorReporter} from './gr-reporting_impl.js';
-import {appContext} from '../app-context.js';
-suite('gr-reporting tests', () => {
-  let service;
-
-  let clock;
-  let fakePerformance;
-
-  const NOW_TIME = 100;
-
-  setup(() => {
-    clock = sinon.useFakeTimers(NOW_TIME);
-    service = new GrReporting(appContext.flagsService);
-    service._baselines = {...DEFAULT_STARTUP_TIMERS};
-    sinon.stub(service, 'reporter');
-  });
-
-  teardown(() => {
-    clock.restore();
-  });
-
-  test('appStarted', () => {
-    fakePerformance = {
-      navigationStart: 1,
-      loadEventEnd: 2,
-    };
-    fakePerformance.toJSON = () => fakePerformance;
-    sinon.stub(service, 'performanceTiming').get(() => fakePerformance);
-    sinon.stub(window.performance, 'now').returns(42);
-    service.appStarted();
-    assert.isTrue(
-        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)
-    );
-  });
-
-  test('WebComponentsReady', () => {
-    sinon.stub(window.performance, 'now').returns(42);
-    service.timeEnd('WebComponentsReady');
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'WebComponentsReady', 42
-    ));
-  });
-
-  test('beforeLocationChanged', () => {
-    service._baselines['garbage'] = 'monster';
-    sinon.stub(service, 'time');
-    service.beforeLocationChanged();
-    assert.isTrue(service.time.calledWithExactly('DashboardDisplayed'));
-    assert.isTrue(service.time.calledWithExactly('ChangeDisplayed'));
-    assert.isTrue(service.time.calledWithExactly('ChangeFullyLoaded'));
-    assert.isTrue(service.time.calledWithExactly('DiffViewDisplayed'));
-    assert.isTrue(service.time.calledWithExactly('FileListDisplayed'));
-    assert.isFalse(service._baselines.hasOwnProperty('garbage'));
-  });
-
-  test('changeDisplayed', () => {
-    sinon.spy(service, 'timeEnd');
-    service.changeDisplayed();
-    assert.isFalse(service.timeEnd.calledWith('ChangeDisplayed'));
-    assert.isTrue(service.timeEnd.calledWith('StartupChangeDisplayed'));
-    service.changeDisplayed();
-    assert.isTrue(service.timeEnd.calledWith('ChangeDisplayed'));
-  });
-
-  test('changeFullyLoaded', () => {
-    sinon.spy(service, 'timeEnd');
-    service.changeFullyLoaded();
-    assert.isFalse(
-        service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
-    assert.isTrue(
-        service.timeEnd.calledWithExactly('StartupChangeFullyLoaded'));
-    service.changeFullyLoaded();
-    assert.isTrue(service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
-  });
-
-  test('diffViewDisplayed', () => {
-    sinon.spy(service, 'timeEnd');
-    service.diffViewDisplayed();
-    assert.isFalse(service.timeEnd.calledWith('DiffViewDisplayed'));
-    assert.isTrue(service.timeEnd.calledWith('StartupDiffViewDisplayed'));
-    service.diffViewDisplayed();
-    assert.isTrue(service.timeEnd.calledWith('DiffViewDisplayed'));
-  });
-
-  test('fileListDisplayed', () => {
-    sinon.spy(service, 'timeEnd');
-    service.fileListDisplayed();
-    assert.isFalse(
-        service.timeEnd.calledWithExactly('FileListDisplayed'));
-    assert.isTrue(
-        service.timeEnd.calledWithExactly('StartupFileListDisplayed'));
-    service.fileListDisplayed();
-    assert.isTrue(service.timeEnd.calledWithExactly('FileListDisplayed'));
-  });
-
-  test('dashboardDisplayed', () => {
-    sinon.spy(service, 'timeEnd');
-    service.dashboardDisplayed();
-    assert.isFalse(service.timeEnd.calledWith('DashboardDisplayed'));
-    assert.isTrue(service.timeEnd.calledWith('StartupDashboardDisplayed'));
-    service.dashboardDisplayed();
-    assert.isTrue(service.timeEnd.calledWith('DashboardDisplayed'));
-  });
-
-  test('dashboardDisplayed details', () => {
-    sinon.spy(service, 'timeEnd');
-    sinon.stub(window, 'performance').value( {
-      memory: {
-        usedJSHeapSize: 1024 * 1024,
-      },
-      measure: () => {},
-      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,
-            }
-        ));
-  });
-
-  suite('hidden duration', () => {
-    let nowStub;
-    let visibilityStateStub;
-    const assertHiddenDurationsMs = hiddenDurationMs => {
-      service.dashboardDisplayed();
-      assert.isTrue(
-          service.timeEnd.calledWithMatch('StartupDashboardDisplayed',
-              {hiddenDurationMs}
-          ));
-    };
-
-    setup(() => {
-      sinon.spy(service, 'timeEnd');
-      nowStub = sinon.stub(window.performance, 'now');
-      visibilityStateStub = {
-        value: value => {
-          Object.defineProperty(document, 'visibilityState',
-              {value, configurable: true});
-        },
-      };
-    });
-
-    test('starts in hidden', () => {
-      nowStub.returns(10);
-      visibilityStateStub.value('hidden');
-      service.onVisibilityChange();
-      nowStub.returns(15);
-      visibilityStateStub.value('visible');
-      service.onVisibilityChange();
-      assertHiddenDurationsMs(5);
-    });
-
-    test('full in hidden', () => {
-      nowStub.returns(10);
-      visibilityStateStub.value('hidden');
-      assertHiddenDurationsMs(10);
-    });
-
-    test('full in visible', () => {
-      nowStub.returns(10);
-      visibilityStateStub.value('visible');
-      assertHiddenDurationsMs(0);
-    });
-
-    test('accumulated', () => {
-      nowStub.returns(10);
-      visibilityStateStub.value('hidden');
-      service.onVisibilityChange();
-      nowStub.returns(15);
-      visibilityStateStub.value('visible');
-      service.onVisibilityChange();
-      nowStub.returns(20);
-      visibilityStateStub.value('hidden');
-      service.onVisibilityChange();
-      nowStub.returns(25);
-      assertHiddenDurationsMs(10);
-    });
-
-    test('reset after location change', () => {
-      nowStub.returns(10);
-      visibilityStateStub.value('hidden');
-      assertHiddenDurationsMs(10);
-      visibilityStateStub.value('visible');
-      nowStub.returns(15);
-      service.beforeLocationChanged();
-      service.timeEnd.resetHistory();
-      service.dashboardDisplayed();
-      assert.isTrue(
-          service.timeEnd.calledWithMatch('DashboardDisplayed',
-              {hiddenDurationMs: 0}
-          ));
-    });
-  });
-
-  test('time and timeEnd', () => {
-    const nowStub = sinon.stub(window.performance, 'now').returns(0);
-    service.time('foo');
-    nowStub.returns(1);
-    service.time('bar');
-    nowStub.returns(2);
-    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
-    ));
-  });
-
-  test('timer object', () => {
-    const nowStub = sinon.stub(window.performance, 'now').returns(100);
-    const timer = service.getTimer('foo-bar');
-    nowStub.returns(150);
-    timer.end();
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'foo-bar', 50));
-  });
-
-  test('timer object double call', () => {
-    const timer = service.getTimer('foo-bar');
-    timer.end();
-    assert.isTrue(service.reporter.calledOnce);
-    assert.throws(() => {
-      timer.end();
-    }, 'Timer for "foo-bar" already ended.');
-  });
-
-  test('timer object maximum', () => {
-    const nowStub = sinon.stub(window.performance, 'now').returns(100);
-    const timer = service.getTimer('foo-bar').withMaximum(100);
-    nowStub.returns(150);
-    timer.end();
-    assert.isTrue(service.reporter.calledOnce);
-
-    timer.reset();
-    nowStub.returns(260);
-    timer.end();
-    assert.isTrue(service.reporter.calledOnce);
-  });
-
-  test('recordDraftInteraction', () => {
-    const key = 'TimeBetweenDraftActions';
-    const nowStub = sinon.stub(window.performance, 'now').returns(100);
-    const timingStub = sinon.stub(service, '_reportTiming');
-    service.recordDraftInteraction();
-    assert.isFalse(timingStub.called);
-
-    nowStub.returns(200);
-    service.recordDraftInteraction();
-    assert.isTrue(timingStub.calledOnce);
-    assert.equal(timingStub.lastCall.args[0], key);
-    assert.equal(timingStub.lastCall.args[1], 100);
-
-    nowStub.returns(350);
-    service.recordDraftInteraction();
-    assert.isTrue(timingStub.calledTwice);
-    assert.equal(timingStub.lastCall.args[0], key);
-    assert.equal(timingStub.lastCall.args[1], 150);
-
-    nowStub.returns(370 + 2 * 60 * 1000);
-    service.recordDraftInteraction();
-    assert.isFalse(timingStub.calledThrice);
-  });
-
-  test('timeEndWithAverage', () => {
-    const nowStub = sinon.stub(window.performance, 'now').returns(0);
-    nowStub.returns(1000);
-    service.time('foo');
-    nowStub.returns(1100);
-    service.timeEndWithAverage('foo', 'bar', 10);
-    assert.isTrue(service.reporter.calledTwice);
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'foo', 100));
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'bar', 10));
-  });
-
-  test('reportExtension', () => {
-    service.reportExtension('foo');
-    assert.isTrue(service.reporter.calledWithExactly(
-        'lifecycle', 'Extension detected', 'Extension detected', undefined,
-        {name: 'foo'}
-    ));
-  });
-
-  test('reportInteraction', () => {
-    service.reporter.restore();
-    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'}),
-        }
-    ));
-  });
-
-  test('trackApi reports same event only once', () => {
-    sinon.spy(service, '_reportEvent');
-    const pluginApi = {getPluginName: () => 'test'};
-    service.trackApi(pluginApi, 'object', 'method');
-    service.trackApi(pluginApi, 'object', 'method');
-    assert.isTrue(service.reporter.calledOnce);
-    service.trackApi(pluginApi, 'object', 'method2');
-    assert.isTrue(service.reporter.calledTwice);
-  });
-
-  test('report start time', () => {
-    service.reporter.restore();
-    sinon.stub(window.performance, 'now').returns(42);
-    sinon.spy(service, '_reportEvent');
-    const dispatchStub = sinon.spy(document, 'dispatchEvent');
-    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);
-  });
-
-  suite('plugins', () => {
-    setup(() => {
-      service.reporter.restore();
-      sinon.stub(service, '_reportEvent');
-    });
-
-    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,
-          }
-      ));
-    });
-
-    test('pluginsLoaded reports plugins', () => {
-      service.pluginsLoaded(['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', () => {
-      service.timeEnd('foo');
-      assert.isFalse(service._reportEvent.called);
-    });
-
-    test('reports if plugins are loaded', () => {
-      service.pluginsLoaded();
-      assert.isTrue(service._reportEvent.called);
-    });
-
-    test('reports if metrics plugin xyz is loaded', () => {
-      service.pluginLoaded('metrics-xyz');
-      assert.isTrue(service._reportEvent.called);
-    });
-
-    test('reports cached events preserving order', () => {
-      service.time('foo');
-      service.time('bar');
-      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'}
-      ));
-    });
-  });
-
-  test('search', () => {
-    service.locationChanged('_handleSomeRoute');
-    assert.isTrue(service.reporter.calledWithExactly(
-        'nav-report', 'Location Changed', 'Page', '_handleSomeRoute'));
-  });
-
-  suite('exception logging', () => {
-    let fakeWindow;
-    let reporter;
-
-    const emulateThrow = function(msg, url, line, column, error) {
-      return fakeWindow.onerror(msg, url, line, column, error);
-    };
-
-    setup(() => {
-      reporter = service.reporter;
-      fakeWindow = {
-        handlers: {},
-        addEventListener(type, handler) {
-          this.handlers[type] = handler;
-        },
-      };
-      sinon.stub(console, 'error');
-      Object.defineProperty(appContext, 'reportingService', {
-        get() {
-          return service;
-        },
-      });
-      const errorReporter = initErrorReporter(appContext);
-      errorReporter.catchErrors(fakeWindow);
-    });
-
-    test('is reported', () => {
-      const error = new Error('bar');
-      error.stack = undefined;
-      emulateThrow('bar', 'http://url', 4, 2, error);
-      assert.isTrue(reporter.calledWith('error', 'exception', 'onError: bar'));
-    });
-
-    test('is reported with stack', () => {
-      const error = new Error('bar');
-      emulateThrow('bar', 'http://url', 4, 2, error);
-      const eventDetails = reporter.lastCall.args[4];
-      assert.equal(error.stack, eventDetails.stack);
-    });
-
-    test('prevent default event handler', () => {
-      assert.isTrue(emulateThrow());
-    });
-
-    test('unhandled rejection', () => {
-      const newError = new Error('bar');
-      fakeWindow.handlers['unhandledrejection']({reason: newError});
-      assert.isTrue(reporter.calledWith('error', 'exception',
-          'unhandledrejection: bar'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
new file mode 100644
index 0000000..13c96c8
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
@@ -0,0 +1,569 @@
+/**
+ * @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 {
+  GrReporting,
+  DEFAULT_STARTUP_TIMERS,
+  initErrorReporter,
+} from './gr-reporting_impl';
+import {getAppContext} from '../app-context';
+import {Deduping} from '../../api/reporting';
+import {SinonFakeTimers} from 'sinon';
+
+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;
+
+  setup(() => {
+    clock = sinon.useFakeTimers(NOW_TIME);
+    service = new GrReporting(getAppContext().flagsService);
+    service._baselines = {...DEFAULT_STARTUP_TIMERS};
+    sinon.stub(service, 'reporter');
+  });
+
+  teardown(() => {
+    clock.restore();
+  });
+
+  test('appStarted', () => {
+    fakePerformance = {
+      navigationStart: 1,
+      loadEventEnd: 2,
+    };
+    fakePerformance.toJSON = () => fakePerformance;
+    sinon.stub(service, 'performanceTiming').get(() => fakePerformance);
+    sinon.stub(window.performance, 'now').returns(42);
+    service.appStarted();
+    assert.isTrue(
+      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
+      )
+    );
+  });
+
+  test('WebComponentsReady', () => {
+    sinon.stub(window.performance, 'now').returns(42);
+    service.timeEnd('WebComponentsReady');
+    assert.isTrue(
+      service.reporter.calledWithMatch(
+        'timing-report',
+        'UI Latency',
+        'WebComponentsReady',
+        42
+      )
+    );
+  });
+
+  test('beforeLocationChanged', () => {
+    service._baselines['garbage'] = 'monster';
+    sinon.stub(service, 'time');
+    service.beforeLocationChanged();
+    assert.isTrue(service.time.calledWithExactly('DashboardDisplayed'));
+    assert.isTrue(service.time.calledWithExactly('ChangeDisplayed'));
+    assert.isTrue(service.time.calledWithExactly('ChangeFullyLoaded'));
+    assert.isTrue(service.time.calledWithExactly('DiffViewDisplayed'));
+    assert.isTrue(service.time.calledWithExactly('FileListDisplayed'));
+    assert.isFalse(
+      Object.prototype.hasOwnProperty.call(service._baselines, 'garbage')
+    );
+  });
+
+  test('changeDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.changeDisplayed();
+    assert.isFalse(service.timeEnd.calledWith('ChangeDisplayed'));
+    assert.isTrue(service.timeEnd.calledWith('StartupChangeDisplayed'));
+    service.changeDisplayed();
+    assert.isTrue(service.timeEnd.calledWith('ChangeDisplayed'));
+  });
+
+  test('changeFullyLoaded', () => {
+    sinon.spy(service, 'timeEnd');
+    service.changeFullyLoaded();
+    assert.isFalse(service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
+    assert.isTrue(
+      service.timeEnd.calledWithExactly('StartupChangeFullyLoaded')
+    );
+    service.changeFullyLoaded();
+    assert.isTrue(service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
+  });
+
+  test('diffViewDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.diffViewDisplayed();
+    assert.isFalse(service.timeEnd.calledWith('DiffViewDisplayed'));
+    assert.isTrue(service.timeEnd.calledWith('StartupDiffViewDisplayed'));
+    service.diffViewDisplayed();
+    assert.isTrue(service.timeEnd.calledWith('DiffViewDisplayed'));
+  });
+
+  test('fileListDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.fileListDisplayed();
+    assert.isFalse(service.timeEnd.calledWithExactly('FileListDisplayed'));
+    assert.isTrue(
+      service.timeEnd.calledWithExactly('StartupFileListDisplayed')
+    );
+    service.fileListDisplayed();
+    assert.isTrue(service.timeEnd.calledWithExactly('FileListDisplayed'));
+  });
+
+  test('dashboardDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.dashboardDisplayed();
+    assert.isFalse(service.timeEnd.calledWith('DashboardDisplayed'));
+    assert.isTrue(service.timeEnd.calledWith('StartupDashboardDisplayed'));
+    service.dashboardDisplayed();
+    assert.isTrue(service.timeEnd.calledWith('DashboardDisplayed'));
+  });
+
+  test('dashboardDisplayed details', () => {
+    sinon.spy(service, 'timeEnd');
+    sinon.stub(window, 'performance').value({
+      memory: {
+        usedJSHeapSize: 1024 * 1024,
+      },
+      measure: () => {},
+      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,
+      })
+    );
+  });
+
+  suite('hidden duration', () => {
+    let nowStub: sinon.SinonStub;
+    let visibilityStateStub: sinon.SinonStub;
+    const assertHiddenDurationsMs = (hiddenDurationMs: number) => {
+      service.dashboardDisplayed();
+      assert.isTrue(
+        service.timeEnd.calledWithMatch('StartupDashboardDisplayed', {
+          hiddenDurationMs,
+        })
+      );
+    };
+
+    setup(() => {
+      sinon.spy(service, 'timeEnd');
+      nowStub = sinon.stub(window.performance, 'now');
+      visibilityStateStub = {
+        value: value => {
+          Object.defineProperty(document, 'visibilityState', {
+            value,
+            configurable: true,
+          });
+        },
+      } as sinon.SinonStub;
+    });
+
+    test('starts in hidden', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      service.onVisibilityChange();
+      nowStub.returns(15);
+      visibilityStateStub.value('visible');
+      service.onVisibilityChange();
+      assertHiddenDurationsMs(5);
+    });
+
+    test('full in hidden', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      assertHiddenDurationsMs(10);
+    });
+
+    test('full in visible', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('visible');
+      assertHiddenDurationsMs(0);
+    });
+
+    test('accumulated', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      service.onVisibilityChange();
+      nowStub.returns(15);
+      visibilityStateStub.value('visible');
+      service.onVisibilityChange();
+      nowStub.returns(20);
+      visibilityStateStub.value('hidden');
+      service.onVisibilityChange();
+      nowStub.returns(25);
+      assertHiddenDurationsMs(10);
+    });
+
+    test('reset after location change', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      assertHiddenDurationsMs(10);
+      visibilityStateStub.value('visible');
+      nowStub.returns(15);
+      service.beforeLocationChanged();
+      service.timeEnd.resetHistory();
+      service.dashboardDisplayed();
+      assert.isTrue(
+        service.timeEnd.calledWithMatch('DashboardDisplayed', {
+          hiddenDurationMs: 0,
+        })
+      );
+    });
+  });
+
+  test('time and timeEnd', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(0);
+    service.time('foo');
+    nowStub.returns(1);
+    service.time('bar');
+    nowStub.returns(2);
+    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)
+    );
+  });
+
+  test('timer object', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(100);
+    const timer = service.getTimer('foo-bar');
+    nowStub.returns(150);
+    timer.end();
+    assert.isTrue(
+      service.reporter.calledWithMatch(
+        'timing-report',
+        'UI Latency',
+        'foo-bar',
+        50
+      )
+    );
+  });
+
+  test('timer object double call', () => {
+    const timer = service.getTimer('foo-bar');
+    timer.end();
+    assert.isTrue(service.reporter.calledOnce);
+    assert.throws(() => {
+      timer.end();
+    }, 'Timer for "foo-bar" already ended.');
+  });
+
+  test('timer object maximum', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(100);
+    const timer = service.getTimer('foo-bar').withMaximum(100);
+    nowStub.returns(150);
+    timer.end();
+    assert.isTrue(service.reporter.calledOnce);
+
+    timer.reset();
+    nowStub.returns(260);
+    timer.end();
+    assert.isTrue(service.reporter.calledOnce);
+  });
+
+  test('reportExtension', () => {
+    service.reportExtension('foo');
+    assert.isTrue(
+      service.reporter.calledWithExactly(
+        'lifecycle',
+        'Extension detected',
+        'Extension detected',
+        undefined,
+        {name: 'foo'}
+      )
+    );
+  });
+
+  test('reportInteraction', () => {
+    service.reporter.restore();
+    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'}),
+      })
+    );
+  });
+
+  test('trackApi reports same event only once', () => {
+    sinon.spy(service, '_reportEvent');
+    const pluginApi = {getPluginName: () => 'test'};
+    service.trackApi(pluginApi, 'object', 'method');
+    service.trackApi(pluginApi, 'object', 'method');
+    assert.isTrue(service.reporter.calledOnce);
+    service.trackApi(pluginApi, 'object', 'method2');
+    assert.isTrue(service.reporter.calledTwice);
+  });
+
+  test('report start time', () => {
+    service.reporter.restore();
+    sinon.stub(window.performance, 'now').returns(42);
+    sinon.spy(service, '_reportEvent');
+    const dispatchStub = sinon.spy(document, 'dispatchEvent');
+    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] as CustomEvent).detail.eventStart,
+      42
+    );
+  });
+
+  test('dedup', () => {
+    assert.isFalse(service._dedup('a', undefined, undefined));
+    assert.isFalse(service._dedup('a', undefined, undefined));
+
+    let deduping = Deduping.EVENT_ONCE_PER_SESSION;
+    assert.isFalse(service._dedup('b', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('b', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('b', {x: 'bar'}, deduping));
+
+    deduping = Deduping.DETAILS_ONCE_PER_SESSION;
+    assert.isFalse(service._dedup('c', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('c', {x: 'foo'}, deduping));
+    assert.isFalse(service._dedup('c', {x: 'bar'}, deduping));
+    assert.isTrue(service._dedup('c', {x: 'bar'}, deduping));
+
+    deduping = Deduping.EVENT_ONCE_PER_CHANGE;
+    service.setChangeId(1);
+    assert.isFalse(service._dedup('d', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('d', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('d', {x: 'bar'}, deduping));
+    service.setChangeId(2);
+    assert.isFalse(service._dedup('d', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('d', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('d', {x: 'bar'}, deduping));
+
+    deduping = Deduping.DETAILS_ONCE_PER_CHANGE;
+    service.setChangeId(1);
+    assert.isFalse(service._dedup('e', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('e', {x: 'foo'}, deduping));
+    assert.isFalse(service._dedup('e', {x: 'bar'}, deduping));
+    assert.isTrue(service._dedup('e', {x: 'bar'}, deduping));
+    service.setChangeId(2);
+    assert.isFalse(service._dedup('e', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('e', {x: 'foo'}, deduping));
+    assert.isFalse(service._dedup('e', {x: 'bar'}, deduping));
+    assert.isTrue(service._dedup('e', {x: 'bar'}, deduping));
+  });
+
+  suite('plugins', () => {
+    setup(() => {
+      service.reporter.restore();
+      sinon.stub(service, '_reportEvent');
+    });
+
+    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,
+        })
+      );
+    });
+
+    test('pluginsLoaded reports plugins', () => {
+      service.pluginsLoaded(['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', () => {
+      service.timeEnd('foo');
+      assert.isFalse(service._reportEvent.called);
+    });
+
+    test('reports if plugins are loaded', () => {
+      service.pluginsLoaded();
+      assert.isTrue(service._reportEvent.called);
+    });
+
+    test('reports if metrics plugin xyz is loaded', () => {
+      service.pluginLoaded('metrics-xyz');
+      assert.isTrue(service._reportEvent.called);
+    });
+
+    test('reports cached events preserving order', () => {
+      service.time('foo');
+      service.time('bar');
+      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',
+        })
+      );
+    });
+  });
+
+  test('search', () => {
+    service.locationChanged('_handleSomeRoute');
+    assert.isTrue(
+      service.reporter.calledWithExactly(
+        'nav-report',
+        'Location Changed',
+        'Page',
+        '_handleSomeRoute'
+      )
+    );
+  });
+
+  suite('exception logging', () => {
+    let fakeWindow: any;
+    let reporter: sinon.SinonStub;
+
+    const emulateThrow = function (
+      msg?: string,
+      url?: string,
+      line?: number,
+      column?: number,
+      error?: Error
+    ) {
+      return fakeWindow.onerror(msg, url, line, column, error);
+    };
+
+    setup(() => {
+      reporter = service.reporter;
+      fakeWindow = {
+        handlers: {},
+        addEventListener(type: string, handler: object) {
+          this.handlers[type] = handler;
+        },
+      };
+      sinon.stub(console, 'error');
+      Object.defineProperty(getAppContext(), 'reportingService', {
+        get() {
+          return service;
+        },
+      });
+      const errorReporter = initErrorReporter(getAppContext().reportingService);
+      errorReporter.catchErrors(fakeWindow);
+    });
+
+    test('is reported', () => {
+      const error = new Error('bar');
+      error.stack = undefined;
+      emulateThrow('bar', 'http://url', 4, 2, error);
+      assert.isTrue(reporter.calledWith('error', 'exception', 'onError: bar'));
+    });
+
+    test('is reported with stack', () => {
+      const error = new Error('bar');
+      emulateThrow('bar', 'http://url', 4, 2, error);
+      const eventDetails = reporter.lastCall.args[4];
+      assert.equal(error.stack, eventDetails.stack);
+    });
+
+    test('prevent default event handler', () => {
+      assert.isTrue(emulateThrow());
+    });
+
+    test('unhandled rejection', () => {
+      const newError = new Error('bar');
+      fakeWindow.handlers['unhandledrejection']({reason: newError});
+      assert.isTrue(
+        reporter.calledWith('error', 'exception', 'unhandledrejection: bar')
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
similarity index 91%
rename from polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
rename to polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 8c4ac6d..567045d 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.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,19 +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 {appContext} from '../../../services/app-context';
-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,
@@ -48,7 +47,6 @@
   AccountId,
   AccountInfo,
   ActionNameToActionInfoMap,
-  AssigneeInput,
   Base64File,
   Base64FileContent,
   Base64ImageFile,
@@ -134,37 +132,36 @@
   TopMenuEntryInfo,
   UrlEncodedCommentId,
   UrlEncodedRepoName,
-} from '../../../types/common';
+} from '../../types/common';
 import {
   DiffInfo,
   DiffPreferencesInfo,
   IgnoreWhitespaceType,
   WebLinkInfo,
-} 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,
   createDefaultEditPrefs,
   createDefaultPreferences,
-  DiffViewMode,
   HttpMethod,
   ProjectState,
   ReviewerState,
-} from '../../../constants/constants';
-import {firePageError, fireServerError} from '../../../utils/event-util';
-import {ParsedChangeInfo} from '../../../types/types';
-import {ErrorCallback} from '../../../api/rest';
-import {FlagsService, KnownExperimentId} from '../../../services/flags/flags';
+} 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;
-// This value is somewhat arbitrary and not based on research or calculations.
-const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
 
 const Requests = {
   SEND_DIFF_DRAFT: 'sendDiffDraft',
@@ -182,7 +179,13 @@
 let fetchPromisesCache = new FetchPromisesCache(); // Shared across instances.
 let pendingRequest: {[promiseName: string]: Array<Promise<unknown>>} = {}; // Shared across instances.
 let grEtagDecorator = new GrEtagDecorator(); // Shared across instances.
-let projectLookup: {[changeNum: string]: RepoName} = {}; // Shared across instances.
+let projectLookup: {[changeNum: string]: Promise<RepoName | undefined>} = {}; // Shared across instances.
+
+function suppress404s(res?: Response | null) {
+  if (!res || res.status === 404) return;
+  // This is the default error handling behavior of the rest-api-helper.
+  fireServerError(res);
+}
 
 interface FetchChangeJSON {
   reportEndpointAsIs?: boolean;
@@ -271,20 +274,17 @@
   pendingRequest = {};
   grEtagDecorator = new GrEtagDecorator();
   projectLookup = {};
-  appContext.authService.clearCache();
+  getAppContext().authService.clearCache();
 }
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-rest-api-interface': GrRestApiInterface;
-  }
+function createReadScheduler() {
+  return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 10);
 }
 
-@customElement('gr-rest-api-interface')
-export class GrRestApiInterface
-  extends PolymerElement
-  implements RestApiService
-{
+function createWriteScheduler() {
+  return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5);
+}
+export class GrRestApiServiceImpl implements RestApiService, Finalizable {
   readonly _cache = siteBasedCache; // Shared across instances.
 
   readonly _sharedFetchPromises = fetchPromisesCache; // Shared across instances.
@@ -298,24 +298,24 @@
   // The value is set in created, before any other actions
   private authService: AuthService;
 
-  private flagService: FlagsService;
-
   // The value is set in created, before any other actions
   private readonly _restApiHelper: GrRestApiHelper;
 
-  constructor(authService?: AuthService, flagService?: FlagsService) {
-    super();
+  constructor(authService?: AuthService) {
     // TODO: Make the authService constructor parameter required when we have
     // changed all usages of this class to not instantiate via createElement().
-    this.authService = authService ?? appContext.authService;
-    this.flagService = flagService ?? appContext.flagsService;
+    this.authService = authService ?? getAppContext().authService;
     this._restApiHelper = new GrRestApiHelper(
       this._cache,
       this.authService,
-      this._sharedFetchPromises
+      this._sharedFetchPromises,
+      createReadScheduler(),
+      createWriteScheduler()
     );
   }
 
+  finalize() {}
+
   _fetchSharedCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
     // Cache is shared across instances
     return this._restApiHelper.fetchCacheURL(req);
@@ -505,7 +505,8 @@
     });
   }
 
-  getIsGroupOwner(groupName: GroupName): Promise<boolean> {
+  getIsGroupOwner(groupName?: GroupName): Promise<boolean> {
+    if (!groupName) return Promise.resolve(false);
     const encodeName = encodeURIComponent(groupName);
     const req = {
       url: `/groups/?owned&g=${encodeName}`,
@@ -982,13 +983,6 @@
             return res;
           }
           const prefInfo = res as unknown as PreferencesInfo;
-          if (this._isNarrowScreen()) {
-            // Note that this can be problematic, because the diff will stay
-            // unified even after increasing the window width.
-            prefInfo.default_diff_view = DiffViewMode.UNIFIED;
-          } else {
-            prefInfo.default_diff_view = prefInfo.diff_view;
-          }
           return prefInfo;
         });
       }
@@ -1024,38 +1018,13 @@
     });
   }
 
-  _isNarrowScreen() {
-    return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
-  }
-
-  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 +1043,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 +1074,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.
    */
@@ -1128,10 +1156,11 @@
   }
 
   getChangeDetail(
-    changeNum: NumericChangeId,
+    changeNum?: NumericChangeId,
     errFn?: ErrorCallback,
     cancelCondition?: CancelConditionCallback
-  ): Promise<ParsedChangeInfo | null | undefined> {
+  ): Promise<ParsedChangeInfo | undefined> {
+    if (!changeNum) return Promise.resolve(undefined);
     return this.getConfig(false).then(config => {
       const optionsHex = this._getChangeOptionsHex(config);
       return this._getChangeDetail(
@@ -1157,6 +1186,7 @@
     const options = [
       ListChangesOption.LABELS,
       ListChangesOption.DETAILED_ACCOUNTS,
+      ListChangesOption.SUBMIT_REQUIREMENTS,
     ];
 
     return listChangesOptionsToHex(...options);
@@ -1166,8 +1196,7 @@
     if (
       window.DEFAULT_DETAIL_HEXES &&
       window.DEFAULT_DETAIL_HEXES.changePage &&
-      (!config || !(config.receive && config.receive.enable_signed_push)) &&
-      !this.flagService?.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)
+      (!config || !(config.receive && config.receive.enable_signed_push))
     ) {
       return window.DEFAULT_DETAIL_HEXES.changePage;
     }
@@ -1184,30 +1213,14 @@
       ListChangesOption.SUBMITTABLE,
       ListChangesOption.WEB_LINKS,
       ListChangesOption.SKIP_DIFFSTAT,
+      ListChangesOption.SUBMIT_REQUIREMENTS,
     ];
     if (config?.receive?.enable_signed_push) {
       options.push(ListChangesOption.PUSH_CERTIFICATES);
     }
-    if (this.flagService?.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
-      options.push(ListChangesOption.SUBMIT_REQUIREMENTS);
-    }
     return listChangesOptionsToHex(...options);
   }
 
-  getDiffChangeDetail(changeNum: NumericChangeId) {
-    let optionsHex = '';
-    if (window.DEFAULT_DETAIL_HEXES?.diffPage) {
-      optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
-    } else {
-      optionsHex = listChangesOptionsToHex(
-        ListChangesOption.ALL_COMMITS,
-        ListChangesOption.ALL_REVISIONS,
-        ListChangesOption.SKIP_DIFFSTAT
-      );
-    }
-    return this._getChangeDetail(changeNum, optionsHex);
-  }
-
   /**
    * @param optionsHex list changes options in hex
    */
@@ -1216,7 +1229,7 @@
     optionsHex: string,
     errFn?: ErrorCallback,
     cancelCondition?: CancelConditionCallback
-  ): Promise<ChangeInfo | undefined | null> {
+  ): Promise<ChangeInfo | undefined> {
     return this.getChangeActionURL(changeNum, undefined, '/detail').then(
       url => {
         const params: FetchParams = {O: optionsHex};
@@ -1247,12 +1260,12 @@
           }
 
           if (!response) {
-            return Promise.resolve(null);
+            return Promise.resolve(undefined);
           }
 
           return readResponsePayload(response).then(payload => {
             if (!payload) {
-              return null;
+              return undefined;
             }
             this._etags.collect(urlWithParams, response, payload.raw);
             // TODO(TS): Why it is always change info?
@@ -1271,6 +1284,7 @@
       endpoint: '/commit?links',
       revision: patchNum,
       reportEndpointAsIs: true,
+      errFn: suppress404s,
     }) as Promise<CommitInfo | undefined>;
   }
 
@@ -1740,18 +1754,23 @@
   }
 
   getChangesSubmittedTogether(
-    changeNum: NumericChangeId
+    changeNum: NumericChangeId,
+    options: string[] = ['NON_VISIBLE_CHANGES']
   ): Promise<SubmittedTogetherInfo | undefined> {
     return this._getChangeURLAndFetch({
       changeNum,
-      endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
+      endpoint: `/submitted_together?o=${options.join('&o=')}`,
       reportEndpointAsIs: true,
     }) as Promise<SubmittedTogetherInfo | undefined>;
   }
 
-  getChangeConflicts(
+  async getChangeConflicts(
     changeNum: NumericChangeId
   ): Promise<ChangeInfo[] | undefined> {
+    const config = await this.getConfig(false);
+    if (!config?.change?.conflicts_predicate_enabled) {
+      return [];
+    }
     const options = listChangesOptionsToHex(
       ListChangesOption.CURRENT_REVISION,
       ListChangesOption.CURRENT_COMMIT
@@ -1795,22 +1814,27 @@
 
   getChangesWithSameTopic(
     topic: string,
-    changeNum: NumericChangeId
+    options?: {
+      openChangesOnly?: boolean;
+      changeToExclude?: NumericChangeId;
+    }
   ): Promise<ChangeInfo[] | undefined> {
-    const options = listChangesOptionsToHex(
+    const requestOptions = listChangesOptionsToHex(
       ListChangesOption.LABELS,
       ListChangesOption.CURRENT_REVISION,
       ListChangesOption.CURRENT_COMMIT,
       ListChangesOption.DETAILED_LABELS
     );
-    const query = [
-      'status:open',
-      `-change:${changeNum}`,
-      `topic:"${topic}"`,
-    ].join(' ');
+    const queryTerms = [`topic:"${topic}"`];
+    if (options?.openChangesOnly) {
+      queryTerms.push('status:open');
+    }
+    if (options?.changeToExclude !== undefined) {
+      queryTerms.push(`-change:${options.changeToExclude}`);
+    }
     const params = {
-      O: options,
-      q: query,
+      O: requestOptions,
+      q: queryTerms.join(' '),
     };
     return this._restApiHelper.fetchJSON({
       url: '/changes/',
@@ -1888,14 +1912,12 @@
     );
   }
 
-  getChangeEdit(
-    changeNum: NumericChangeId,
-    downloadCommands?: boolean
-  ): Promise<false | EditInfo | undefined> {
-    const params = downloadCommands ? {'download-commands': true} : undefined;
+  getChangeEdit(changeNum?: NumericChangeId): Promise<EditInfo | undefined> {
+    if (!changeNum) return Promise.resolve(undefined);
+    const params = {'download-commands': true};
     return this.getLoggedIn().then(loggedIn => {
       if (!loggedIn) {
-        return Promise.resolve(false);
+        return Promise.resolve(undefined);
       }
       return this._getChangeURLAndFetch(
         {
@@ -1905,7 +1927,7 @@
           reportEndpointAsIs: true,
         },
         true
-      ) as Promise<EditInfo | false | undefined>;
+      ) as Promise<EditInfo | undefined>;
     });
   }
 
@@ -1944,12 +1966,6 @@
   ): Promise<Response | Base64FileContent | undefined> {
     // 404s indicate the file does not exist yet in the revision, so suppress
     // them.
-    const suppress404s: ErrorCallback = res => {
-      if (res && res?.status !== 404) {
-        fireServerError(res);
-      }
-      return res;
-    };
     const promise =
       patchNum === EditPatchSetNum
         ? this._getFileInChangeEdit(changeNum, path)
@@ -2321,45 +2337,16 @@
    * is no logged in user, the request is not made and the promise yields an
    * empty object.
    */
-  getDiffDrafts(
+  async getDiffDrafts(
     changeNum: NumericChangeId
-  ): Promise<PathToCommentsInfoMap | undefined>;
-
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum: BasePatchSetNum,
-    patchNum: PatchSetNum,
-    path: string
-  ): Promise<GetDiffCommentsOutput>;
-
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: PatchSetNum,
-    path?: string
-  ) {
-    return this.getLoggedIn().then(loggedIn => {
-      if (!loggedIn) {
-        return {};
-      }
-      if (!basePatchNum && !patchNum && !path) {
-        return this._getDiffComments(changeNum, '/drafts', {
-          'enable-context': true,
-          'context-padding': 3,
-        });
-      }
-      return this._getDiffComments(
-        changeNum,
-        '/drafts',
-        {
-          'enable-context': true,
-          'context-padding': 3,
-        },
-        basePatchNum,
-        patchNum,
-        path
-      );
+  ): Promise<{[path: string]: DraftInfo[]} | undefined> {
+    const loggedIn = await this.getLoggedIn();
+    if (!loggedIn) return {};
+    const comments = await this._getDiffComments(changeNum, '/drafts', {
+      'enable-context': true,
+      'context-padding': 3,
     });
+    return addDraftProp(comments);
   }
 
   _setRange(comments: CommentInfo[], comment: CommentInfo) {
@@ -2575,7 +2562,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];
@@ -2583,7 +2570,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> {
@@ -2978,29 +2965,6 @@
     }) as Promise<TopMenuEntryInfo[] | undefined>;
   }
 
-  setAssignee(
-    changeNum: NumericChangeId,
-    assignee: AccountId
-  ): Promise<Response> {
-    const body: AssigneeInput = {assignee};
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      endpoint: '/assignee',
-      body,
-      reportUrlAsIs: true,
-    });
-  }
-
-  deleteAssignee(changeNum: NumericChangeId): Promise<Response> {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.DELETE,
-      endpoint: '/assignee',
-      reportUrlAsIs: true,
-    });
-  }
-
   probePath(path: string) {
     return fetch(new Request(path, {method: HttpMethod.HEAD})).then(
       response => response.ok
@@ -3067,17 +3031,15 @@
       });
   }
 
-  setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
-    if (
-      this._projectLookup[changeNum] &&
-      this._projectLookup[changeNum] !== project
-    ) {
+  async setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
+    const lookupProject = await this._projectLookup[changeNum];
+    if (lookupProject && lookupProject !== project) {
       console.warn(
         'Change set with multiple project nums.' +
           'One of them must be invalid.'
       );
     }
-    this._projectLookup[changeNum] = project;
+    this._projectLookup[changeNum] = Promise.resolve(project);
   }
 
   /**
@@ -3090,18 +3052,22 @@
   ): Promise<RepoName | undefined> {
     const project = this._projectLookup[`${changeNum}`];
     if (project) {
-      return Promise.resolve(project);
+      return project;
     }
 
     const onError = (response?: Response | null) => firePageError(response);
 
-    return this.getChange(changeNum, onError).then(change => {
+    const projectPromise = this.getChange(changeNum, onError).then(change => {
       if (!change || !change.project) {
         return;
       }
       this.setInProjectLookup(changeNum, change.project);
       return change.project;
     });
+
+    this._projectLookup[changeNum] = projectPromise;
+
+    return projectPromise;
   }
 
   // if errFn is not set, then only Response possible
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
similarity index 90%
rename from polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
rename to polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
index 9c096bf..b4defb5 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
@@ -15,21 +15,26 @@
  * 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 {appContext} 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 {GrRestApiInterface} from './gr-rest-api-interface.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';
 
-suite('gr-rest-api-interface tests', () => {
+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;
 
   let ctr = 0;
@@ -49,9 +54,12 @@
       },
     }));
     // fake auth
-    sinon.stub(appContext.authService, 'authCheck')
+    sinon.stub(getAppContext().authService, 'authCheck')
         .returns(Promise.resolve(true));
-    element = new GrRestApiInterface();
+    element = new GrRestApiServiceImpl(
+        getAppContext().authService,
+        getAppContext().flagsService
+    );
     element._projectLookup = {};
   });
 
@@ -333,26 +341,23 @@
     stub.lastCall.args[0].errFn({});
   });
 
-  const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
+  const preferenceSetup = function(testJSON, loggedIn) {
     sinon.stub(element, 'getLoggedIn')
         .callsFake(() => Promise.resolve(loggedIn));
-    sinon.stub(element, '_isNarrowScreen').callsFake(() => smallScreen);
     sinon.stub(
         element._restApiHelper,
         'fetchCacheURL')
         .callsFake(() => Promise.resolve(testJSON));
   };
 
-  test('getPreferences returns correctly on small screens logged in',
+  test('getPreferences returns correctly logged in',
       () => {
         const testJSON = {diff_view: 'SIDE_BY_SIDE'};
         const loggedIn = true;
-        const smallScreen = true;
 
-        preferenceSetup(testJSON, loggedIn, smallScreen);
+        preferenceSetup(testJSON, loggedIn);
 
         return element.getPreferences().then(obj => {
-          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
           assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
         });
       });
@@ -361,12 +366,10 @@
       () => {
         const testJSON = {diff_view: 'UNIFIED_DIFF'};
         const loggedIn = true;
-        const smallScreen = false;
 
-        preferenceSetup(testJSON, loggedIn, smallScreen);
+        preferenceSetup(testJSON, loggedIn);
 
         return element.getPreferences().then(obj => {
-          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
           assert.equal(obj.diff_view, 'UNIFIED_DIFF');
         });
       });
@@ -375,12 +378,10 @@
       () => {
         const testJSON = {diff_view: 'UNIFIED_DIFF'};
         const loggedIn = false;
-        const smallScreen = false;
 
-        preferenceSetup(testJSON, loggedIn, smallScreen);
+        preferenceSetup(testJSON, loggedIn);
 
         return element.getPreferences().then(obj => {
-          assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
           assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
         });
       });
@@ -583,7 +584,7 @@
   });
 
   test('saveChangeEdit', () => {
-    element._projectLookup = {1: 'test'};
+    element._projectLookup = {1: Promise.resolve('test')};
     const change_num = '1';
     const file_name = 'index.php';
     const file_contents = '<?php';
@@ -605,7 +606,7 @@
   });
 
   test('putChangeCommitMessage', () => {
-    element._projectLookup = {1: 'test'};
+    element._projectLookup = {1: Promise.resolve('test')};
     const change_num = '1';
     const message = 'this is a commit message';
     sinon.stub(element._restApiHelper, 'send').returns(
@@ -624,7 +625,7 @@
   });
 
   test('deleteChangeCommitMessage', () => {
-    element._projectLookup = {1: 'test'};
+    element._projectLookup = {1: Promise.resolve('test')};
     const change_num = '1';
     const messageId = 'abc';
     sinon.stub(element._restApiHelper, 'send').returns(
@@ -870,7 +871,7 @@
   test('gerrit auth is used', () => {
     stubAuth('fetch').returns(Promise.resolve());
     element._restApiHelper.fetchJSON({url: 'foo'});
-    assert(appContext.authService.fetch.called);
+    assert(getAppContext().authService.fetch.called);
   });
 
   test('getSuggestedAccounts does not return _fetchJSON', () => {
@@ -933,7 +934,7 @@
 
     test('_getChangeDetail passes params to ETags decorator', () => {
       const changeNum = 4321;
-      element._projectLookup[changeNum] = 'test';
+      element._projectLookup[changeNum] = Promise.resolve('test');
       const expectedUrl =
           window.CANONICAL_PATH + '/changes/test~4321/detail?O=516714';
       sinon.stub(element._etags, 'getOptions');
@@ -967,7 +968,8 @@
           }));
       await element._getChangeDetail(1, '516714');
       assert.equal(Object.keys(element._projectLookup).length, 1);
-      assert.equal(element._projectLookup[1], 'test');
+      const project = await element._projectLookup[1];
+      assert.equal(project, 'test');
     });
 
     suite('_getChangeDetail ETag cache', () => {
@@ -1021,41 +1023,32 @@
     });
   });
 
-  test('setInProjectLookup', () => {
-    element.setInProjectLookup('test', 'project');
-    assert.deepEqual(element._projectLookup, {test: 'project'});
+  test('setInProjectLookup', async () => {
+    await element.setInProjectLookup('test', 'project');
+    const project = await element.getFromProjectLookup('test');
+    assert.deepEqual(project, 'project');
   });
 
   suite('getFromProjectLookup', () => {
-    test('getChange fails', () => {
-      sinon.stub(element, 'getChange')
-          .returns(Promise.resolve(null));
-      return element.getFromProjectLookup().then(val => {
-        assert.strictEqual(val, undefined);
-        assert.deepEqual(element._projectLookup, {});
-      });
-    });
-
-    test('getChange succeeds, no project', () => {
+    test('getChange succeeds, no project', async () => {
       sinon.stub(element, 'getChange').returns(Promise.resolve(null));
-      return element.getFromProjectLookup().then(val => {
-        assert.strictEqual(val, undefined);
-        assert.deepEqual(element._projectLookup, {});
-      });
+      const val = await element.getFromProjectLookup();
+      assert.strictEqual(val, undefined);
     });
 
     test('getChange succeeds with project', () => {
       sinon.stub(element, 'getChange')
           .returns(Promise.resolve({project: 'project'}));
-      return element.getFromProjectLookup('test').then(val => {
+      const projectLookup = element.getFromProjectLookup('test');
+      return projectLookup.then(val => {
         assert.equal(val, 'project');
-        assert.deepEqual(element._projectLookup, {test: 'project'});
+        assert.deepEqual(element._projectLookup, {test: projectLookup});
       });
     });
   });
 
   suite('getChanges populates _projectLookup', () => {
-    test('multiple queries', () => {
+    test('multiple queries', async () => {
       sinon.stub(element._restApiHelper, 'fetchJSON')
           .returns(Promise.resolve([
             [
@@ -1067,15 +1060,17 @@
           ]));
       // When opt_query instanceof Array, _fetchJSON returns
       // Array<Array<Object>>.
-      return element.getChanges(null, []).then(() => {
-        assert.equal(Object.keys(element._projectLookup).length, 3);
-        assert.equal(element._projectLookup[1], 'test');
-        assert.equal(element._projectLookup[2], 'test');
-        assert.equal(element._projectLookup[3], 'test/test');
-      });
+      await element.getChangesForMultipleQueries(null, []);
+      assert.equal(Object.keys(element._projectLookup).length, 3);
+      const project1 = await element.getFromProjectLookup(1);
+      assert.equal(project1, 'test');
+      const project2 = await element.getFromProjectLookup(2);
+      assert.equal(project2, 'test');
+      const project3 = await element.getFromProjectLookup(3);
+      assert.equal(project3, 'test/test');
     });
 
-    test('no query', () => {
+    test('no query', async () => {
       sinon.stub(element._restApiHelper, 'fetchJSON')
           .returns(Promise.resolve([
             {_number: 1, project: 'test'},
@@ -1085,17 +1080,37 @@
 
       // When opt_query !instanceof Array, _fetchJSON returns
       // Array<Object>.
-      return element.getChanges().then(() => {
-        assert.equal(Object.keys(element._projectLookup).length, 3);
-        assert.equal(element._projectLookup[1], 'test');
-        assert.equal(element._projectLookup[2], 'test');
-        assert.equal(element._projectLookup[3], 'test/test');
-      });
+      await element.getChanges();
+      assert.equal(Object.keys(element._projectLookup).length, 3);
+      const project1 = await element.getFromProjectLookup(1);
+      assert.equal(project1, 'test');
+      const project2 = await element.getFromProjectLookup(2);
+      assert.equal(project2, 'test');
+      const project3 = await element.getFromProjectLookup(3);
+      assert.equal(project3, 'test/test');
     });
   });
 
+  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: 'test'};
+    element._projectLookup = {1: Promise.resolve('test')};
     const fetchStub = sinon.stub(element._restApiHelper, 'fetchJSON')
         .returns(Promise.resolve());
     const req = {changeNum: 1, endpoint: '/test', revision: 1};
@@ -1106,7 +1121,7 @@
   });
 
   test('_getChangeURLAndSend', () => {
-    element._projectLookup = {1: 'test'};
+    element._projectLookup = {1: Promise.resolve('test')};
     const sendStub = sinon.stub(element._restApiHelper, 'send')
         .returns(Promise.resolve());
 
@@ -1286,7 +1301,8 @@
     const res = {status: 404};
     const spy = sinon.spy();
     addListenerForTest(document, 'server-error', spy);
-    sinon.stub(appContext.authService, 'fetch').returns(Promise.resolve(res));
+    sinon.stub(getAppContext().authService, 'fetch')
+        .returns(Promise.resolve(res));
     sinon.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
     return element.getFileContent('1', 'tst/path', '1')
         .then(() => {
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 445e932..e727216 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
@@ -16,6 +16,7 @@
  */
 
 import {HttpMethod} from '../../constants/constants';
+import {Finalizable} from '../registry';
 import {
   AccountCapabilityInfo,
   AccountDetailInfo,
@@ -108,17 +109,10 @@
 } from '../../types/diff';
 import {ParsedChangeInfo} from '../../types/types';
 import {ErrorCallback} from '../../api/rest';
+import {DraftInfo} from '../../utils/comment-util';
 
 export type CancelConditionCallback = () => boolean;
 
-// TODO(TS): remove when GrReplyDialog converted to typescript
-export interface GrReplyDialog {
-  getLabelValue(label: string): string;
-  setLabelValue(label: string, value: string): void;
-  send(includeComments?: boolean, startReview?: boolean): Promise<unknown>;
-  setPluginMessage(message: string): void;
-}
-
 export interface GetDiffCommentsOutput {
   baseComments: CommentInfo[];
   comments: CommentInfo[];
@@ -129,7 +123,7 @@
   comments: RobotCommentInfo[];
 }
 
-export interface RestApiService {
+export interface RestApiService extends Finalizable {
   getConfig(noCache?: boolean): Promise<ServerInfo | undefined>;
   getLoggedIn(): Promise<boolean>;
   getPreferences(): Promise<PreferencesInfo | undefined>;
@@ -200,10 +194,10 @@
   ): Promise<BranchInfo[] | undefined>;
 
   getChangeDetail(
-    changeNum: number | string,
+    changeNum?: number | string,
     opt_errFn?: ErrorCallback,
     opt_cancelCondition?: Function
-  ): Promise<ParsedChangeInfo | null | undefined>;
+  ): Promise<ParsedChangeInfo | undefined>;
 
   getChange(
     changeNum: ChangeId | NumericChangeId,
@@ -325,7 +319,7 @@
 
   getIsAdmin(): Promise<boolean | undefined>;
 
-  getIsGroupOwner(groupName: GroupName): Promise<boolean>;
+  getIsGroupOwner(groupName?: GroupName): Promise<boolean>;
 
   saveGroupName(
     groupId: GroupId | GroupName,
@@ -365,10 +359,7 @@
     errFn?: ErrorCallback
   ): Promise<Response>;
 
-  getChangeEdit(
-    changeNum: NumericChangeId,
-    downloadCommands?: boolean
-  ): Promise<false | EditInfo | undefined>;
+  getChangeEdit(changeNum?: NumericChangeId): Promise<EditInfo | undefined>;
 
   getChangeActionURL(
     changeNum: NumericChangeId,
@@ -401,10 +392,6 @@
     draft: CommentInput
   ): Promise<Response>;
 
-  getDiffChangeDetail(
-    changeNum: NumericChangeId
-  ): Promise<ChangeInfo | undefined | null>;
-
   getPortedComments(
     changeNum: NumericChangeId,
     revision: RevisionId
@@ -453,21 +440,7 @@
 
   getDiffDrafts(
     changeNum: NumericChangeId
-  ): Promise<PathToCommentsInfoMap | undefined>;
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum: PatchSetNum,
-    patchNum: PatchSetNum,
-    path: string
-  ): Promise<GetDiffCommentsOutput>;
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: PatchSetNum,
-    path?: string
-  ):
-    | Promise<GetDiffCommentsOutput>
-    | Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: DraftInfo[]} | undefined>;
 
   createGroup(config: GroupInput & {name: string}): Promise<Response>;
 
@@ -478,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>;
 
@@ -647,7 +612,8 @@
   ): Promise<RelatedChangesInfo | undefined>;
 
   getChangesSubmittedTogether(
-    changeNum: NumericChangeId
+    changeNum: NumericChangeId,
+    options?: string[]
   ): Promise<SubmittedTogetherInfo | undefined>;
 
   getChangeConflicts(
@@ -662,7 +628,10 @@
 
   getChangesWithSameTopic(
     topic: string,
-    changeNum: NumericChangeId
+    options?: {
+      openChangesOnly?: boolean;
+      changeToExclude?: NumericChangeId;
+    }
   ): Promise<ChangeInfo[] | undefined>;
   getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
 
@@ -727,13 +696,6 @@
 
   deleteDraftComments(query: string): Promise<Response>;
 
-  setAssignee(
-    changeNum: NumericChangeId,
-    assignee: AccountId
-  ): Promise<Response>;
-
-  deleteAssignee(changeNum: NumericChangeId): Promise<Response>;
-
   setChangeHashtag(
     changeNum: NumericChangeId,
     hashtag: HashtagsInput
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
new file mode 100644
index 0000000..80da260
--- /dev/null
+++ b/polygerrit-ui/app/services/highlight/highlight-service.ts
@@ -0,0 +1,159 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  SyntaxWorkerRequest,
+  SyntaxWorkerInit,
+  SyntaxWorkerResult,
+  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 syntaxWorkerUrl = `${
+  window.STATIC_RESOURCE_PATH ?? ''
+}/workers/syntax-worker.js`;
+
+/**
+ * It is unlikely that a pool size greater than 3 will gain anything, because
+ * the app also needs the resources to process the results.
+ */
+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.
+ */
+export class HighlightService implements Finalizable {
+  // visible for testing
+  poolIdle: Set<Worker> = new Set();
+
+  // visible for testing
+  poolBusy: Set<Worker> = new Set();
+
+  // visible for testing
+  /** Queue for waiting that a worker becomes available. */
+  queueForWorker: Array<() => void> = [];
+
+  // visible for testing
+  /** Queue for waiting on the results of a worker. */
+  queueForResult: Map<Worker, (r: SyntaxLayerLine[]) => void> = new Map();
+
+  constructor(readonly reporting: ReportingService) {
+    for (let i = 0; i < WORKER_POOL_SIZE; i++) {
+      this.addWorker();
+    }
+  }
+
+  /** Allows tests to produce fake workers. */
+  protected createWorker() {
+    return createWorker(prependOrigin(syntaxWorkerUrl));
+  }
+
+  /** Creates, initializes and then moves a worker to the idle pool. */
+  private addWorker() {
+    const worker = this.createWorker();
+    // Will move to the idle pool after being initialized.
+    this.poolBusy.add(worker);
+    worker.onmessage = (e: MessageEvent<SyntaxWorkerResult>) => {
+      this.handleResult(worker, e.data);
+    };
+    const initMsg: SyntaxWorkerInit = {
+      type: SyntaxWorkerMessageType.INIT,
+      url: prependOrigin(hljsLibUrl),
+    };
+    worker.postMessage(initMsg);
+  }
+
+  private moveIdleToBusy() {
+    const worker = this.poolIdle.values().next().value;
+    this.poolIdle.delete(worker);
+    this.poolBusy.add(worker);
+    return worker;
+  }
+
+  private moveBusyToIdle(worker: Worker) {
+    this.poolBusy.delete(worker);
+    this.poolIdle.add(worker);
+    const resolver = this.queueForWorker.shift();
+    if (resolver) resolver();
+  }
+
+  /**
+   * If there is worker in the idle pool, then return it. Otherwise wait for a
+   * worker to become a available.
+   */
+  private async requestWorker(): Promise<Worker> {
+    if (this.poolIdle.size > 0) {
+      const worker = this.moveIdleToBusy();
+      return Promise.resolve(worker);
+    }
+    await new Promise<void>(r => this.queueForWorker.push(r));
+    return this.requestWorker();
+  }
+
+  /**
+   * A worker is done with its job. Move it back to the idle pool and notify the
+   * resolver that is waiting for the results.
+   */
+  private handleResult(worker: Worker, result: SyntaxWorkerResult) {
+    this.moveBusyToIdle(worker);
+    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[]> {
+    if (!language || !code) return [];
+    if (code.length > CODE_MAX_LENGTH) return [];
+    const worker = await this.requestWorker();
+    const message: SyntaxWorkerRequest = {
+      type: SyntaxWorkerMessageType.REQUEST,
+      language,
+      code,
+    };
+    const promise = new Promise<SyntaxLayerLine[]>(r => {
+      this.queueForResult.set(worker, r);
+    });
+    worker.postMessage(message);
+    return await promise;
+  }
+
+  finalize() {
+    for (const worker of this.poolIdle) {
+      worker.terminate();
+    }
+    this.poolIdle.clear();
+    for (const worker of this.poolBusy) {
+      worker.terminate();
+    }
+    this.poolBusy.clear();
+    this.queueForResult.clear();
+    this.queueForWorker.length = 0;
+  }
+}
diff --git a/polygerrit-ui/app/services/highlight/highlight-service_test.ts b/polygerrit-ui/app/services/highlight/highlight-service_test.ts
new file mode 100644
index 0000000..4c38a68
--- /dev/null
+++ b/polygerrit-ui/app/services/highlight/highlight-service_test.ts
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup-karma';
+import {waitUntil} from '../../test/test-utils';
+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(grReportingMock);
+  });
+
+  test('initial state', () => {
+    assert.equal(service.poolBusy.size, 3);
+    assert.equal(service.poolIdle.size, 0);
+    assert.equal(service.queueForResult.size, 0);
+    assert.equal(service.queueForWorker.length, 0);
+  });
+
+  test('initialized workers move to idle pool', () => {
+    service.sendToAll({});
+    assert.equal(service.countAllMessages(), 3);
+
+    assert.equal(service.poolBusy.size, 0);
+    assert.equal(service.poolIdle.size, 3);
+  });
+
+  test('highlight 1', async () => {
+    service.sendToAll({});
+    assert.equal(service.countAllMessages(), 3);
+
+    const p = service.highlight('asdf', 'qwer');
+    assert.equal(service.poolBusy.size, 1);
+    assert.equal(service.poolIdle.size, 2);
+    await waitUntil(() => service.queueForResult.size > 0);
+    assert.equal(service.queueForResult.size, 1);
+    assert.equal(service.queueForWorker.length, 0);
+
+    service.sendToAll({ranges: []});
+    assert.equal(service.countAllMessages(), 4);
+    const ranges = await p;
+    assert.equal(ranges.length, 0);
+
+    await waitUntil(() => service.queueForResult.size === 0);
+    assert.equal(service.poolBusy.size, 0);
+    assert.equal(service.poolIdle.size, 3);
+    assert.equal(service.queueForResult.size, 0);
+    assert.equal(service.queueForWorker.length, 0);
+  });
+
+  test('highlight 5', async () => {
+    service.sendToAll({});
+    assert.equal(service.countAllMessages(), 3);
+
+    const p1 = service.highlight('asdf1', 'qwer1');
+    const p2 = service.highlight('asdf2', 'qwer2');
+    const p3 = service.highlight('asdf3', 'qwer3');
+    const p4 = service.highlight('asdf4', 'qwer4');
+    const p5 = service.highlight('asdf5', 'qwer5');
+
+    assert.equal(service.poolBusy.size, 3);
+    assert.equal(service.poolIdle.size, 0);
+    await waitUntil(() => service.queueForResult.size > 0);
+    assert.equal(service.queueForResult.size, 3);
+    assert.equal(service.queueForWorker.length, 2);
+
+    service.sendToAll({ranges: []});
+    assert.equal(service.countAllMessages(), 6);
+    const ranges1 = await p1;
+    const ranges2 = await p2;
+    const ranges3 = await p3;
+    assert.equal(ranges1.length, 0);
+    assert.equal(ranges2.length, 0);
+    assert.equal(ranges3.length, 0);
+
+    await waitUntil(() => service.queueForResult.size === 2);
+    assert.equal(service.poolBusy.size, 2);
+    assert.equal(service.poolIdle.size, 1);
+    assert.equal(service.queueForResult.size, 2);
+    assert.equal(service.queueForWorker.length, 0);
+
+    service.sendToAll({ranges: []});
+    assert.equal(service.countAllMessages(), 8);
+    const ranges4 = await p4;
+    const ranges5 = await p5;
+    assert.equal(ranges4.length, 0);
+    assert.equal(ranges5.length, 0);
+
+    await waitUntil(() => service.queueForResult.size === 0);
+    assert.equal(service.poolBusy.size, 0);
+    assert.equal(service.poolIdle.size, 3);
+    assert.equal(service.queueForResult.size, 0);
+    assert.equal(service.queueForWorker.length, 0);
+  });
+});
diff --git a/polygerrit-ui/app/services/registry.ts b/polygerrit-ui/app/services/registry.ts
new file mode 100644
index 0000000..e7de1ef
--- /dev/null
+++ b/polygerrit-ui/app/services/registry.ts
@@ -0,0 +1,86 @@
+/**
+ * @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.
+ */
+
+// A finalizable object has a single method `finalize` that is called when
+// the object is no longer needed and should clean itself up.
+export interface Finalizable {
+  finalize(): void;
+}
+
+// A factory can take a partially created TContext and generate a property
+// for a given key on that TContext.
+export type Factory<TContext, K extends keyof TContext> = (
+  ctx: Partial<TContext>
+) => TContext[K] & Finalizable;
+
+// A registry contains a factory for each key in TContext.
+export type Registry<TContext> = {[P in keyof TContext]: Factory<TContext, P>} &
+  Record<string, (_: TContext) => Finalizable>;
+
+// Creates a context given a registry.
+export function create<TContext>(
+  registry: Registry<TContext>
+): TContext & Finalizable {
+  const context: Partial<TContext> & Finalizable = {
+    finalize() {
+      for (const key of Object.getOwnPropertyNames(registry)) {
+        const name = key as keyof TContext;
+        try {
+          if (this[name]) {
+            (this[name] as unknown as Finalizable).finalize();
+          }
+        } catch (e) {
+          console.info(`Failed to finalize ${name}`);
+          throw e;
+        }
+      }
+    },
+  } as Partial<TContext> & Finalizable;
+
+  const initialized: Map<keyof TContext, Finalizable> = new Map<
+    keyof TContext,
+    Finalizable
+  >();
+  for (const key of Object.keys(registry)) {
+    const name = key as keyof TContext;
+    const factory = registry[name];
+    let initializing = false;
+    Object.defineProperty(context, name, {
+      configurable: true, // Tests can mock properties
+      get() {
+        if (!initialized.has(name)) {
+          // Notice that this is the getter for the property in question.
+          // It is possible that during the initialization of one property,
+          // another property is required. This extra check ensures that
+          // the construction of propertiers on Context are not circularly
+          // dependent.
+          if (initializing) throw new Error(`Circular dependency for ${key}`);
+          try {
+            initializing = true;
+            initialized.set(name, factory(context));
+          } catch (e) {
+            console.error(`Failed to initialize ${name}`, e);
+          } finally {
+            initializing = false;
+          }
+        }
+        return initialized.get(name);
+      },
+    });
+  }
+  return context as TContext & Finalizable;
+}
diff --git a/polygerrit-ui/app/services/registry_test.ts b/polygerrit-ui/app/services/registry_test.ts
new file mode 100644
index 0000000..3bb584a
--- /dev/null
+++ b/polygerrit-ui/app/services/registry_test.ts
@@ -0,0 +1,56 @@
+/**
+ * @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 {create, Finalizable, Registry} from './registry';
+import '../test/common-test-setup-karma.js';
+
+class Foo implements Finalizable {
+  constructor(private readonly final: string[]) {}
+
+  finalize() {
+    this.final.push('Foo');
+  }
+}
+
+class Bar implements Finalizable {
+  constructor(private readonly final: string[], _foo?: Foo) {}
+
+  finalize() {
+    this.final.push('Bar');
+  }
+}
+
+interface DemoContext {
+  foo: Foo;
+  bar: Bar;
+}
+
+suite('Registry', () => {
+  setup(() => {});
+
+  test('It finalizes correctly', () => {
+    const final: string[] = [];
+    const demoRegistry: Registry<DemoContext> = {
+      foo: (_ctx: Partial<DemoContext>) => new Foo(final),
+      bar: (ctx: Partial<DemoContext>) => new Bar(final, ctx.foo),
+    };
+    const demoContext: DemoContext & Finalizable =
+      create<DemoContext>(demoRegistry);
+    demoContext.finalize();
+    assert.deepEqual(final, ['Foo', 'Bar']);
+  });
+});
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index b3cdf9e..221e55b 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -15,9 +15,11 @@
  * limitations under the License.
  */
 
-import {NumericChangeId, PatchSetNum} from '../../types/common';
-import {BehaviorSubject, Observable} from 'rxjs';
+import {Observable} from 'rxjs';
 import {distinctUntilChanged, map} from 'rxjs/operators';
+import {Finalizable} from '../registry';
+import {NumericChangeId, PatchSetNum} from '../../types/common';
+import {Model} from '../../models/model';
 
 export enum GerritView {
   ADMIN = 'admin',
@@ -41,41 +43,40 @@
   patchNum?: PatchSetNum;
 }
 
-// TODO: Figure out how to best enforce immutability of all states. Use Immer?
-// Use DeepReadOnly?
-const initialState: RouterState = {};
+export class RouterModel extends Model<RouterState> implements Finalizable {
+  readonly routerView$: Observable<GerritView | undefined>;
 
-const privateState$ = new BehaviorSubject<RouterState>(initialState);
+  readonly routerChangeNum$: Observable<NumericChangeId | undefined>;
 
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const routerState$: Observable<RouterState> = privateState$;
+  readonly routerPatchNum$: Observable<PatchSetNum | undefined>;
 
-// Must only be used by the router service or whatever is in control of this
-// model.
-export function updateState(
-  view?: GerritView,
-  changeNum?: NumericChangeId,
-  patchNum?: PatchSetNum
-) {
-  privateState$.next({
-    ...privateState$.getValue(),
-    view,
-    changeNum,
-    patchNum,
-  });
+  constructor() {
+    super({});
+    this.routerView$ = this.state$.pipe(
+      map(state => state.view),
+      distinctUntilChanged()
+    );
+    this.routerChangeNum$ = this.state$.pipe(
+      map(state => state.changeNum),
+      distinctUntilChanged()
+    );
+    this.routerPatchNum$ = this.state$.pipe(
+      map(state => state.patchNum),
+      distinctUntilChanged()
+    );
+  }
+
+  finalize() {}
+
+  // Private but used in tests
+  setState(state: RouterState) {
+    this.subject$.next(state);
+  }
+
+  updateState(partial: Partial<RouterState>) {
+    this.subject$.next({
+      ...this.subject$.getValue(),
+      ...partial,
+    });
+  }
 }
-
-export const routerView$ = routerState$.pipe(
-  map(state => state.view),
-  distinctUntilChanged()
-);
-
-export const routerChangeNum$ = routerState$.pipe(
-  map(state => state.changeNum),
-  distinctUntilChanged()
-);
-
-export const routerPatchNum$ = routerState$.pipe(
-  map(state => state.patchNum),
-  distinctUntilChanged()
-);
diff --git a/polygerrit-ui/app/services/scheduler/fake-scheduler.ts b/polygerrit-ui/app/services/scheduler/fake-scheduler.ts
new file mode 100644
index 0000000..d4df3ce
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/fake-scheduler.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Scheduler, Task} from './scheduler';
+
+type FakeTask = (error?: unknown) => Promise<void>;
+export class FakeScheduler<T> implements Scheduler<T> {
+  readonly scheduled: Array<FakeTask> = [];
+
+  schedule(task: Task<T>) {
+    return new Promise<T>((resolve, reject) => {
+      this.scheduled.push(async (error?: unknown) => {
+        if (error) {
+          reject(error);
+        } else {
+          try {
+            resolve(await task());
+          } catch (e: unknown) {
+            reject(e);
+          }
+        }
+      });
+    });
+  }
+
+  async resolve(): Promise<void> {
+    if (this.scheduled.length === 0) return;
+    const fakeTask = this.scheduled.shift() as FakeTask;
+    await fakeTask();
+  }
+
+  async reject(error: unknown): Promise<void> {
+    if (this.scheduled.length === 0) return;
+    const fakeTask = this.scheduled.shift() as FakeTask;
+    await fakeTask(error);
+  }
+}
diff --git a/polygerrit-ui/app/services/scheduler/fake-scheduler_test.ts b/polygerrit-ui/app/services/scheduler/fake-scheduler_test.ts
new file mode 100644
index 0000000..6a0e56d
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/fake-scheduler_test.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {assertFails} from '../../test/test-utils.js';
+import {FakeScheduler} from './fake-scheduler';
+
+suite('fake scheduler', () => {
+  let scheduler: FakeScheduler<number>;
+  setup(() => {
+    scheduler = new FakeScheduler<number>();
+  });
+  test('schedules tasks', () => {
+    scheduler.schedule(async () => 1);
+    assert.equal(scheduler.scheduled.length, 1);
+  });
+
+  test('resolves tasks', async () => {
+    const promise = scheduler.schedule(async () => 1);
+    await scheduler.resolve();
+    const val = await promise;
+    assert.equal(val, 1);
+  });
+
+  test('rejects tasks', async () => {
+    const promise = scheduler.schedule(async () => 1);
+    assertFails(promise);
+    await scheduler.reject(new Error('Fake Error'));
+  });
+
+  test('propagates errors', async () => {
+    const error = new Error('This is an error');
+    const promise = scheduler.schedule(async () => {
+      throw error;
+    });
+    assertFails(promise, error);
+    await scheduler.resolve();
+    await promise.catch((reason: Error) => {
+      assert.equal(reason, error);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler.ts b/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler.ts
new file mode 100644
index 0000000..1febcb6
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Scheduler, Task} from './scheduler';
+
+export class MaxInFlightScheduler<T> implements Scheduler<T> {
+  private inflight = 0;
+
+  private waiting: Array<Task<void>> = [];
+
+  constructor(
+    private readonly base: Scheduler<T>,
+    private maxInflight: number = 10
+  ) {}
+
+  async schedule(task: Task<T>): Promise<T> {
+    return new Promise<T>((resolve, reject) => {
+      this.waiting.push(async () => {
+        try {
+          const result = await this.base.schedule(task);
+          resolve(result);
+        } catch (e: unknown) {
+          reject(e);
+        }
+      });
+      this.next();
+    });
+  }
+
+  private next() {
+    if (this.inflight >= this.maxInflight) return;
+    if (this.waiting.length === 0) return;
+    const task = this.waiting.shift() as Task<void>;
+    ++this.inflight;
+    task().finally(() => {
+      --this.inflight;
+      this.next();
+    });
+  }
+}
diff --git a/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler_test.ts b/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler_test.ts
new file mode 100644
index 0000000..9e76821
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler_test.ts
@@ -0,0 +1,96 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup-karma.js';
+import {assertFails} from '../../test/test-utils.js';
+import {Scheduler} from './scheduler';
+import {MaxInFlightScheduler} from './max-in-flight-scheduler';
+import {FakeScheduler} from './fake-scheduler';
+
+suite('max-in-flight scheduler', () => {
+  let fakeScheduler: FakeScheduler<number>;
+  let scheduler: Scheduler<number>;
+  setup(() => {
+    fakeScheduler = new FakeScheduler<number>();
+    scheduler = new MaxInFlightScheduler<number>(fakeScheduler, 2);
+  });
+
+  test('executes tasks', async () => {
+    const promise = scheduler.schedule(async () => 1);
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    fakeScheduler.resolve();
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    const val = await promise;
+    assert.equal(val, 1);
+  });
+
+  test('propagates errors', async () => {
+    const error = new Error('This is an error');
+    const promise = scheduler.schedule(async () => {
+      throw error;
+    });
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    assertFails(promise, error);
+    fakeScheduler.resolve();
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    await promise.catch((reason: Error) => {
+      assert.equal(reason, error);
+    });
+  });
+
+  test('propagates subscheduler errors', async () => {
+    const error = new Error('This is an error');
+    const promise = scheduler.schedule(async () => 1);
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    assertFails(promise, error);
+    fakeScheduler.reject(error);
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    await promise.catch((reason: Error) => {
+      assert.equal(reason, error);
+    });
+  });
+
+  test('allows up to 2 in flight', async () => {
+    for (let i = 0; i < 3; ++i) {
+      scheduler.schedule(async () => i);
+    }
+    assert.equal(fakeScheduler.scheduled.length, 2);
+  });
+
+  test('resumes when promise resolves', async () => {
+    for (let i = 0; i < 3; ++i) {
+      scheduler.schedule(async () => i);
+    }
+    assert.equal(fakeScheduler.scheduled.length, 2);
+    fakeScheduler.resolve();
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    await flush();
+    assert.equal(fakeScheduler.scheduled.length, 2);
+  });
+
+  test('resumes when promise fails', async () => {
+    for (let i = 0; i < 3; ++i) {
+      scheduler.schedule(async () => i);
+    }
+    assert.equal(fakeScheduler.scheduled.length, 2);
+    fakeScheduler.reject(new Error('Fake Error'));
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    await flush();
+    assert.equal(fakeScheduler.scheduled.length, 2);
+  });
+
+  test('eventually resumes all', async () => {
+    const promises = [];
+    for (let i = 0; i < 3; ++i) {
+      promises.push(scheduler.schedule(async () => i));
+    }
+    for (let i = 0; i < 3; ++i) {
+      fakeScheduler.resolve();
+      await flush();
+    }
+    const res = await Promise.all(promises);
+    assert.deepEqual(res, [0, 1, 2]);
+  });
+});
diff --git a/polygerrit-ui/app/services/scheduler/retry-scheduler.ts b/polygerrit-ui/app/services/scheduler/retry-scheduler.ts
new file mode 100644
index 0000000..04ced2f
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/retry-scheduler.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Scheduler, Task} from './scheduler';
+
+export class RetryError<T> extends Error {
+  constructor(readonly payload: T, message = 'Retry Error') {
+    super(message);
+  }
+}
+
+function untilTimeout(ms: number) {
+  return new Promise(resolve => window.setTimeout(resolve, ms));
+}
+
+export class RetryScheduler<T> implements Scheduler<T> {
+  constructor(
+    private readonly base: Scheduler<T>,
+    private maxRetry: number,
+    private backoffIntervalMs: number,
+    private backoffFactor: number = 1.618
+  ) {}
+
+  async schedule(task: Task<T>): Promise<T> {
+    let tries = 0;
+    let timeout = this.backoffIntervalMs;
+
+    const worker: Task<T> = async () => {
+      try {
+        return await this.base.schedule(task);
+      } catch (e: unknown) {
+        if (e instanceof RetryError && tries++ < this.maxRetry) {
+          await untilTimeout(timeout);
+          timeout = timeout * this.backoffFactor;
+          return await worker();
+        } else {
+          throw e;
+        }
+      }
+    };
+    return worker();
+  }
+}
diff --git a/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts b/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
new file mode 100644
index 0000000..988b167
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
@@ -0,0 +1,122 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup-karma.js';
+import {assertFails} from '../../test/test-utils.js';
+import {Scheduler} from './scheduler';
+import {RetryScheduler, RetryError} from './retry-scheduler';
+import {FakeScheduler} from './fake-scheduler';
+import {SinonFakeTimers} from 'sinon';
+
+suite('retry scheduler', () => {
+  let clock: SinonFakeTimers;
+  let fakeScheduler: FakeScheduler<number>;
+  let scheduler: Scheduler<number>;
+  setup(() => {
+    clock = sinon.useFakeTimers();
+    fakeScheduler = new FakeScheduler<number>();
+    scheduler = new RetryScheduler<number>(fakeScheduler, 3, 50, 1);
+  });
+
+  async function waitForRetry(ms: number) {
+    // Flush the promise so that we can reach untilTimeout
+    await flush();
+    // Advance the clock.
+    clock.tick(ms);
+    // Flush the promise that waits for the clock.
+    await flush();
+  }
+
+  test('executes tasks', async () => {
+    const promise = scheduler.schedule(async () => 1);
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    fakeScheduler.resolve();
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    const val = await promise;
+    assert.equal(val, 1);
+  });
+
+  test('propagates task errors', async () => {
+    const error = new Error('This is an error');
+    const promise = scheduler.schedule(async () => {
+      throw error;
+    });
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    assertFails(promise, error);
+    fakeScheduler.resolve();
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    await promise.catch((reason: Error) => {
+      assert.equal(reason, error);
+    });
+  });
+
+  test('propagates subscheduler errors', async () => {
+    const error = new Error('This is an error');
+    const promise = scheduler.schedule(async () => 1);
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    assertFails(promise, error);
+    fakeScheduler.reject(error);
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    await promise.catch((reason: Error) => {
+      assert.equal(reason, error);
+    });
+  });
+
+  test('retries on retryable error', async () => {
+    let retries = 1;
+    const promise = scheduler.schedule(async () => {
+      if (retries-- > 0) throw new RetryError('Retrying');
+      return 1;
+    });
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    fakeScheduler.resolve();
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    await waitForRetry(50);
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    fakeScheduler.resolve();
+    const val = await promise;
+    assert.equal(val, 1);
+  });
+
+  test('retries up to 3 times', async () => {
+    let retries = 3;
+    const promise = scheduler.schedule(async () => {
+      if (retries-- > 0) throw new RetryError('Retrying');
+      return 1;
+    });
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    for (let i = 0; i < 3; i++) {
+      fakeScheduler.resolve();
+      assert.equal(fakeScheduler.scheduled.length, 0);
+      await waitForRetry(50);
+      assert.equal(fakeScheduler.scheduled.length, 1);
+    }
+    fakeScheduler.resolve();
+    const val = await promise;
+    assert.equal(val, 1);
+  });
+
+  test('fails after more than 3 times', async () => {
+    let retries = 4;
+    const promise = scheduler.schedule(async () => {
+      if (retries-- > 0) throw new RetryError(retries, `Retrying ${retries}`);
+      return 1;
+    });
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    for (let i = 0; i < 3; i++) {
+      fakeScheduler.resolve();
+      assert.equal(fakeScheduler.scheduled.length, 0);
+      await waitForRetry(50);
+      assert.equal(fakeScheduler.scheduled.length, 1);
+    }
+    fakeScheduler.resolve();
+    assertFails(promise);
+    // The error we get back should be the last error.
+    await promise.catch((reason: RetryError<number>) => {
+      assert.equal(reason.payload, 0);
+      assert.equal(reason.message, 'Retrying 0');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/scheduler/scheduler.ts b/polygerrit-ui/app/services/scheduler/scheduler.ts
new file mode 100644
index 0000000..b834ab3
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/scheduler.ts
@@ -0,0 +1,14 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+export type Task<T> = () => Promise<T>;
+export interface Scheduler<T> {
+  schedule(task: Task<T>): Promise<T>;
+}
+export class BaseScheduler<T> implements Scheduler<T> {
+  schedule(task: Task<T>) {
+    return task();
+  }
+}
diff --git a/polygerrit-ui/app/services/scheduler/scheduler_test.ts b/polygerrit-ui/app/services/scheduler/scheduler_test.ts
new file mode 100644
index 0000000..3edbdab
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/scheduler_test.ts
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {assertFails} from '../../test/test-utils.js';
+import {BaseScheduler} from './scheduler';
+
+suite('naive scheduler', () => {
+  let scheduler: BaseScheduler<number>;
+  setup(() => {
+    scheduler = new BaseScheduler<number>();
+  });
+
+  test('executes tasks', async () => {
+    const promise = scheduler.schedule(async () => 1);
+    const val = await promise;
+    assert.equal(val, 1);
+  });
+
+  test('propagates errors', async () => {
+    const error = new Error('This is an error');
+    const promise = scheduler.schedule(async () => {
+      throw error;
+    });
+    assertFails(promise, error);
+    await promise.catch((reason: Error) => {
+      assert.equal(reason, error);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
index 3c9e058..ed68f15 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -52,7 +52,6 @@
   OPEN_CHANGE = 'OPEN_CHANGE',
   NEXT_PAGE = 'NEXT_PAGE',
   PREV_PAGE = 'PREV_PAGE',
-  TOGGLE_CHANGE_REVIEWED = 'TOGGLE_CHANGE_REVIEWED',
   TOGGLE_CHANGE_STAR = 'TOGGLE_CHANGE_STAR',
   REFRESH_CHANGE_LIST = 'REFRESH_CHANGE_LIST',
   OPEN_SUBMIT_DIALOG = 'OPEN_SUBMIT_DIALOG',
@@ -180,13 +179,13 @@
   Shortcut.CURSOR_NEXT_CHANGE,
   ShortcutSection.ACTIONS,
   'Select next change',
-  {key: 'j'}
+  {key: 'j', allowRepeat: true}
 );
 describe(
   Shortcut.CURSOR_PREV_CHANGE,
   ShortcutSection.ACTIONS,
   'Select previous change',
-  {key: 'k'}
+  {key: 'k', allowRepeat: true}
 );
 describe(
   Shortcut.OPEN_CHANGE,
@@ -239,12 +238,6 @@
   {key: 'R'}
 );
 describe(
-  Shortcut.TOGGLE_CHANGE_REVIEWED,
-  ShortcutSection.ACTIONS,
-  'Mark/unmark change as reviewed',
-  {key: 'r'}
-);
-describe(
   Shortcut.TOGGLE_FILE_REVIEWED,
   ShortcutSection.ACTIONS,
   'Toggle review flag on selected file',
@@ -316,15 +309,15 @@
   Shortcut.NEXT_LINE,
   ShortcutSection.DIFFS,
   'Go to next line',
-  {key: 'j'},
-  {key: Key.DOWN}
+  {key: 'j', allowRepeat: true},
+  {key: Key.DOWN, allowRepeat: true}
 );
 describe(
   Shortcut.PREV_LINE,
   ShortcutSection.DIFFS,
   'Go to previous line',
-  {key: 'k'},
-  {key: Key.UP}
+  {key: 'k', allowRepeat: true},
+  {key: Key.UP, allowRepeat: true}
 );
 describe(
   Shortcut.VISIBLE_LINE,
@@ -480,15 +473,15 @@
   Shortcut.CURSOR_NEXT_FILE,
   ShortcutSection.FILE_LIST,
   'Select next file',
-  {key: 'j'},
-  {key: Key.DOWN}
+  {key: 'j', allowRepeat: true},
+  {key: Key.DOWN, allowRepeat: true}
 );
 describe(
   Shortcut.CURSOR_PREV_FILE,
   ShortcutSection.FILE_LIST,
   'Select previous file',
-  {key: 'k'},
-  {key: Key.UP}
+  {key: 'k', allowRepeat: true},
+  {key: Key.UP, allowRepeat: true}
 );
 describe(
   Shortcut.OPEN_FILE,
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index a26fa08..de28a60 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -14,13 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {Subscription} from 'rxjs';
+import {map, distinctUntilChanged} from 'rxjs/operators';
 import {
   config,
   Shortcut,
   ShortcutHelpItem,
   ShortcutSection,
 } from './shortcuts-config';
-import {disableShortcuts$} from '../user/user-model';
 import {
   ComboKey,
   eventMatchesShortcut,
@@ -31,6 +32,8 @@
   shouldSuppress,
 } from '../../utils/dom-util';
 import {ReportingService} from '../gr-reporting/gr-reporting';
+import {Finalizable} from '../registry';
+import {UserModel} from '../../models/user/user-model';
 
 export type SectionView = Array<{binding: string[][]; text: string}>;
 
@@ -62,13 +65,13 @@
 /**
  * Shortcuts service, holds all hosts, bindings and listeners.
  */
-export class ShortcutsService {
+export class ShortcutsService implements Finalizable {
   /**
    * Keeps track of the components that are currently active such that we can
    * show a shortcut help dialog that only shows the shortcuts that are
    * currently relevant.
    */
-  private readonly activeShortcuts = new Map<HTMLElement, Shortcut[]>();
+  private readonly activeShortcuts = new Set<Shortcut>();
 
   /**
    * Keeps track of cleanup callbacks (which remove keyboard listeners) that
@@ -92,19 +95,41 @@
   /** Keeps track of the corresponding user preference. */
   private shortcutsDisabled = false;
 
-  constructor(readonly reporting?: ReportingService) {
+  private readonly keydownListener: (e: KeyboardEvent) => void;
+
+  private readonly subscriptions: Subscription[] = [];
+
+  constructor(
+    readonly userModel: UserModel,
+    readonly reporting?: ReportingService
+  ) {
     for (const section of config.keys()) {
       const items = config.get(section) ?? [];
       for (const item of items) {
         this.bindings.set(item.shortcut, item.bindings);
       }
     }
-    disableShortcuts$.subscribe(x => (this.shortcutsDisabled = x));
-    document.addEventListener('keydown', (e: KeyboardEvent) => {
+    this.subscriptions.push(
+      this.userModel.preferences$
+        .pipe(
+          map(preferences => preferences?.disable_keyboard_shortcuts ?? false),
+          distinctUntilChanged()
+        )
+        .subscribe(x => (this.shortcutsDisabled = x))
+    );
+    this.keydownListener = (e: KeyboardEvent) => {
       if (!isComboKey(e.key)) return;
-      if (this.shouldSuppress(e)) return;
+      if (this.shortcutsDisabled || shouldSuppress(e)) return;
       this.comboKeyLastPressed = {key: e.key, timestampMs: Date.now()};
-    });
+    };
+    document.addEventListener('keydown', this.keydownListener);
+  }
+
+  finalize() {
+    document.removeEventListener('keydown', this.keydownListener);
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
   }
 
   public _testOnly_isEmpty() {
@@ -134,29 +159,36 @@
   addShortcut(
     element: HTMLElement,
     shortcut: Binding,
-    listener: (e: KeyboardEvent) => void
+    listener: (e: KeyboardEvent) => void,
+    options: {
+      shouldSuppress: boolean;
+    } = {
+      shouldSuppress: true,
+    }
   ) {
     const wrappedListener = (e: KeyboardEvent) => {
-      if (e.repeat) return;
+      if (e.repeat && !shortcut.allowRepeat) return;
       if (!eventMatchesShortcut(e, shortcut)) return;
       if (shortcut.combo) {
         if (!this.isInSpecificComboKeyMode(shortcut.combo)) return;
       } else {
         if (this.isInComboKeyMode()) return;
       }
-      if (this.shouldSuppress(e)) return;
+      if (options.shouldSuppress && shouldSuppress(e)) return;
+      // `shortcutsDisabled` refers to disabling global shortcuts like 'n'. If
+      // `shouldSuppress` is false (e.g.for Ctrl - ENTER), then don't disable
+      // the shortcut.
+      if (options.shouldSuppress && this.shortcutsDisabled) return;
       e.preventDefault();
       e.stopPropagation();
+      this.reportTriggered(e);
       listener(e);
     };
     element.addEventListener('keydown', wrappedListener);
     return () => element.removeEventListener('keydown', wrappedListener);
   }
 
-  shouldSuppress(e: KeyboardEvent) {
-    if (this.shortcutsDisabled) return true;
-    if (shouldSuppress(e)) return true;
-
+  private reportTriggered(e: KeyboardEvent) {
     // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
     let key = `${e.key}:${e.type}`;
     if (this.isInSpecificComboKeyMode(ComboKey.G)) key = 'g+' + key;
@@ -170,7 +202,6 @@
       from = e.currentTarget.tagName;
     }
     this.reporting?.reportInteraction('shortcut-triggered', {key, from});
-    return false;
   }
 
   createTitle(shortcutName: Shortcut, section: ShortcutSection) {
@@ -183,28 +214,47 @@
     return this.bindings.get(shortcut);
   }
 
+  /**
+   * Looks up bindings for the given shortcut and calls addShortcut() for each
+   * of them. Also adds the shortcut to `activeShortcuts` and thus to the
+   * help page about active shortcuts. Returns a cleanup function for removing
+   * the bindings and the help page entry.
+   */
+  addShortcutListener(
+    shortcut: Shortcut,
+    listener: (e: KeyboardEvent) => void
+  ) {
+    const cleanups: (() => void)[] = [];
+    this.activeShortcuts.add(shortcut);
+    cleanups.push(() => {
+      this.activeShortcuts.delete(shortcut);
+      this.notifyViewListeners();
+    });
+    const bindings = this.getBindingsForShortcut(shortcut);
+    for (const binding of bindings ?? []) {
+      if (binding.docOnly) continue;
+      cleanups.push(this.addShortcut(document.body, binding, listener));
+    }
+    this.notifyViewListeners();
+    return () => {
+      for (const cleanup of cleanups ?? []) cleanup();
+    };
+  }
+
+  /**
+   * Being called by the Polymer specific KeyboardShortcutMixin.
+   */
   attachHost(host: HTMLElement, shortcuts: ShortcutListener[]) {
-    this.activeShortcuts.set(
-      host,
-      shortcuts.map(s => s.shortcut)
-    );
     const cleanups: (() => void)[] = [];
     for (const s of shortcuts) {
-      const bindings = this.getBindingsForShortcut(s.shortcut);
-      for (const binding of bindings ?? []) {
-        if (binding.docOnly) continue;
-        cleanups.push(this.addShortcut(document.body, binding, s.listener));
-      }
+      cleanups.push(this.addShortcutListener(s.shortcut, s.listener));
     }
     this.cleanupsPerHost.set(host, cleanups);
-    this.notifyViewListeners();
   }
 
   detachHost(host: HTMLElement) {
-    this.activeShortcuts.delete(host);
     const cleanups = this.cleanupsPerHost.get(host);
     for (const cleanup of cleanups ?? []) cleanup();
-    this.notifyViewListeners();
     return true;
   }
 
@@ -233,20 +283,13 @@
   }
 
   activeShortcutsBySection() {
-    const activeShortcuts = new Set<Shortcut>();
-    for (const shortcuts of this.activeShortcuts.values()) {
-      for (const shortcut of shortcuts) {
-        activeShortcuts.add(shortcut);
-      }
-    }
-
     const activeShortcutsBySection = new Map<
       ShortcutSection,
       ShortcutHelpItem[]
     >();
     config.forEach((shortcutList, section) => {
       shortcutList.forEach(shortcutHelp => {
-        if (activeShortcuts.has(shortcutHelp.shortcut)) {
+        if (this.activeShortcuts.has(shortcutHelp.shortcut)) {
           if (!activeShortcutsBySection.has(section)) {
             activeShortcutsBySection.set(section, []);
           }
@@ -312,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/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index 05c4f53..7dd3f75 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -21,80 +21,18 @@
   ShortcutsService,
 } from '../../services/shortcuts/shortcuts-service';
 import {Shortcut, ShortcutSection} from './shortcuts-config';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {SinonFakeTimers} from 'sinon';
 import {Key, Modifier} from '../../utils/dom-util';
-
-async function keyEventOn(
-  el: HTMLElement,
-  callback: (e: KeyboardEvent) => void,
-  keyCode = 75,
-  key = 'k'
-): Promise<KeyboardEvent> {
-  let resolve: (e: KeyboardEvent) => void;
-  const promise = new Promise<KeyboardEvent>(r => (resolve = r));
-  el.addEventListener('keydown', (e: KeyboardEvent) => {
-    callback(e);
-    resolve(e);
-  });
-  MockInteractions.keyDownOn(el, keyCode, null, key);
-  return await promise;
-}
+import {getAppContext} from '../app-context';
 
 suite('shortcuts-service tests', () => {
   let service: ShortcutsService;
 
   setup(() => {
-    service = new ShortcutsService();
-  });
-
-  suite('shouldSuppress', () => {
-    test('do not suppress shortcut event from <div>', async () => {
-      await keyEventOn(document.createElement('div'), e => {
-        assert.isFalse(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress shortcut event from <input>', async () => {
-      await keyEventOn(document.createElement('input'), e => {
-        assert.isTrue(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress shortcut event from <textarea>', async () => {
-      await keyEventOn(document.createElement('textarea'), e => {
-        assert.isTrue(service.shouldSuppress(e));
-      });
-    });
-
-    test('do not suppress shortcut event from checkbox <input>', async () => {
-      const inputEl = document.createElement('input');
-      inputEl.setAttribute('type', 'checkbox');
-      await keyEventOn(inputEl, e => {
-        assert.isFalse(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress shortcut event from children of <gr-overlay>', async () => {
-      const overlay = document.createElement('gr-overlay');
-      const div = document.createElement('div');
-      overlay.appendChild(div);
-      await keyEventOn(div, e => {
-        assert.isTrue(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress "enter" shortcut event from <a>', async () => {
-      await keyEventOn(document.createElement('a'), e => {
-        assert.isFalse(service.shouldSuppress(e));
-      });
-      await keyEventOn(
-        document.createElement('a'),
-        e => assert.isTrue(service.shouldSuppress(e)),
-        13,
-        'enter'
-      );
-    });
+    service = new ShortcutsService(
+      getAppContext().userModel,
+      getAppContext().reportingService
+    );
   });
 
   test('getShortcut', () => {
@@ -213,7 +151,10 @@
           {
             shortcut: Shortcut.NEXT_LINE,
             text: 'Go to next line',
-            bindings: [{key: 'j'}, {key: 'ArrowDown'}],
+            bindings: [
+              {allowRepeat: true, key: 'j'},
+              {allowRepeat: true, key: 'ArrowDown'},
+            ],
           },
         ],
         [ShortcutSection.NAVIGATION]: [
@@ -234,7 +175,10 @@
           {
             shortcut: Shortcut.NEXT_LINE,
             text: 'Go to next line',
-            bindings: [{key: 'j'}, {key: 'ArrowDown'}],
+            bindings: [
+              {allowRepeat: true, key: 'j'},
+              {allowRepeat: true, key: 'ArrowDown'},
+            ],
           },
         ],
         [ShortcutSection.EVERYWHERE]: [
diff --git a/polygerrit-ui/app/services/storage/gr-storage.ts b/polygerrit-ui/app/services/storage/gr-storage.ts
index 08a3387..3319906 100644
--- a/polygerrit-ui/app/services/storage/gr-storage.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage.ts
@@ -15,7 +15,8 @@
  * limitations under the License.
  */
 
-import {CommentRange, PatchSetNum} from '../../types/common';
+import {CommentRange, NumericChangeId, PatchSetNum} from '../../types/common';
+import {Finalizable} from '../registry';
 
 export interface StorageLocation {
   changeNum: number;
@@ -30,7 +31,7 @@
   updated: number;
 }
 
-export interface StorageService {
+export interface StorageService extends Finalizable {
   getDraftComment(location: StorageLocation): StorageObject | null;
 
   setDraftComment(location: StorageLocation, message: string): void;
@@ -41,9 +42,7 @@
 
   setEditableContentItem(key: string, message: string): void;
 
-  getRespectfulTipVisibility(): StorageObject | null;
-
-  setRespectfulTipVisibility(delayDays?: number): void;
-
   eraseEditableContentItem(key: string): void;
+
+  eraseEditableContentItemsForChangeEdit(changeNum?: NumericChangeId): void;
 }
diff --git a/polygerrit-ui/app/services/storage/gr-storage_impl.ts b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
index 0c0d151..95bb4a5 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_impl.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
@@ -16,6 +16,8 @@
  */
 
 import {StorageLocation, StorageObject, StorageService} from './gr-storage';
+import {Finalizable} from '../registry';
+import {NumericChangeId} from '../../types/common';
 
 export const DURATION_DAY = 24 * 60 * 60 * 1000;
 
@@ -23,17 +25,18 @@
 const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
 
 const CLEANUP_PREFIXES_MAX_AGE_MAP = new Map<string, number>();
-CLEANUP_PREFIXES_MAX_AGE_MAP.set('respectfultip', 14 * DURATION_DAY);
 CLEANUP_PREFIXES_MAX_AGE_MAP.set('draft', DURATION_DAY);
 CLEANUP_PREFIXES_MAX_AGE_MAP.set('editablecontent', DURATION_DAY);
 
-export class GrStorageService implements StorageService {
+export class GrStorageService implements StorageService, Finalizable {
   private lastCleanup = 0;
 
   private readonly storage = window.localStorage;
 
   private exceededQuota = false;
 
+  finalize() {}
+
   getDraftComment(location: StorageLocation): StorageObject | null {
     this.cleanupItems();
     return this.getObject(this.getDraftKey(location));
@@ -61,22 +64,30 @@
     });
   }
 
-  getRespectfulTipVisibility(): StorageObject | null {
-    this.cleanupItems();
-    return this.getObject('respectfultip:visibility');
-  }
-
-  setRespectfulTipVisibility(delayDays = 0) {
-    this.cleanupItems();
-    this.setObject('respectfultip:visibility', {
-      updated: Date.now() + delayDays * DURATION_DAY,
-    });
-  }
-
   eraseEditableContentItem(key: string) {
     this.storage.removeItem(this.getEditableContentKey(key));
   }
 
+  /**
+   * Deletes all keys for cached edits.
+   *
+   * @param changeNum
+   */
+  eraseEditableContentItemsForChangeEdit(changeNum?: NumericChangeId) {
+    if (!changeNum) return;
+
+    // Fetch all keys and then match them up to the keys we want.
+    for (const key of Object.keys(this.storage)) {
+      // Only delete the value that starts with editablecontent:c${changeNum}_ps
+      // to prevent deleting unrelated keys.
+      if (key.startsWith(`editablecontent:c${changeNum}_ps`)) {
+        // We have to remove editablecontent: from the string as it is
+        // automatically added to the string within the storage.
+        this.eraseEditableContentItem(key.replace('editablecontent:', ''));
+      }
+    }
+  }
+
   private getDraftKey(location: StorageLocation): string {
     const range = location.range
       ? `${location.range.start_line}-${location.range.start_character}` +
@@ -136,16 +147,17 @@
     }
     try {
       this.storage.setItem(key, JSON.stringify(obj));
-    } catch (exc) {
-      // Catch for QuotaExceededError and disable writes on local storage the
-      // first time that it occurs.
-      if (exc.code === 22) {
-        this.exceededQuota = true;
-        console.warn('Local storage quota exceeded: disabling');
-        return;
-      } else {
-        throw exc;
+    } catch (exc: unknown) {
+      if (exc instanceof DOMException) {
+        // Catch for QuotaExceededError and disable writes on local storage the
+        // first time that it occurs.
+        if (exc.code === 22) {
+          this.exceededQuota = true;
+          console.warn('Local storage quota exceeded: disabling');
+          return;
+        }
       }
+      throw exc;
     }
   }
 }
diff --git a/polygerrit-ui/app/services/storage/gr-storage_mock.ts b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
index 399ffe4..97879e0 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_mock.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
@@ -15,8 +15,8 @@
  * limitations under the License.
  */
 
+import {NumericChangeId} from '../../types/common';
 import {StorageLocation, StorageObject, StorageService} from './gr-storage';
-import {DURATION_DAY} from './gr-storage_impl';
 
 const storage = new Map<string, StorageObject>();
 
@@ -45,6 +45,7 @@
 }
 
 export const grStorageMock: StorageService = {
+  finalize(): void {},
   getDraftComment(location: StorageLocation): StorageObject | null {
     return storage.get(getDraftKey(location)) ?? null;
   },
@@ -70,17 +71,15 @@
     });
   },
 
-  getRespectfulTipVisibility(): StorageObject | null {
-    return storage.get('respectfultip:visibility') ?? null;
-  },
-
-  setRespectfulTipVisibility(delayDays = 0): void {
-    storage.set('respectfultip:visibility', {
-      updated: Date.now() + delayDays * DURATION_DAY,
-    });
-  },
-
   eraseEditableContentItem(key: string): void {
     storage.delete(getEditableContentKey(key));
   },
+
+  eraseEditableContentItemsForChangeEdit(changeNum?: NumericChangeId): void {
+    for (const key of Array.from(storage.keys())) {
+      if (key.startsWith(`editablecontent:c${changeNum}_ps`)) {
+        this.eraseEditableContentItem(key.replace('editablecontent:', ''));
+      }
+    }
+  },
 };
diff --git a/polygerrit-ui/app/services/storage/gr-storage_test.js b/polygerrit-ui/app/services/storage/gr-storage_test.ts
similarity index 63%
rename from polygerrit-ui/app/services/storage/gr-storage_test.js
rename to polygerrit-ui/app/services/storage/gr-storage_test.ts
index 6cbfacf..a019218 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_test.js
+++ b/polygerrit-ui/app/services/storage/gr-storage_test.ts
@@ -15,32 +15,41 @@
  * limitations under the License.
  */
 
-import '../../test/common-test-setup-karma.js';
-import {GrStorageService} from './gr-storage_impl.js';
+import '../../test/common-test-setup-karma';
+import {PatchSetNum} from '../../types/common';
+import {StorageLocation} from './gr-storage';
+import {GrStorageService} from './gr-storage_impl';
 
 suite('gr-storage tests', () => {
-  let grStorage;
+  // We have to type as any because we access private methods
+  // for testing
+  let grStorage: any;
 
-  function mockStorage(opt_quotaExceeded) {
+  function mockStorage(opt_quotaExceeded: boolean) {
     return {
-      getItem(key) { return this[key]; },
-      removeItem(key) { delete this[key]; },
-      setItem(key, value) {
-        // eslint-disable-next-line no-throw-literal
-        if (opt_quotaExceeded) { throw {code: 22}; /* Quota exceeded */ }
-        this[key] = value;
+      getItem(key: string) {
+        return (this as any)[key];
+      },
+      removeItem(key: string) {
+        delete (this as any)[key];
+      },
+      setItem(key: string, value: string) {
+        if (opt_quotaExceeded) {
+          throw new DOMException('error', 'QuotaExceededError');
+        }
+        (this as any)[key] = value;
       },
     };
   }
 
   setup(() => {
     grStorage = new GrStorageService();
-    grStorage.storage = mockStorage();
+    grStorage.storage = mockStorage(false);
   });
 
   test('storing, retrieving and erasing drafts', () => {
     const changeNum = 1234;
-    const patchNum = 5;
+    const patchNum = 5 as PatchSetNum;
     const path = 'my_source_file.js';
     const line = 123;
     const location = {
@@ -61,8 +70,10 @@
     // Setting the draft stores it under the expected key.
     grStorage.setDraftComment(location, 'my comment');
     assert.isOk(grStorage.storage.getItem(key));
-    assert.equal(JSON.parse(grStorage.storage.getItem(key)).message,
-        'my comment');
+    assert.equal(
+      JSON.parse(grStorage.storage.getItem(key)).message,
+      'my comment'
+    );
     assert.isOk(JSON.parse(grStorage.storage.getItem(key)).updated);
 
     // Erasing the draft removes the key.
@@ -90,10 +101,13 @@
     const cleanupSpy = sinon.spy(grStorage, 'cleanupItems');
 
     // Create a message with a timestamp that is a second behind the max age.
-    grStorage.storage.setItem(key, JSON.stringify({
-      message: 'old message',
-      updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
-    }));
+    grStorage.storage.setItem(
+      key,
+      JSON.stringify({
+        message: 'old message',
+        updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
+      })
+    );
 
     // Getting the draft should cause it to be removed.
     const draft = grStorage.getDraftComment(location);
@@ -105,10 +119,10 @@
 
   test('getDraftKey', () => {
     const changeNum = 1234;
-    const patchNum = 5;
+    const patchNum = 5 as PatchSetNum;
     const path = 'my_source_file.js';
     const line = 123;
-    const location = {
+    const location: StorageLocation = {
       changeNum,
       patchNum,
       path,
@@ -172,5 +186,46 @@
     grStorage.eraseEditableContentItem(key);
     assert.isNotOk(grStorage.storage.getItem(computedKey));
   });
-});
 
+  test('editable content items eraseEditableContentItemsForChangeEdit', () => {
+    grStorage.setEditableContentItem('testKey', 'my content');
+    grStorage.setEditableContentItem(
+      'c50_psedit_index.php',
+      'my content test 1'
+    );
+    grStorage.setEditableContentItem('c50_ps3_index.php', 'my content test 2');
+
+    const item = grStorage.storage.getItem(
+      'editablecontent:c50_psedit_index.php'
+    );
+    assert.isOk(item);
+    assert.equal(JSON.parse(item).message, 'my content test 1');
+    assert.isOk(JSON.parse(item).updated);
+
+    // We have to add getItem, removeItem and setItem to the array.
+    // Typically these functions don't get outputed in .storage,
+    // but we're mocking the storage so they are being outputed.
+    // This doesn't invalidate the test.
+    assert.deepEqual(Object.keys(grStorage.storage), [
+      'getItem',
+      'removeItem',
+      'setItem',
+      'editablecontent:testKey',
+      'editablecontent:c50_psedit_index.php',
+      'editablecontent:c50_ps3_index.php',
+    ]);
+
+    grStorage.eraseEditableContentItemsForChangeEdit(50);
+
+    // We have to add getItem, removeItem and setItem to the array.
+    // Typically these functions don't get outputed in .storage,
+    // but we're mocking the storage so they are being outputed.
+    // This doesn't invalidate the test.
+    assert.deepEqual(Object.keys(grStorage.storage), [
+      'getItem',
+      'removeItem',
+      'setItem',
+      'editablecontent:testKey',
+    ]);
+  });
+});
diff --git a/polygerrit-ui/app/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
deleted file mode 100644
index 72ce3e1..0000000
--- a/polygerrit-ui/app/services/user/user-model.ts
+++ /dev/null
@@ -1,67 +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 {AccountDetailInfo, PreferencesInfo} from '../../types/common';
-import {BehaviorSubject, Observable} from 'rxjs';
-import {map, distinctUntilChanged} from 'rxjs/operators';
-import {createDefaultPreferences} from '../../constants/constants';
-
-interface UserState {
-  /**
-   * Keeps being defined even when credentials have expired.
-   */
-  account?: AccountDetailInfo;
-  preferences: PreferencesInfo;
-}
-
-const initialState: UserState = {
-  preferences: createDefaultPreferences(),
-};
-
-const privateState$ = new BehaviorSubject(initialState);
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const userState$: Observable<UserState> = privateState$;
-
-export function updateAccount(account?: AccountDetailInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, account});
-}
-
-export function updatePreferences(preferences: PreferencesInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, preferences});
-}
-
-export const account$ = userState$.pipe(
-  map(userState => userState.account),
-  distinctUntilChanged()
-);
-
-export const preferences$ = userState$.pipe(
-  map(userState => userState.preferences),
-  distinctUntilChanged()
-);
-
-export const myTopMenuItems$ = preferences$.pipe(
-  map(preferences => preferences?.my ?? []),
-  distinctUntilChanged()
-);
-
-export const disableShortcuts$ = preferences$.pipe(
-  map(preferences => preferences?.disable_keyboard_shortcuts ?? false),
-  distinctUntilChanged()
-);
diff --git a/polygerrit-ui/app/services/user/user-service.ts b/polygerrit-ui/app/services/user/user-service.ts
deleted file mode 100644
index 125d20c..0000000
--- a/polygerrit-ui/app/services/user/user-service.ts
+++ /dev/null
@@ -1,55 +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 {
-  AccountDetailInfo,
-  PreferencesInfo,
-  PreferencesInput,
-} from '../../types/common';
-import {from, of} from 'rxjs';
-import {account$, updateAccount, updatePreferences} from './user-model';
-import {switchMap} from 'rxjs/operators';
-import {createDefaultPreferences} from '../../constants/constants';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-
-export class UserService {
-  constructor(readonly restApiService: RestApiService) {
-    from(this.restApiService.getAccount()).subscribe(
-      (account?: AccountDetailInfo) => {
-        updateAccount(account);
-      }
-    );
-    account$
-      .pipe(
-        switchMap(account => {
-          if (!account) return of(createDefaultPreferences());
-          return from(this.restApiService.getPreferences());
-        })
-      )
-      .subscribe((preferences?: PreferencesInfo) => {
-        updatePreferences(preferences ?? createDefaultPreferences());
-      });
-  }
-
-  updatePreferences(prefs: PreferencesInput) {
-    this.restApiService
-      .savePreferences(prefs)
-      .then((newPrefs: PreferencesInfo | undefined) => {
-        if (!newPrefs) return;
-        updatePreferences(newPrefs);
-      });
-  }
-}
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.ts b/polygerrit-ui/app/styles/dashboard-header-styles.ts
index 643a76a..669f81f 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.ts
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.ts
@@ -14,14 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {css} from 'lit';
 
-// 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 {};
-
 const $_documentContainer = document.createElement('template');
 
 export const dashboardHeaderStyles = css`
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index e0a7a28..e0ce1d7 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -14,191 +14,186 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {css} from 'lit';
 
-// 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 {};
+export const changeListStyles = css`
+  gr-change-list-item {
+    border-top: 1px solid var(--border-color);
+  }
+  gr-change-list-item[selected],
+  gr-change-list-item:focus {
+    background-color: var(--selection-background-color);
+  }
+  gr-change-list-item[highlight] {
+    background-color: var(--assignee-highlight-color);
+  }
+  gr-change-list-item[highlight][selected],
+  gr-change-list-item[highlight]:focus {
+    background-color: var(--assignee-highlight-selection-color);
+  }
+  .groupTitle td,
+  .cell {
+    vertical-align: middle;
+  }
+  .groupTitle td:not(.label):not(.endpoint),
+  .cell:not(.label):not(.endpoint) {
+    padding-right: 8px;
+  }
+  .groupTitle td {
+    color: var(--deemphasized-text-color);
+    text-align: left;
+  }
+  .groupHeader {
+    background-color: transparent;
+    font-size: var(--font-size-h3);
+    font-weight: var(--font-weight-h3);
+    line-height: var(--line-height-h3);
+  }
+  .groupContent {
+    background-color: var(--background-color-primary);
+    box-shadow: var(--elevation-level-1);
+  }
+  .groupHeader a {
+    color: var(--primary-text-color);
+    text-decoration: none;
+  }
+  .groupHeader a:hover {
+    text-decoration: underline;
+  }
+  .groupTitle td,
+  .cell {
+    padding: var(--spacing-s) 0;
+  }
+  .groupHeader .cell {
+    padding-top: var(--spacing-l);
+  }
+  .star {
+    padding: 0;
+  }
+  gr-change-star {
+    vertical-align: middle;
+  }
+  .owner {
+    --account-max-length: 100px;
+  }
+  .branch,
+  .star,
+  .label,
+  .number,
+  .owner,
+  .updated,
+  .submitted,
+  .waiting,
+  .size,
+  .status,
+  .repo {
+    white-space: nowrap;
+  }
+  .star {
+    vertical-align: middle;
+  }
+  .leftPadding {
+    width: var(--spacing-l);
+  }
+  .star {
+    width: 30px;
+  }
+  .reviewers div {
+    overflow: hidden;
+  }
+  .label,
+  .endpoint {
+    border-left: 1px solid var(--border-color);
+  }
+  .groupTitle td.label,
+  .label {
+    text-align: center;
+    width: 3rem;
+  }
+  .truncatedRepo {
+    display: none;
+  }
+  @media only screen and (max-width: 150em) {
+    .branch {
+      overflow: hidden;
+      max-width: 18rem;
+      text-overflow: ellipsis;
+    }
+    .truncatedRepo {
+      display: inline-block;
+    }
+    .fullRepo {
+      display: none;
+    }
+  }
+  @media only screen and (max-width: 100em) {
+    .branch {
+      max-width: 10rem;
+    }
+  }
+  @media only screen and (max-width: 50em) {
+    :host {
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h3);
+      font-weight: var(--font-weight-h3);
+      line-height: var(--line-height-h3);
+    }
+    gr-change-list-item {
+      flex-wrap: wrap;
+      justify-content: space-between;
+      padding: var(--spacing-xs) var(--spacing-m);
+    }
+    gr-change-list-item[selected],
+    gr-change-list-item:focus {
+      background-color: var(--view-background-color);
+      border: none;
+      border-top: 1px solid var(--border-color);
+    }
+    gr-change-list-item:hover {
+      background-color: var(--view-background-color);
+    }
+    .cell {
+      align-items: center;
+      display: flex;
+    }
+    .groupTitle,
+    .leftPadding,
+    .status,
+    .repo,
+    .branch,
+    .updated,
+    .submitted,
+    .waiting,
+    .label,
+    .groupHeader .star,
+    .noChanges .star {
+      display: none;
+    }
+    .groupHeader .cell,
+    .noChanges .cell {
+      padding-left: var(--spacing-m);
+    }
+    .subject {
+      margin-bottom: var(--spacing-xs);
+      width: calc(100% - 2em);
+    }
+    .owner,
+    .size {
+      max-width: none;
+    }
+    .noChanges .cell {
+      display: block;
+      height: auto;
+    }
+  }
+`;
 
 const $_documentContainer = document.createElement('template');
-
 $_documentContainer.innerHTML = `<dom-module id="gr-change-list-styles">
   <template>
     <style>
-      gr-change-list-item {
-        border-top: 1px solid var(--border-color);
-      }
-      gr-change-list-item[selected],
-      gr-change-list-item:focus {
-        background-color: var(--selection-background-color);
-      }
-      gr-change-list-item[highlight] {
-        background-color: var(--assignee-highlight-color);
-      }
-      gr-change-list-item[highlight][selected],
-      gr-change-list-item[highlight]:focus {
-        background-color: var(--assignee-highlight-selection-color);
-      }
-      .groupTitle td,
-      .cell {
-        vertical-align: middle;
-      }
-      .groupTitle td:not(.label):not(.endpoint),
-      .cell:not(.label):not(.endpoint) {
-        padding-right: 8px;
-      }
-      .groupTitle td {
-        color: var(--deemphasized-text-color);
-        text-align: left;
-      }
-      .groupHeader {
-        background-color: transparent;
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      .groupContent {
-        background-color: var(--background-color-primary);
-        box-shadow: var(--elevation-level-1);
-      }
-      .groupHeader a {
-        color: var(--primary-text-color);
-        text-decoration: none;
-      }
-      .groupHeader a:hover {
-        text-decoration: underline;
-      }
-      .groupTitle td,
-      .cell {
-        padding: var(--spacing-s) 0;
-      }
-      .groupHeader .cell {
-        padding-top: var(--spacing-l);
-      }
-      .star {
-        padding: 0;
-      }
-      gr-change-star {
-        vertical-align: middle;
-      }
-      .owner {
-        --account-max-length: 100px;
-      }
-      .branch,
-      .star,
-      .label,
-      .number,
-      .owner,
-      .assignee,
-      .updated,
-      .submitted,
-      .waiting,
-      .size,
-      .status,
-      .repo {
-        white-space: nowrap;
-      }
-      .star {
-        vertical-align: middle;
-      }
-      .leftPadding {
-        width: var(--spacing-l);
-      }
-      .star {
-        width: 30px;
-      }
-      .reviewers div {
-        overflow: hidden;
-      }
-      .label, .endpoint {
-        border-left: 1px solid var(--border-color);
-      }
-      .groupTitle td.label,
-      .label {
-        text-align: center;
-        width: 3rem;
-      }
-      .truncatedRepo {
-        display: none;
-      }
-      @media only screen and (max-width: 150em) {
-        .assignee,
-        .branch {
-          overflow: hidden;
-          max-width: 18rem;
-          text-overflow: ellipsis;
-        }
-        .truncatedRepo {
-          display: inline-block;
-        }
-        .fullRepo {
-          display: none;
-        }
-      }
-      @media only screen and (max-width: 100em) {
-        .assignee,
-        .branch {
-          max-width: 10rem;
-        }
-      }
-      @media only screen and (max-width: 50em) {
-        :host {
-          font-family: var(--header-font-family);
-          font-size: var(--font-size-h3);
-          font-weight: var(--font-weight-h3);
-          line-height: var(--line-height-h3);
-        }
-        gr-change-list-item {
-          flex-wrap: wrap;
-          justify-content: space-between;
-          padding: var(--spacing-xs) var(--spacing-m);
-        }
-        gr-change-list-item[selected],
-        gr-change-list-item:focus {
-          background-color: var(--view-background-color);
-          border: none;
-          border-top: 1px solid var(--border-color);
-        }
-        gr-change-list-item:hover {
-          background-color: var(--view-background-color);
-        }
-        .cell {
-          align-items: center;
-          display: flex;
-        }
-        .groupTitle,
-        .leftPadding,
-        .status,
-        .repo,
-        .branch,
-        .updated,
-        .submitted,
-        .waiting,
-        .label,
-        .assignee,
-        .groupHeader .star,
-        .noChanges .star {
-          display: none;
-        }
-        .groupHeader .cell,
-        .noChanges .cell {
-          padding-left: var(--spacing-m);
-        }
-        .subject {
-          margin-bottom: var(--spacing-xs);
-          width: calc(100% - 2em);
-        }
-        .owner,
-        .size {
-          max-width: none;
-        }
-        .noChanges .cell {
-          display: block;
-          height: auto;
-        }
-      }
+    ${changeListStyles.cssText}
     </style>
   </template>
 </dom-module>`;
-
 document.head.appendChild($_documentContainer.content);
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-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
index f928848..15ef5b8 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.ts
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
@@ -14,14 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {css} from 'lit';
 
-// 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 {};
-
 const $_documentContainer = document.createElement('template');
 
 export const pageNavStyles = css`
diff --git a/polygerrit-ui/app/styles/gr-paper-styles.ts b/polygerrit-ui/app/styles/gr-paper-styles.ts
new file mode 100644
index 0000000..1ef7124
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-paper-styles.ts
@@ -0,0 +1,60 @@
+/**
+ * @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 {css} from 'lit';
+
+export const paperStyles = css`
+  paper-toggle-button {
+    --paper-toggle-button-checked-bar-color: var(--link-color);
+    --paper-toggle-button-checked-button-color: var(--link-color);
+  }
+  paper-tabs {
+    font-size: var(--font-size-h3);
+    font-weight: var(--font-weight-h3);
+    line-height: var(--line-height-h3);
+    --paper-font-common-base: {
+      font-family: var(--header-font-family);
+      -webkit-font-smoothing: initial;
+    }
+    --paper-tab-content: {
+      margin-bottom: var(--spacing-s);
+    }
+    --paper-tab-content-focused: {
+      /* paper-tabs uses 700 here, which can look awkward */
+      font-weight: var(--font-weight-h3);
+      background: var(--gray-background-focus);
+    }
+    --paper-tab-content-unselected: {
+      /* paper-tabs uses 0.8 here, but we want to control the color directly */
+      opacity: 1;
+      color: var(--deemphasized-text-color);
+    }
+  }
+  paper-tab:focus {
+    padding-left: 0px;
+    padding-right: 0px;
+  }
+`;
+
+const $_documentContainer = document.createElement('template');
+$_documentContainer.innerHTML = `<dom-module id="gr-paper-styles">
+  <template>
+    <style>
+    ${paperStyles.cssText}
+    </style>
+  </template>
+</dom-module>`;
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/elements/gr-app_html.ts b/polygerrit-ui/app/styles/gr-submit-requirements-styles.ts
similarity index 64%
copy from polygerrit-ui/app/elements/gr-app_html.ts
copy to polygerrit-ui/app/styles/gr-submit-requirements-styles.ts
index f6172c9..8c5deef 100644
--- a/polygerrit-ui/app/elements/gr-app_html.ts
+++ b/polygerrit-ui/app/styles/gr-submit-requirements-styles.ts
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2020 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.
@@ -14,8 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {css} from 'lit';
 
-export const htmlTemplate = html`
-  <gr-app-element id="app-element"></gr-app-element>
+export const submitRequirementsStyles = css`
+  iron-icon.check-circle-filled,
+  iron-icon.overridden {
+    color: var(--success-foreground);
+  }
+  iron-icon.block,
+  iron-icon.error {
+    color: var(--deemphasized-text-color);
+  }
 `;
diff --git a/polygerrit-ui/app/styles/gr-table-styles.ts b/polygerrit-ui/app/styles/gr-table-styles.ts
index 6871499..ce01555 100644
--- a/polygerrit-ui/app/styles/gr-table-styles.ts
+++ b/polygerrit-ui/app/styles/gr-table-styles.ts
@@ -93,7 +93,7 @@
     text-decoration: underline;
   }
   .genericList .description {
-    width: 99%;
+    width: var(--generic-list-description-width, 99%);
   }
   .genericList .loadingMsg {
     color: var(--deemphasized-text-color);
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 98f6eb2..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);
@@ -189,36 +191,6 @@
   .separator.transparent {
     border-color: transparent;
   }
-  paper-toggle-button {
-    --paper-toggle-button-checked-bar-color: var(--link-color);
-    --paper-toggle-button-checked-button-color: var(--link-color);
-  }
-  paper-tabs {
-    font-size: var(--font-size-h3);
-    font-weight: var(--font-weight-h3);
-    line-height: var(--line-height-h3);
-    --paper-font-common-base: {
-      font-family: var(--header-font-family);
-      -webkit-font-smoothing: initial;
-    }
-    --paper-tab-content: {
-      margin-bottom: var(--spacing-s);
-    }
-    --paper-tab-content-focused: {
-      /* paper-tabs uses 700 here, which can look awkward */
-      font-weight: var(--font-weight-h3);
-      background: var(--gray-background-focus);
-    }
-    --paper-tab-content-unselected: {
-      /* paper-tabs uses 0.8 here, but we want to control the color directly */
-      opacity: 1;
-      color: var(--deemphasized-text-color);
-    }
-  }
-  paper-tab:focus {
-    padding-left: 0px;
-    padding-right: 0px;
-  }
   iron-autogrow-textarea {
     /** This is needed for firefox */
     --iron-autogrow-textarea_-_white-space: pre-wrap;
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 8b00a79..0aae217 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -1,43 +1,21 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {safeStyleSheet, safeStyleEl} from '../../utils/inner-html-util';
 
-// 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
-import {
-  createStyle,
-  safeStyleSheet,
-  setInnerHtml,
-} from '../../utils/inner-html-util';
-
-const customStyle = document.createElement('custom-style');
-customStyle.setAttribute('id', 'light-theme');
-
-const styleSheet = safeStyleSheet`
+const appThemeCss = safeStyleSheet`
   html {
     /**
-     * When adding a new color variable make sure to also add it to the other
-     * theme files in the same directory.
-     *
-     * For colors prefer lower case hex colors.
-     *
-     * Note that plugins might be using these variables, so removing a variable
-     * can be a breaking change that should go into the release notes.
-     */
+       * When adding a new color variable make sure to also add it to the other
+       * theme files in the same directory.
+       *
+       * For colors prefer lower case hex colors.
+       *
+       * Note that plugins might be using these variables, so removing a variable
+       * can be a breaking change that should go into the release notes.
+       */
 
     /* color palette */
     --gerrit-blue-light: #1565c0;
@@ -100,6 +78,7 @@
     --gray-700-10: #5f63681a;
     --gray-700-12: #5f63681f;
     --gray-500: #9aa0a6;
+    --gray-400: #bdc1c6;
     --gray-300: #dadce0;
     --gray-200: #e8eaed;
     --gray-200-12: #e8eaed1f;
@@ -112,6 +91,7 @@
     --purple-500: #a142f4;
     --purple-400: #af5cf7;
     --purple-200: #d7aefb;
+    --purple-100: #e9d2fd;
     --purple-50: #f3e8fd;
     --purple-tonal: #523272;
     --pink-800: #b80672;
@@ -128,20 +108,44 @@
 
     --error-foreground: var(--red-700);
     --error-background: var(--red-50);
-    --error-background-hover: linear-gradient(var(--red-700-04), var(--red-700-04)), var(--red-50);
-    --error-background-focus: linear-gradient(var(--red-700-12), var(--red-700-12)), var(--red-50);
+    --error-background-hover: linear-gradient(
+        var(--red-700-04),
+        var(--red-700-04)
+      ),
+      var(--red-50);
+    --error-background-focus: linear-gradient(
+        var(--red-700-12),
+        var(--red-700-12)
+      ),
+      var(--red-50);
     --error-ripple: var(--red-700-10);
 
     --warning-foreground: var(--orange-700);
     --warning-background: var(--orange-50);
-    --warning-background-hover: linear-gradient(var(--orange-700-04), var(--orange-700-04)), var(--orange-50);
-    --warning-background-focus: linear-gradient(var(--orange-700-12), var(--orange-700-12)), var(--orange-50);
+    --warning-background-hover: linear-gradient(
+        var(--orange-700-04),
+        var(--orange-700-04)
+      ),
+      var(--orange-50);
+    --warning-background-focus: linear-gradient(
+        var(--orange-700-12),
+        var(--orange-700-12)
+      ),
+      var(--orange-50);
     --warning-ripple: var(--orange-700-10);
 
     --info-foreground: var(--blue-700);
     --info-background: var(--blue-50);
-    --info-background-hover: linear-gradient(var(--blue-700-04), var(--blue-700-04)), var(--blue-50);
-    --info-background-focus: linear-gradient(var(--blue-700-12), var(--blue-700-12)), var(--blue-50);
+    --info-background-hover: linear-gradient(
+        var(--blue-700-04),
+        var(--blue-700-04)
+      ),
+      var(--blue-50);
+    --info-background-focus: linear-gradient(
+        var(--blue-700-12),
+        var(--blue-700-12)
+      ),
+      var(--blue-50);
     --info-ripple: var(--blue-700-10);
 
     --primary-button-text-color: white;
@@ -154,14 +158,30 @@
 
     --success-foreground: var(--green-700);
     --success-background: var(--green-50);
-    --success-background-hover: linear-gradient(var(--green-700-04), var(--green-700-04)), var(--green-50);
-    --success-background-focus: linear-gradient(var(--green-700-12), var(--green-700-12)), var(--green-50);
+    --success-background-hover: linear-gradient(
+        var(--green-700-04),
+        var(--green-700-04)
+      ),
+      var(--green-50);
+    --success-background-focus: linear-gradient(
+        var(--green-700-12),
+        var(--green-700-12)
+      ),
+      var(--green-50);
     --success-ripple: var(--green-700-10);
 
     --gray-foreground: var(--gray-700);
     --gray-background: var(--gray-100);
-    --gray-background-hover: linear-gradient(var(--gray-700-04), var(--gray-700-04)), var(--gray-100);
-    --gray-background-focus: linear-gradient(var(--gray-700-12), var(--gray-700-12)), var(--gray-100);
+    --gray-background-hover: linear-gradient(
+        var(--gray-700-04),
+        var(--gray-700-04)
+      ),
+      var(--gray-100);
+    --gray-background-focus: linear-gradient(
+        var(--gray-700-12),
+        var(--gray-700-12)
+      ),
+      var(--gray-100);
     --gray-ripple: var(--gray-700-10);
 
     --disabled-foreground: var(--gray-800-38);
@@ -172,6 +192,12 @@
     --tag-background: var(--cyan-100);
     --label-background: var(--red-50);
 
+    --not-working-hours-icon-background-color: var(--purple-50);
+    --not-working-hours-icon-color: var(--purple-700);
+    --unavailability-icon-color: var(--gray-700);
+    --unavailability-chip-icon-color: var(--orange-900);
+    --unavailability-chip-background-color: var(--yellow-50);
+
     /* text colors */
     --primary-text-color: var(--gray-900);
     --link-color: var(--gerrit-blue-light);
@@ -203,7 +229,9 @@
     --expanded-background-color: var(--background-color-tertiary);
     --select-background-color: var(--background-color-secondary);
     --shell-command-background-color: var(--background-color-secondary);
-    --shell-command-decoration-background-color: var(--background-color-tertiary);
+    --shell-command-decoration-background-color: var(
+      --background-color-tertiary
+    );
     --table-header-background-color: var(--background-color-secondary);
     --table-subheader-background-color: var(--background-color-tertiary);
     --view-background-color: var(--background-color-primary);
@@ -220,6 +248,16 @@
     --selection-background-color: rgba(161, 194, 250, 0.1);
     --tooltip-background-color: var(--gray-900);
 
+    /* dashboard size background colors */
+    --dashboard-size-xs: var(--gray-200);
+    --dashboard-size-s: var(--gray-300);
+    --dashboard-size-m: var(--gray-400);
+    --dashboard-size-l: var(--gray-500);
+    --dashboard-size-xl: var(--gray-700);
+    --dashboard-size-text: black;
+    --dashboard-size-xs-text: black;
+    --dashboard-size-xl-text: white;
+
     /* comment background colors */
     --comment-background-color: var(--gray-200);
     --robot-comment-background-color: var(--blue-50);
@@ -234,10 +272,20 @@
     --vote-outline-recommended: var(--green-700);
     --vote-color-rejected: var(--red-300);
 
+    /* vote chip background colors */
+    --vote-chip-unselected-outline-color: var(--gray-500);
+    --vote-chip-unselected-color: white;
+    --vote-chip-selected-positive-color: var(--green-300);
+    --vote-chip-selected-neutral-color: var(--gray-300);
+    --vote-chip-selected-negative-color: var(--red-300);
+    --vote-chip-unselected-text-color: black;
+    --vote-chip-selected-text-color: black;
+
     --outline-color-focus: var(--gray-900);
 
     /* misc colors */
     --border-color: var(--gray-300);
+    --input-focus-border-color: var(--blue-800);
     --comment-separator-color: var(--gray-300);
 
     /* checks tag colors */
@@ -262,33 +310,40 @@
     /* file status colors */
     --file-status-added: var(--green-300);
     --file-status-changed: var(--red-200);
-    --file-status-unchanged: var(--grey-300);
+    --file-status-unchanged: var(--gray-300);
 
     /* fonts */
-    --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
-    --header-font-family: 'Open Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
-    --monospace-font-family: 'Roboto Mono', 'SF Mono', 'Lucida Console', Monaco, monospace;
-    --font-size-code: 12px;     /* 12px mono */
-    --font-size-mono: .929rem;  /* 13px mono */
-    --font-size-small: .857rem; /* 12px */
-    --font-size-normal: 1rem;   /* 14px */
-    --font-size-h3: 1.143rem;   /* 16px */
-    --font-size-h2: 1.429rem;   /* 20px */
-    --font-size-h1: 1.714rem;   /* 24px */
-    --line-height-mono: 1.286rem;   /* 18px */
-    --line-height-small: 1.143rem;  /* 16px */
+    --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI',
+      Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
+      'Segoe UI Symbol';
+    --header-font-family: 'Open Sans', 'Roboto', -apple-system,
+      BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif,
+      'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+    --monospace-font-family: 'Roboto Mono', 'SF Mono', 'Lucida Console', Monaco,
+      monospace;
+    --font-size-code: 12px; /* 12px mono */
+    --font-size-mono: 0.929rem; /* 13px mono */
+    --font-size-small: 0.857rem; /* 12px */
+    --font-size-normal: 1rem; /* 14px */
+    --font-size-h3: 1.143rem; /* 16px */
+    --font-size-h2: 1.429rem; /* 20px */
+    --font-size-h1: 1.714rem; /* 24px */
+    --line-height-mono: 1.286rem; /* 18px */
+    --line-height-small: 1.143rem; /* 16px */
     --line-height-normal: 1.429rem; /* 20px */
-    --line-height-h3: 1.715rem;     /* 24px */
-    --line-height-h2: 2rem;         /* 28px */
-    --line-height-h1: 2.286rem;     /* 32px */
+    --line-height-h3: 1.715rem; /* 24px */
+    --line-height-h2: 2rem; /* 28px */
+    --line-height-h1: 2.286rem; /* 32px */
     --font-weight-normal: 400; /* 400 is the same as 'normal' */
     --font-weight-bold: 500;
     --font-weight-h1: 400;
     --font-weight-h2: 400;
     --font-weight-h3: var(--font-weight-bold, 500);
-    --context-control-button-font: var(--font-weight-normal) var(--font-size-normal) var(--font-family);
+    --context-control-button-font: var(--font-weight-normal)
+      var(--font-size-normal) var(--font-family);
     --code-hint-font-weight: 500;
-    --image-diff-button-font: var(--font-weight-normal) var(--font-size-normal) var(--font-family);
+    --image-diff-button-font: var(--font-weight-normal) var(--font-size-normal)
+      var(--font-family);
 
     /* spacing */
     --spacing-xxs: 1px;
@@ -327,6 +382,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);
@@ -344,9 +400,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;
@@ -355,26 +417,40 @@
     --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, .30), 0px 1px 3px 1px rgba(60, 64, 67, .15);
-    --elevation-level-2: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 2px 6px 2px rgba(60, 64, 67, .15);
-    --elevation-level-3: 0px 1px 3px 0px rgba(60, 64, 67, .30), 0px 4px 8px 3px rgba(60, 64, 67, .15);
-    --elevation-level-4: 0px 2px 3px 0px rgba(60, 64, 67, .30), 0px 6px 10px 4px rgba(60, 64, 67, .15);
-    --elevation-level-5: 0px 4px 4px 0px rgba(60, 64, 67, .30), 0px 8px 12px 6px rgba(60, 64, 67, .15);
+    --elevation-level-1: 0px 1px 2px 0px rgba(60, 64, 67, 0.3),
+      0px 1px 3px 1px rgba(60, 64, 67, 0.15);
+    --elevation-level-2: 0px 1px 2px 0px rgba(60, 64, 67, 0.3),
+      0px 2px 6px 2px rgba(60, 64, 67, 0.15);
+    --elevation-level-3: 0px 1px 3px 0px rgba(60, 64, 67, 0.3),
+      0px 4px 8px 3px rgba(60, 64, 67, 0.15);
+    --elevation-level-4: 0px 2px 3px 0px rgba(60, 64, 67, 0.3),
+      0px 6px 10px 4px rgba(60, 64, 67, 0.15);
+    --elevation-level-5: 0px 4px 4px 0px rgba(60, 64, 67, 0.3),
+      0px 8px 12px 6px rgba(60, 64, 67, 0.15);
 
     /* misc */
     --border-radius: 4px;
@@ -384,19 +460,14 @@
     /* paper and iron component overrides */
     --iron-overlay-backdrop-background-color: black;
     --iron-overlay-backdrop-opacity: 0.32;
-    --iron-overlay-backdrop: {
-      transition: none;
-    };
+
     --paper-tooltip-delay-in: 200ms;
     --paper-tooltip-delay-out: 0;
     --paper-tooltip-duration-in: 0;
     --paper-tooltip-duration-out: 0;
     --paper-tooltip-background: var(--tooltip-background-color);
-    --paper-tooltip-opacity: 1.0;
+    --paper-tooltip-opacity: 1;
     --paper-tooltip-text-color: var(--tooltip-text-color);
-    --paper-tooltip: {
-      font-size: var(--font-size-small);
-    }
   }
   @media screen and (max-width: 50em) {
     html {
@@ -408,8 +479,30 @@
       --spacing-xl: 12px;
       --spacing-xxl: 16px;
     }
-  }`;
+  }
+`;
 
-setInnerHtml(customStyle, createStyle(styleSheet));
+const styleEl = document.createElement('style');
+styleEl.setAttribute('id', 'light-theme');
+safeStyleEl.setTextContent(styleEl, appThemeCss);
+document.head.appendChild(styleEl);
 
-document.head.appendChild(customStyle);
+// TODO: The following can be removed when Paper and Iron components have been
+// removed from Gerrit.
+
+const appThemeCssPolymerLegacy = safeStyleSheet`
+  html {
+    --paper-tooltip: {
+      font-size: var(--font-size-small);
+    }
+    --iron-overlay-backdrop: {
+      transition: none;
+    }
+  }
+`;
+
+const customStyleEl = document.createElement('custom-style');
+const innerStyleEl = document.createElement('style');
+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 05c6b7d..6f19924 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -1,33 +1,17 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {safeStyleSheet, safeStyleEl} from '../../utils/inner-html-util';
 
-import {
-  createStyle,
-  safeStyleSheet,
-  setInnerHtml,
-} from '../../utils/inner-html-util';
-
-function getStyleEl() {
-  const customStyle = document.createElement('custom-style');
-  customStyle.setAttribute('id', 'dark-theme');
-
-  const styleSheet = safeStyleSheet`
-    html {
-      /**
+// 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 = safeStyleSheet`
+  html {
+    /**
        * Sections and variables must stay consistent with app-theme.js.
        *
        * Only modify color variables in this theme file. dark-theme extends
@@ -36,216 +20,264 @@
        * you probably want to override all.
        */
 
-      --error-foreground: var(--red-200);
-      --error-background: var(--red-tonal);
-      --error-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--red-tonal);
-      --error-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--red-tonal);
-      --error-ripple: var(--white-10);
+    --error-foreground: var(--red-200);
+    --error-background: var(--red-tonal);
+    --error-background-hover: linear-gradient(var(--white-04), var(--white-04)),
+      var(--red-tonal);
+    --error-background-focus: linear-gradient(var(--white-12), var(--white-12)),
+      var(--red-tonal);
+    --error-ripple: var(--white-10);
 
-      --warning-foreground: var(--orange-200);
-      --warning-background: var(--orange-tonal);
-      --warning-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--orange-tonal);
-      --warning-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--orange-tonal);
-      --warning-ripple: var(--white-10);
+    --warning-foreground: var(--orange-200);
+    --warning-background: var(--orange-tonal);
+    --warning-background-hover: linear-gradient(
+        var(--white-04),
+        var(--white-04)
+      ),
+      var(--orange-tonal);
+    --warning-background-focus: linear-gradient(
+        var(--white-12),
+        var(--white-12)
+      ),
+      var(--orange-tonal);
+    --warning-ripple: var(--white-10);
 
-      --info-foreground: var(--blue-200);
-      --info-background: var(--blue-tonal);
-      --info-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--blue-tonal);
-      --info-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--blue-tonal);
-      --info-ripple: var(--white-10);
+    --info-foreground: var(--blue-200);
+    --info-background: var(--blue-tonal);
+    --info-background-hover: linear-gradient(var(--white-04), var(--white-04)),
+      var(--blue-tonal);
+    --info-background-focus: linear-gradient(var(--white-12), var(--white-12)),
+      var(--blue-tonal);
+    --info-ripple: var(--white-10);
 
-      --primary-button-text-color: black;
-      --primary-button-background-color: var(--gerrit-blue-dark);
-      --primary-button-background-hover: var(--blue-200-16);
-      --primary-button-background-focus: var(--blue-200-24);
+    --primary-button-text-color: black;
+    --primary-button-background-color: var(--gerrit-blue-dark);
+    --primary-button-background-hover: var(--blue-200-16);
+    --primary-button-background-focus: var(--blue-200-24);
 
-      --selected-foreground: var(--blue-200);
-      --selected-background: var(--blue-900);
+    --selected-foreground: var(--blue-200);
+    --selected-background: var(--blue-900);
 
-      --success-foreground: var(--green-200);
-      --success-background: var(--green-tonal);
-      --success-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--green-tonal);
-      --success-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--green-tonal);
-      --success-ripple: var(--white-10);
+    --success-foreground: var(--green-200);
+    --success-background: var(--green-tonal);
+    --success-background-hover: linear-gradient(
+        var(--white-04),
+        var(--white-04)
+      ),
+      var(--green-tonal);
+    --success-background-focus: linear-gradient(
+        var(--white-12),
+        var(--white-12)
+      ),
+      var(--green-tonal);
+    --success-ripple: var(--white-10);
 
-      --gray-foreground: var(--gray-300);
-      --gray-background: var(--gray-tonal);
-      --gray-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--gray-tonal);
-      --gray-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--gray-tonal);
-      --gray-ripple: var(--white-10);
+    --gray-foreground: var(--gray-300);
+    --gray-background: var(--gray-tonal);
+    --gray-background-hover: linear-gradient(var(--white-04), var(--white-04)),
+      var(--gray-tonal);
+    --gray-background-focus: linear-gradient(var(--white-12), var(--white-12)),
+      var(--gray-tonal);
+    --gray-ripple: var(--white-10);
 
-      --disabled-foreground: var(--gray-200-38);
-      --disabled-background: var(--gray-200-12);
+    --disabled-foreground: var(--gray-200-38);
+    --disabled-background: var(--gray-200-12);
 
-      --chip-color: var(--gray-100);
-      --error-color: var(--red-200);
-      --tag-background: var(--cyan-900);
-      --label-background: var(--red-900);
+    --chip-color: var(--gray-100);
+    --error-color: var(--red-200);
+    --tag-background: var(--cyan-900);
+    --label-background: var(--red-900);
 
-      /* text colors */
-      --primary-text-color: var(--gray-200);
-      --link-color: var(--gerrit-blue-dark);
-      --comment-text-color: var(--primary-text-color);
-      --deemphasized-text-color: var(--gray-500);
-      --default-button-text-color: var(--gerrit-blue-dark);
-      --chip-selected-text-color: var(--blue-100);
-      --error-text-color: var(--red-200);
-      /* Used on text color for change list doesn't need user's attention. */
-      --reviewed-text-color: var(--gray-300);
-      --vote-text-color: black;
-      --status-text-color: black;
-      --tooltip-text-color: var(--gray-900);
-      --tooltip-button-text-color: var(--gerrit-blue-light);
-      --negative-red-text-color: var(--red-200);
-      --positive-green-text-color: var(--green-200);
-      --indirect-ancestor-text-color: var(--green-200);
+    --not-working-hours-icon-background-color: var(--purple-tonal);
+    --not-working-hours-icon-color: var(--purple-100);
+    --unavailability-icon-color: var(--gray-500);
 
-      /* background colors */
-      /* primary background colors */
-      --background-color-primary: var(--gray-900);
-      --background-color-secondary: #2f3034;
-      --background-color-tertiary: var(--gray-800);
-      /* directly derived from primary background colors */
-      /*   empty, because inheriting from app-theme is just fine
+    /* text colors */
+    --primary-text-color: var(--gray-200);
+    --link-color: var(--gerrit-blue-dark);
+    --comment-text-color: var(--primary-text-color);
+    --deemphasized-text-color: var(--gray-500);
+    --default-button-text-color: var(--gerrit-blue-dark);
+    --chip-selected-text-color: var(--blue-100);
+    --error-text-color: var(--red-200);
+    /* Used on text color for change list doesn't need user's attention. */
+    --reviewed-text-color: var(--gray-300);
+    --vote-text-color: black;
+    --status-text-color: black;
+    --tooltip-text-color: var(--gray-900);
+    --tooltip-button-text-color: var(--gerrit-blue-light);
+    --negative-red-text-color: var(--red-200);
+    --positive-green-text-color: var(--green-200);
+    --indirect-ancestor-text-color: var(--green-200);
+
+    /* background colors */
+    /* primary background colors */
+    --background-color-primary: var(--gray-900);
+    --background-color-secondary: #2f3034;
+    --background-color-tertiary: var(--gray-800);
+    /* directly derived from primary background colors */
+    /*   empty, because inheriting from app-theme is just fine
       /* unique background colors */
-      --assignee-highlight-color: #3a361c;
-      --assignee-highlight-selection-color: #423e24;
-      --chip-selected-background-color: #3c4455;
-      --edit-mode-background-color: #5c0a36;
-      --emphasis-color: #383f4a;
-      --hover-background-color: rgba(161, 194, 250, 0.2);
-      --disabled-button-background-color: #484a4d;
-      --selection-background-color: rgba(161, 194, 250, 0.1);
-      --tooltip-background-color: var(--gray-200);
+    --assignee-highlight-color: #3a361c;
+    --assignee-highlight-selection-color: #423e24;
+    --chip-selected-background-color: #3c4455;
+    --edit-mode-background-color: #5c0a36;
+    --emphasis-color: #383f4a;
+    --hover-background-color: rgba(161, 194, 250, 0.2);
+    --disabled-button-background-color: #484a4d;
+    --selection-background-color: rgba(161, 194, 250, 0.1);
+    --tooltip-background-color: var(--gray-200);
 
-      /* comment background colors */
-      --comment-background-color: #3c3f43;
-      --robot-comment-background-color: #1e3a5f;
-      --unresolved-comment-background-color: #614a19;
+    /* comment background colors */
+    --comment-background-color: #3c3f43;
+    --robot-comment-background-color: #1e3a5f;
+    --unresolved-comment-background-color: #614a19;
 
-      /* vote background colors */
-      --vote-color-approved: var(--green-300);
-      --vote-color-disliked: var(--red-tonal);
-      --vote-outline-disliked: var(--red-200);
-      --vote-color-neutral: var(--gray-700);
-      --vote-color-recommended: var(--green-tonal);
-      --vote-outline-recommended: var(--green-200);
-      --vote-color-rejected: var(--red-200);
+    /* vote background colors */
+    --vote-color-approved: var(--green-300);
+    --vote-color-disliked: var(--red-tonal);
+    --vote-outline-disliked: var(--red-200);
+    --vote-color-neutral: var(--gray-700);
+    --vote-color-recommended: var(--green-tonal);
+    --vote-outline-recommended: var(--green-200);
+    --vote-color-rejected: var(--red-200);
 
-      --outline-color-focus: var(--gray-100);
+    /* vote chip background colors */
+    --vote-chip-unselected-outline-color: var(--gray-500);
+    --vote-chip-unselected-color: var(--grey-800);
+    --vote-chip-selected-positive-color: var(--green-200);
+    --vote-chip-selected-neutral-color: var(--gray-300);
+    --vote-chip-selected-negative-color: var(--red-200);
+    --vote-chip-unselected-text-color: white;
+    --vote-chip-selected-text-color: black;
 
-      /* misc colors */
-      --border-color: var(--gray-700);
-      --comment-separator-color: var(--border-color);
+    --outline-color-focus: var(--gray-100);
 
-      /* checks tag colors */
-      --tag-gray: var(--gray-tonal);
-      --tag-yellow: var(--yellow-tonal);
-      --tag-pink: var(--pink-tonal);
-      --tag-purple: var(--purple-tonal);
-      --tag-cyan: var(--cyan-tonal);
-      --tag-brown: var(--brown-tonal);
+    /* misc colors */
+    --border-color: var(--gray-700);
+    --input-focus-border-color: var(--blue-200);
+    --comment-separator-color: var(--border-color);
 
-      /* status colors */
-      --status-merged: var(--green-400);
-      --status-abandoned: var(--gray-300);
-      --status-wip: #bcaaa4;
-      --status-private: var(--purple-200);
-      --status-conflict: var(--red-300);
-      --status-revert-created: #ff8a65;
-      --status-active: var(--blue-400);
-      --status-ready: var(--pink-500);
-      --status-custom: var(--purple-400);
+    /* checks tag colors */
+    --tag-gray: var(--gray-tonal);
+    --tag-yellow: var(--yellow-tonal);
+    --tag-pink: var(--pink-tonal);
+    --tag-purple: var(--purple-tonal);
+    --tag-cyan: var(--cyan-tonal);
+    --tag-brown: var(--brown-tonal);
 
-      /* file status colors */
-      --file-status-added: var(--green-tonal);
-      --file-status-changed: var(--red-tonal);
-      --file-status-unchanged: var(--grey-700);
+    /* status colors */
+    --status-merged: var(--green-400);
+    --status-abandoned: var(--gray-300);
+    --status-wip: #bcaaa4;
+    --status-private: var(--purple-200);
+    --status-conflict: var(--red-300);
+    --status-revert-created: #ff8a65;
+    --status-active: var(--blue-400);
+    --status-ready: var(--pink-500);
+    --status-custom: var(--purple-400);
 
-      /* fonts */
-      --font-weight-bold: 700; /* 700 is the same as 'bold' */
+    /* file status colors */
+    --file-status-added: var(--green-tonal);
+    --file-status-changed: var(--red-tonal);
+    --file-status-unchanged: var(--gray-700);
 
-      /* spacing */
+    /* fonts */
+    --font-weight-bold: 700; /* 700 is the same as 'bold' */
 
-      /* header and footer */
-      --footer-background-color: var(--background-color-tertiary);
-      --footer-border-top: 1px solid var(--border-color);
-      --header-background-color: var(--background-color-tertiary);
-      --header-border-bottom: 1px solid var(--border-color);
-      --header-padding: 0 var(--spacing-l);
-      --header-text-color: var(--primary-text-color);
+    /* spacing */
 
-      /* diff colors */
-      --dark-add-highlight-color: var(--green-tonal); 
-      --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
-      --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
-      --dark-remove-highlight-color: #62110f;
-      --diff-blank-background-color: var(--background-color-secondary);
-      --diff-context-control-background-color: #333311;
-      --diff-context-control-border-color: var(--border-color);
-      --diff-context-control-color: var(--deemphasized-text-color);
-      --diff-highlight-range-color: rgba(0, 100, 200, 0.5);
-      --diff-highlight-range-hover-color: rgba(0, 150, 255, 0.5);
-      --diff-selection-background-color: #3a71d8;
-      --diff-tab-indicator-color: var(--deemphasized-text-color);
-      --diff-trailing-whitespace-indicator: #ff9ad2;
-      --light-add-highlight-color: #182b1f;
-      --light-rebased-add-highlight-color: #487165;
-      --diff-moved-in-background: #1d4042;
-      --diff-moved-out-background: #230e34;
-      --diff-moved-in-label-color: var(--cyan-50);
-      --diff-moved-out-label-color: var(--purple-50);
-      --light-remove-add-highlight-color: #2f3f2f;
-      --light-remove-highlight-color: #320404;
-      --coverage-covered: #112826;
-      --coverage-not-covered: #6b3600;
-      --ranged-comment-hint-text-color: var(--blue-50);
-      --token-highlighting-color: var(--yellow-tonal);
+    /* header and footer */
+    --footer-background-color: var(--background-color-tertiary);
+    --footer-border-top: 1px solid var(--border-color);
+    --header-background-color: var(--background-color-tertiary);
+    --header-border-bottom: 1px solid var(--border-color);
+    --header-padding: 0 var(--spacing-l);
+    --header-text-color: var(--primary-text-color);
 
-      /* syntax colors */
-      --syntax-attr-color: #80cbbf;
-      --syntax-attribute-color: var(--primary-text-color);
-      --syntax-built_in-color: #f7c369;
-      --syntax-comment-color: var(--deemphasized-text-color);
-      --syntax-default-color: var(--primary-text-color);
-      --syntax-doctag-weight: bold;
-      --syntax-function-color: var(--primary-text-color);
-      --syntax-keyword-color: #cd4cf0;
-      --syntax-link-color: #c792ea;
-      --syntax-literal-color: #eefff7;
-      --syntax-meta-color: #6d7eee;
-      --syntax-meta-keyword-color: #eefff7;
-      --syntax-number-color: #00998a;
-      --syntax-params-color: var(--primary-text-color);
-      --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-type-color: #dd5f5f;
-      --syntax-variable-color: #f77669;
+    /* dashboard size background colors */
+    --dashboard-size-xs: var(--gray-700);
+    --dashboard-size-s: var(--gray-500);
+    --dashboard-size-m: var(--gray-400);
+    --dashboard-size-l: var(--gray-300);
+    --dashboard-size-xl: var(--gray-200);
+    --dashboard-size-text: black;
+    --dashboard-size-xs-text: white;
+    --dashboard-size-xl-text: black;
 
-      /* misc */
-      --line-length-indicator-color: #d7aefb;
+    /* diff colors */
+    --dark-add-highlight-color: var(--green-tonal);
+    --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
+    --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
+    --dark-remove-highlight-color: #62110f;
+    --diff-blank-background-color: var(--background-color-secondary);
+    --diff-context-control-background-color: #333311;
+    --diff-context-control-border-color: var(--border-color);
+    --diff-context-control-color: var(--deemphasized-text-color);
+    --diff-highlight-range-color: rgba(0, 100, 200, 0.5);
+    --diff-highlight-range-hover-color: rgba(0, 150, 255, 0.5);
+    --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;
+    --diff-moved-out-background: #230e34;
+    --diff-moved-in-label-color: var(--cyan-50);
+    --diff-moved-out-label-color: var(--purple-50);
+    --light-remove-add-highlight-color: #2f3f2f;
+    --light-remove-highlight-color: #320404;
+    --coverage-covered: #112826;
+    --coverage-not-covered: #6b3600;
+    --ranged-comment-hint-text-color: var(--blue-50);
+    --token-highlighting-color: var(--yellow-tonal);
 
-      /* paper and iron component overrides */
-      --iron-overlay-backdrop-background-color: white;
+    /* syntax colors */
+    --syntax-attr-color: #80cbbf;
+    --syntax-attribute-color: var(--primary-text-color);
+    --syntax-built_in-color: #f7c369;
+    --syntax-comment-color: var(--deemphasized-text-color);
+    --syntax-default-color: var(--primary-text-color);
+    --syntax-doctag-weight: bold;
+    --syntax-function-color: var(--primary-text-color);
+    --syntax-keyword-color: #cd4cf0;
+    --syntax-link-color: #c792ea;
+    --syntax-literal-color: #eefff7;
+    --syntax-meta-color: #6d7eee;
+    --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-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);
 
-      /* rules applied to html */
-      background-color: var(--view-background-color);
-    }
-  `;
+    /* misc */
+    --line-length-indicator-color: #d7aefb;
 
-  setInnerHtml(customStyle, createStyle(styleSheet));
-  return customStyle;
-}
+    /* paper and iron component overrides */
+    --iron-overlay-backdrop-background-color: white;
+
+    /* rules applied to html */
+    background-color: var(--view-background-color);
+  }
+`;
 
 export function applyTheme() {
-  document.head.appendChild(getStyleEl());
+  const styleEl = document.createElement('style');
+  styleEl.setAttribute('id', 'dark-theme');
+  safeStyleEl.setTextContent(styleEl, darkThemeCss);
+  document.head.appendChild(styleEl);
 }
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.ts b/polygerrit-ui/app/test/common-test-setup-karma.ts
index 39c79d1..5c84b05 100644
--- a/polygerrit-ui/app/test/common-test-setup-karma.ts
+++ b/polygerrit-ui/app/test/common-test-setup-karma.ts
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import './common-test-setup';
+import {testResolver as testResolverImpl} from './common-test-setup';
 import '@polymer/test-fixture/test-fixture';
 import 'chai/chai';
 
@@ -23,10 +23,12 @@
     flush: typeof flushImpl;
     fixtureFromTemplate: typeof fixtureFromTemplateImpl;
     fixtureFromElement: typeof fixtureFromElementImpl;
+    testResolver: typeof testResolverImpl;
   }
   let flush: typeof flushImpl;
   let fixtureFromTemplate: typeof fixtureFromTemplateImpl;
   let fixtureFromElement: typeof fixtureFromElementImpl;
+  let testResolver: typeof testResolverImpl;
 }
 
 // Workaround for https://github.com/karma-runner/karma-mocha/issues/227
@@ -36,6 +38,7 @@
   // For uncaught error mochajs doesn't print the full stack trace.
   // We should print it ourselves.
   console.error('Uncaught error:');
+  console.error(e);
   console.error(e.error.stack.toString());
   unhandledError = e;
 });
@@ -206,3 +209,4 @@
 
 window.fixtureFromTemplate = fixtureFromTemplateImpl;
 window.fixtureFromElement = fixtureFromElementImpl;
+window.testResolver = testResolverImpl;
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index bd5504a..46ba4ab 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -21,9 +21,15 @@
 import '../scripts/bundled-polymer';
 import '@polymer/iron-test-helpers/iron-test-helpers';
 import './test-router';
-import {_testOnlyInitAppContext} from './test-app-context-init';
+import {AppContext, injectAppContext} from '../services/app-context';
+import {Finalizable} from '../services/registry';
+import {
+  createTestAppContext,
+  createTestDependencies,
+  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-interface';
+import {_testOnlyResetGrRestApiSharedObjects} from '../services/gr-rest-api/gr-rest-api-impl';
 import {
   cleanupTestUtils,
   getCleanupsCount,
@@ -33,18 +39,22 @@
   removeThemeStyles,
 } from './test-utils';
 import {safeTypesBridge} from '../utils/safe-types-util';
-import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
 import {initGlobalVariables} from '../elements/gr-app-global-var-init';
 import 'chai/chai';
+import {chaiDomDiff} from '@open-wc/semantic-dom-diff';
+import {fixtureCleanup} from '@open-wc/testing-helpers';
 import {
   _testOnly_defaultResinReportHandler,
   installPolymerResin,
 } from '../scripts/polymer-resin-install';
 import {_testOnly_allTasks} from '../utils/async-util';
 import {cleanUpStorage} from '../services/storage/gr-storage_mock';
-import {updatePreferences} from '../services/user/user-model';
-import {createDefaultPreferences} from '../constants/constants';
-import {appContext} from '../services/app-context';
+import {
+  DependencyRequestEvent,
+  DependencyError,
+  DependencyToken,
+  Provider,
+} from '../models/dependency';
 
 declare global {
   interface Window {
@@ -53,6 +63,7 @@
     fixture: typeof fixtureImpl;
     stub: typeof stubImpl;
     sinon: typeof sinon;
+    chai: typeof chai;
   }
   let assert: typeof chai.assert;
   let expect: typeof chai.expect;
@@ -61,6 +72,7 @@
 }
 window.assert = chai.assert;
 window.expect = chai.expect;
+window.chai.use(chaiDomDiff);
 
 window.sinon = sinon;
 
@@ -93,6 +105,40 @@
 
 window.fixture = fixtureImpl;
 let testSetupTimestampMs = 0;
+let appContext: AppContext & Finalizable;
+
+const injectedDependencies: Map<
+  DependencyToken<unknown>,
+  Provider<unknown>
+> = new Map();
+
+const finalizers: Finalizable[] = [];
+
+function injectDependency<T>(
+  dependency: DependencyToken<T>,
+  creator: Creator<T>
+) {
+  let service: (T & Finalizable) | undefined = undefined;
+  injectedDependencies.set(dependency, () => {
+    if (service) return service;
+    service = creator();
+    finalizers.push(service);
+    return service;
+  });
+}
+
+export function testResolver<T>(token: DependencyToken<T>): T {
+  const provider = injectedDependencies.get(token);
+  if (provider) {
+    return provider() as T;
+  } else {
+    throw new DependencyError(token, 'Forgot to set up dependency for tests');
+  }
+}
+
+function resolveDependency(evt: DependencyRequestEvent<unknown>) {
+  evt.callback(testResolver(evt.dependency));
+}
 
 setup(() => {
   testSetupTimestampMs = new Date().getTime();
@@ -101,11 +147,18 @@
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
   assert.equal(getCleanupsCount(), 0);
-  _testOnlyInitAppContext();
+  appContext = createTestAppContext();
+  injectAppContext(appContext);
+  finalizers.push(appContext);
+  const dependencies = createTestDependencies(appContext, testResolver);
+  for (const [token, provider] of dependencies) {
+    injectDependency(token, provider);
+  }
+  document.addEventListener('request-dependency', resolveDependency);
   // The following calls is nessecary to avoid influence of previously executed
   // tests.
-  initGlobalVariables();
-  _testOnly_initGerritPluginApi();
+  initGlobalVariables(appContext);
+
   const shortcuts = appContext.shortcutsService;
   assert.isTrue(shortcuts._testOnly_isEmpty());
   const selection = document.getSelection();
@@ -195,14 +248,19 @@
 
 teardown(() => {
   sinon.restore();
+  fixtureCleanup();
   cleanupTestUtils();
   checkGlobalSpace();
   removeIronOverlayBackdropStyleEl();
   removeThemeStyles();
   cancelAllTasks();
   cleanUpStorage();
+  document.removeEventListener('request-dependency', resolveDependency);
+  injectedDependencies.clear();
   // Reset state
-  updatePreferences(createDefaultPreferences());
+  for (const f of finalizers) {
+    f.finalize();
+  }
   const testTeardownTimestampMs = new Date().getTime();
   const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
   if (elapsedMs > 1000) {
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 3bb0c34..d91b438 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';
 
@@ -134,9 +135,6 @@
     return Promise.resolve(new Response());
   },
   deleteAccountSSHKey(): void {},
-  deleteAssignee(): Promise<Response> {
-    return Promise.resolve(new Response());
-  },
   deleteChangeCommitMessage(): Promise<Response> {
     return Promise.resolve(new Response());
   },
@@ -173,6 +171,7 @@
   executeChangeAction(): Promise<Response | undefined> {
     return Promise.resolve(new Response());
   },
+  finalize(): void {},
   generateAccountHttpPassword(): Promise<Password> {
     return Promise.resolve('asdf');
   },
@@ -227,11 +226,14 @@
   getChangeConflicts(): Promise<ChangeInfo[] | undefined> {
     return Promise.resolve([]);
   },
-  getChangeDetail(): Promise<ParsedChangeInfo | null | undefined> {
+  getChangeDetail(
+    changeNum?: number | string
+  ): Promise<ParsedChangeInfo | undefined> {
+    if (changeNum === undefined) return Promise.resolve(undefined);
     return Promise.resolve(createChange() as ParsedChangeInfo);
   },
-  getChangeEdit(): Promise<false | EditInfo | undefined> {
-    return Promise.resolve(false);
+  getChangeEdit(): Promise<EditInfo | undefined> {
+    return Promise.resolve(undefined);
   },
   getChangeFiles(): Promise<FileNameToFileInfoMap | undefined> {
     return Promise.resolve({});
@@ -254,9 +256,24 @@
   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([]);
   },
@@ -275,20 +292,23 @@
   getDiff(): Promise<DiffInfo | undefined> {
     throw new Error('getDiff() not implemented by RestApiMock.');
   },
-  getDiffChangeDetail(): Promise<ChangeInfo | undefined | null> {
-    throw new Error('getDiffChangeDetail() not implemented by RestApiMock.');
-  },
   getDiffComments() {
-    throw new Error('getDiffComments() not implemented by RestApiMock.');
+    // NOTE: This method can not be typed properly due to overloads.
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    return Promise.resolve({}) as any;
   },
   getDiffDrafts() {
-    throw new Error('getDiffDrafts() not implemented by RestApiMock.');
+    // NOTE: This method can not be typed properly due to overloads.
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    return Promise.resolve({}) as any;
   },
   getDiffPreferences(): Promise<DiffPreferencesInfo | undefined> {
     return Promise.resolve(createDefaultDiffPrefs());
   },
   getDiffRobotComments() {
-    throw new Error('getDiffRobotComments() not implemented by RestApiMock.');
+    // NOTE: This method can not be typed properly due to overloads.
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    return Promise.resolve({}) as any;
   },
   getDocumentationSearches(): Promise<DocResult[] | undefined> {
     return Promise.resolve([]);
@@ -482,8 +502,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());
@@ -506,9 +527,6 @@
   setAccountUsername(): Promise<void> {
     return Promise.resolve();
   },
-  setAssignee(): Promise<Response> {
-    return Promise.resolve(new Response());
-  },
   setChangeHashtag(): Promise<Hashtag[]> {
     return Promise.resolve([]);
   },
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 483baa6..5795c4a 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -16,28 +16,110 @@
  */
 
 // Init app context before any other imports
-import {initAppContext} from '../services/app-context-init';
+import {create, Registry, Finalizable} from '../services/registry';
+import {DependencyToken} from '../models/dependency';
+import {assertIsDefined} from '../utils/common-util';
+import {AppContext} from '../services/app-context';
 import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
-import {AppContext, appContext} from '../services/app-context';
 import {grRestApiMock} from './mocks/gr-rest-api_mock';
 import {grStorageMock} from '../services/storage/gr-storage_mock';
 import {GrAuthMock} from '../services/gr-auth/gr-auth_mock';
+import {FlagsServiceImplementation} from '../services/flags/flags_impl';
+import {EventEmitter} from '../services/gr-event-interface/gr-event-interface_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';
+import {UserModel} from '../models/user/user-model';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../models/comments/comments-model';
+import {RouterModel} from '../services/router/router-model';
+import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
+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-mock';
 
-export function _testOnlyInitAppContext() {
-  initAppContext();
+export function createTestAppContext(): AppContext & Finalizable {
+  const appRegistry: Registry<AppContext> = {
+    routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
+    flagsService: (_ctx: Partial<AppContext>) =>
+      new FlagsServiceImplementation(),
+    reportingService: (_ctx: Partial<AppContext>) => grReportingMock,
+    eventEmitter: (_ctx: Partial<AppContext>) => new EventEmitter(),
+    authService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.eventEmitter, 'eventEmitter');
+      return new GrAuthMock(ctx.eventEmitter);
+    },
+    restApiService: (_ctx: Partial<AppContext>) => grRestApiMock,
+    jsApiService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.reportingService, 'reportingService');
+      return new GrJsApiInterface(ctx.reportingService);
+    },
+    storageService: (_ctx: Partial<AppContext>) => grStorageMock,
+    userModel: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.restApiService, '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);
+    },
+    pluginsModel: (_ctx: Partial<AppContext>) => new PluginsModel(),
+    highlightService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.reportingService, 'reportingService');
+      return new MockHighlightService(ctx.reportingService);
+    },
+  };
+  return create<AppContext>(appRegistry);
+}
 
-  function setMock<T extends keyof AppContext>(
-    serviceName: T,
-    setupMock: AppContext[T]
-  ) {
-    Object.defineProperty(appContext, serviceName, {
-      get() {
-        return setupMock;
-      },
-    });
-  }
-  setMock('reportingService', grReportingMock);
-  setMock('restApiService', grRestApiMock);
-  setMock('storageService', grStorageMock);
-  setMock('authService', new GrAuthMock(appContext.eventEmitter));
+export type Creator<T> = () => T & Finalizable;
+
+// Test dependencies are provides as creator functions to ensure that they are
+// not created if a test doesn't depend on them. E.g. don't create a
+// change-model in change-model_test.ts because it creates one in the test
+// after setting up stubs.
+export function createTestDependencies(
+  appContext: AppContext,
+  resolver: <T>(token: DependencyToken<T>) => T
+): Map<DependencyToken<unknown>, Creator<unknown>> {
+  const dependencies = new Map();
+  const browserModel = () => new BrowserModel(appContext.userModel);
+  dependencies.set(browserModelToken, browserModel);
+
+  const changeModelCreator = () =>
+    new ChangeModel(
+      appContext.routerModel,
+      appContext.restApiService,
+      appContext.userModel
+    );
+  dependencies.set(changeModelToken, changeModelCreator);
+
+  const commentsModelCreator = () =>
+    new CommentsModel(
+      appContext.routerModel,
+      resolver(changeModelToken),
+      appContext.restApiService,
+      appContext.reportingService
+    );
+  dependencies.set(commentsModelToken, commentsModelCreator);
+
+  const configModelCreator = () =>
+    new ConfigModel(resolver(changeModelToken), appContext.restApiService);
+  dependencies.set(configModelToken, configModelCreator);
+
+  const checksModelCreator = () =>
+    new ChecksModel(
+      appContext.routerModel,
+      resolver(changeModelToken),
+      appContext.reportingService,
+      appContext.pluginsModel
+    );
+
+  dependencies.set(checksModelToken, checksModelCreator);
+
+  return dependencies;
 }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 76f0cd1..8c33c79 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {
   AccountDetailInfo,
   AccountId,
@@ -31,12 +30,15 @@
   ChangeMessageId,
   ChangeMessageInfo,
   ChangeViewChangeInfo,
+  CommentInfo,
   CommentLinkInfo,
   CommentLinks,
+  CommentRange,
   CommitId,
   CommitInfo,
   ConfigInfo,
   DownloadInfo,
+  EditInfo,
   EditPatchSetNum,
   EmailAddress,
   FixId,
@@ -63,6 +65,9 @@
   RequirementType,
   Reviewers,
   RevisionInfo,
+  RobotCommentInfo,
+  RobotId,
+  RobotRunId,
   SchemesInfoMap,
   ServerInfo,
   SubmittedTogetherInfo,
@@ -93,23 +98,45 @@
 } from '../constants/constants';
 import {formatDate} from '../utils/date-util';
 import {GetDiffCommentsOutput} from '../services/gr-rest-api/gr-rest-api';
-import {AppElementChangeViewParams} from '../elements/gr-app-types';
+import {
+  AppElementChangeViewParams,
+  AppElementSearchParam,
+} from '../elements/gr-app-types';
 import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
 import {WebLinkInfo} from '../types/diff';
-import {createCommentThreads, UIComment, UIDraft} from '../utils/comment-util';
+import {
+  ChangeMessage,
+  CommentThread,
+  createCommentThreads,
+  DraftInfo,
+  UnsavedInfo,
+} from '../utils/comment-util';
 import {GerritView} from '../services/router/router-model';
 import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
-import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 import {GenerateUrlEditViewParameters} from '../elements/core/gr-navigation/gr-navigation';
 import {
   DetailedLabelInfo,
+  QuickLabelInfo,
   SubmitRequirementExpressionInfo,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../api/rest-api';
-import {RunResult} from '../services/checks/checks-model';
+import {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';
@@ -156,6 +183,7 @@
     work_in_progress_by_default: createInheritedBoolean(),
     max_object_size_limit: createMaxObjectSizeLimit(),
     default_submit_type: createSubmitType(),
+    enable_reviewer_by_email: createInheritedBoolean(),
     submit_type: SubmitType.INHERIT,
     commentlinks: createCommentLinks(),
   };
@@ -188,21 +216,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,
@@ -255,7 +283,10 @@
   };
 }
 
-export function createRevision(patchSetNum = 1): RevisionInfo {
+export function createRevision(
+  patchSetNum = 1,
+  description = ''
+): RevisionInfo {
   return {
     _number: patchSetNum as PatchSetNum,
     commit: createCommit(),
@@ -263,14 +294,29 @@
     kind: RevisionKind.REWORK,
     ref: 'refs/changes/5/6/1' as GitRef,
     uploader: createAccountWithId(),
+    description,
   };
 }
 
-export function createEditRevision(): EditRevisionInfo {
+export function createEditInfo(): EditInfo {
+  return {
+    commit: {...createCommit(), commit: 'commit-id-of-edit-ps' as CommitId},
+    base_patch_set_number: 1 as BasePatchSetNum,
+    base_revision: 'base-revision-of-edit',
+    ref: 'refs/changes/5/6/1' as GitRef,
+    fetch: {},
+    files: {},
+  };
+}
+
+export function createEditRevision(basePatchNum = 1): EditRevisionInfo {
   return {
     _number: EditPatchSetNum,
-    basePatchNum: 1 as BasePatchSetNum,
-    commit: createCommit(),
+    basePatchNum: basePatchNum as BasePatchSetNum,
+    commit: {
+      ...createCommit(),
+      commit: 'test-commit-id-of-edit-rev' as CommitId,
+    },
   };
 }
 
@@ -295,7 +341,7 @@
   [revisionId: string]: RevisionInfo;
 } {
   const revisions: {[revisionId: string]: RevisionInfo} = {};
-  const revisionDate = TEST_CHANGE_CREATED;
+  let revisionDate = TEST_CHANGE_CREATED;
   const revisionIdStart = 1; // The same as getCurrentRevision
   for (let i = 0; i < count; i++) {
     const revisionId = (i + revisionIdStart).toString(16);
@@ -306,6 +352,7 @@
     };
     revisions[revisionId] = revision;
     // advance 1 day
+    revisionDate = new Date(revisionDate);
     revisionDate.setDate(revisionDate.getDate() + 1);
   }
   return revisions;
@@ -319,12 +366,13 @@
 export function createChangeMessages(count: number): ChangeMessageInfo[] {
   const messageIdStart = 1000;
   const messages: ChangeMessageInfo[] = [];
-  const messageDate = TEST_CHANGE_CREATED;
+  let messageDate = TEST_CHANGE_CREATED;
   for (let i = 0; i < count; i++) {
     messages.push({
       ...createChangeMessageInfo((i + messageIdStart).toString(16)),
       date: dateToTimestamp(messageDate),
     });
+    messageDate = new Date(messageDate);
     messageDate.setDate(messageDate.getDate() + 1);
   }
   return messages;
@@ -385,7 +433,6 @@
     update_delay: 0,
     mergeability_computation_behavior:
       MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX,
-    enable_assignee: false,
   };
 }
 
@@ -447,6 +494,122 @@
   };
 }
 
+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,
@@ -481,6 +644,14 @@
   };
 }
 
+export function createAppElementSearchViewParams(): AppElementSearchParam {
+  return {
+    view: GerritView.SEARCH,
+    query: TEST_NUMERIC_CHANGE_ID.toString(),
+    offset: '0',
+  };
+}
+
 export function createGenerateUrlEditViewParameters(): GenerateUrlEditViewParameters {
   return {
     view: GerritView.EDIT,
@@ -507,7 +678,18 @@
   };
 }
 
-export function createComment(): UIComment {
+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 {
   return {
     patch_set: 1 as PatchSetNum,
     id: '12345' as UrlEncodedCommentId,
@@ -517,15 +699,38 @@
     updated: '2018-02-13 22:48:48.018000000' as Timestamp,
     unresolved: false,
     path: 'abc.txt',
+    ...extra,
   };
 }
 
-export function createDraft(): UIDraft {
+export function createDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
   return {
     ...createComment(),
-    collapsed: false,
     __draft: true,
-    __editing: false,
+    ...extra,
+  };
+}
+
+export function createUnsaved(extra: Partial<CommentInfo> = {}): UnsavedInfo {
+  return {
+    ...createComment(),
+    __unsaved: true,
+    id: undefined,
+    updated: undefined,
+    ...extra,
+  };
+}
+
+export function createRobotComment(
+  extra: Partial<CommentInfo> = {}
+): RobotCommentInfo {
+  return {
+    ...createComment(),
+    robot_id: 'robot-id-123' as RobotId,
+    robot_run_id: 'robot-run-id-456' as RobotRunId,
+    properties: {},
+    fix_suggestions: [],
+    ...extra,
   };
 }
 
@@ -632,14 +837,27 @@
   return new ChangeComments(comments, {}, drafts, {}, {});
 }
 
-export function createCommentThread(comments: UIComment[]) {
+export function createThread(
+  ...comments: Partial<CommentInfo | DraftInfo>[]
+): CommentThread {
+  return {
+    comments: comments.map(c => createComment(c)),
+    rootId: 'test-root-id-comment-thread' as UrlEncodedCommentId,
+    path: 'test-path-comment-thread',
+    commentSide: CommentSide.REVISION,
+    patchNum: 1 as PatchSetNum,
+    line: 314,
+  };
+}
+
+export function createCommentThread(comments: Array<Partial<CommentInfo>>) {
   if (!comments.length) {
     throw new Error('comment is required to create a thread');
   }
-  comments = comments.map(comment => {
+  const filledComments = comments.map(comment => {
     return {...createComment(), ...comment};
   });
-  const threads = createCommentThreads(comments);
+  const threads = createCommentThreads(filledComments);
   return threads[0];
 }
 
@@ -700,20 +918,36 @@
   }
 }
 
-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(expression),
+    is_legacy: false,
+  };
+}
+
+export function createNonApplicableSubmitRequirementResultInfo(): SubmitRequirementResultInfo {
+  return {
+    name: 'Verified',
+    status: SubmitRequirementStatus.NOT_APPLICABLE,
+    applicability_expression_result: createSubmitRequirementExpressionInfo(),
     submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    is_legacy: false,
   };
 }
 
@@ -742,3 +976,7 @@
     },
   };
 }
+
+export function createQuickLabelInfo(): QuickLabelInfo {
+  return {};
+}
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 557fe71..985bec1 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -17,28 +17,40 @@
 import '../types/globals';
 import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
 import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
-import {appContext} from '../services/app-context';
+import {getAppContext} from '../services/app-context';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
-import {SinonSpy} from 'sinon';
+import {SinonSpy, SinonStub} from 'sinon';
 import {StorageService} from '../services/storage/gr-storage';
 import {AuthService} from '../services/gr-auth/gr-auth';
 import {ReportingService} from '../services/gr-reporting/gr-reporting';
-import {CommentsService} from '../services/comments/comments-service';
-import {UserService} from '../services/user/user-service';
+import {UserModel} from '../models/user/user-model';
+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 {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 extends Promise<unknown> {
-  resolve: (value?: unknown) => void;
+export interface MockPromise<T> extends Promise<T> {
+  resolve: (value?: T) => void;
+  reject: (reason?: any) => void;
 }
 
-export const mockPromise = () => {
-  let res: (value?: unknown) => void;
-  const promise: MockPromise = new Promise(resolve => {
-    res = resolve;
-  }) as MockPromise;
+export function mockPromise<T = unknown>(): MockPromise<T> {
+  let res: (value?: T) => void;
+  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;
-};
+}
 
 export function isHidden(el: Element | undefined | null) {
   if (!el) return true;
@@ -101,35 +113,45 @@
 }
 
 export function stubRestApi<K extends keyof RestApiService>(method: K) {
-  return sinon.stub(appContext.restApiService, method);
+  return sinon.stub(getAppContext().restApiService, method);
 }
 
 export function spyRestApi<K extends keyof RestApiService>(method: K) {
-  return sinon.spy(appContext.restApiService, method);
+  return sinon.spy(getAppContext().restApiService, method);
 }
 
-export function stubComments<K extends keyof CommentsService>(method: K) {
-  return sinon.stub(appContext.commentsService, method);
+export function stubUsers<K extends keyof UserModel>(method: K) {
+  return sinon.stub(getAppContext().userModel, method);
 }
 
-export function stubUsers<K extends keyof UserService>(method: K) {
-  return sinon.stub(appContext.userService, method);
+export function stubShortcuts<K extends keyof ShortcutsService>(method: K) {
+  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(appContext.storageService, method);
+  return sinon.stub(getAppContext().storageService, method);
 }
 
 export function spyStorage<K extends keyof StorageService>(method: K) {
-  return sinon.spy(appContext.storageService, method);
+  return sinon.spy(getAppContext().storageService, method);
 }
 
 export function stubAuth<K extends keyof AuthService>(method: K) {
-  return sinon.stub(appContext.authService, method);
+  return sinon.stub(getAppContext().authService, method);
 }
 
 export function stubReporting<K extends keyof ReportingService>(method: K) {
-  return sinon.stub(appContext.reportingService, method);
+  return sinon.stub(getAppContext().reportingService, method);
+}
+
+export function stubFlags<K extends keyof FlagsService>(method: K) {
+  return sinon.stub(getAppContext().flagsService, method);
 }
 
 export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
@@ -162,27 +184,67 @@
   document.head.querySelector('#dark-theme')?.remove();
 }
 
+export async function waitQueryAndAssert<E extends Element = Element>(
+  el: Element | null | undefined,
+  selector: string
+): Promise<E> {
+  await waitUntil(
+    () => !!query<E>(el, selector),
+    `The element '${selector}' did not appear in the DOM within 1000 ms.`
+  );
+  return queryAndAssert<E>(el, selector);
+}
+
 export function waitUntil(
   predicate: () => boolean,
-  maxMillis = 100
+  message = 'The waitUntil() predicate is still false after 1000 ms.'
 ): Promise<void> {
   const start = Date.now();
-  let sleep = 1;
+  let sleep = 0;
+  if (predicate()) return Promise.resolve();
+  const error = new Error(message);
   return new Promise((resolve, reject) => {
     const waiter = () => {
       if (predicate()) {
-        return resolve();
+        resolve();
+        return;
       }
-      if (Date.now() - start >= maxMillis) {
-        return reject(new Error('Took to long to waitUntil'));
+      if (Date.now() - start >= 1000) {
+        reject(error);
+        return;
       }
       setTimeout(waiter, sleep);
-      sleep *= 2;
+      sleep = sleep === 0 ? 1 : sleep * 4;
     };
     waiter();
   });
 }
 
+export function waitUntilCalled(stub: SinonStub | SinonSpy, name: string) {
+  return waitUntil(() => stub.called, `${name} was not called`);
+}
+
+/**
+ * Subscribes to the observable and resolves once it emits a matching value.
+ * Usage:
+ *   await waitUntilObserved(
+ *     myTestModel.state$,
+ *     state => state.prop === expectedValue
+ *   );
+ */
+export async function waitUntilObserved<T>(
+  observable$: Observable<T>,
+  predicate: (t: T) => boolean,
+  message = 'The waitUntilObserved() predicate did not match after 1000 ms.'
+): Promise<T> {
+  return new Promise((resolve, reject) => {
+    observable$.pipe(filter(predicate), take(1), timeout(1000)).subscribe({
+      next: t => resolve(t),
+      error: () => reject(new Error(message)),
+    });
+  });
+}
+
 /**
  * Promisify an event callback to simplify async...await tests.
  *
@@ -190,17 +252,71 @@
  *   await listenOnce(el, 'render');
  *   ...
  */
-export function listenOnce(el: EventTarget, eventType: string) {
-  return new Promise<void>(resolve => {
-    const listener = () => {
+export function listenOnce<T extends Event>(
+  el: EventTarget,
+  eventType: string
+) {
+  return new Promise<T>(resolve => {
+    const listener = (e: Event) => {
       removeEventListener();
-      resolve();
+      resolve(e as T);
     };
-    el.addEventListener(eventType, listener);
     let removeEventListener = () => {
       el.removeEventListener(eventType, listener);
       removeEventListener = () => {};
     };
+    el.addEventListener(eventType, listener);
     registerTestCleanup(removeEventListener);
   });
 }
+
+export function dispatch<T>(element: HTMLElement, type: string, detail: T) {
+  const eventOptions = {
+    detail,
+    bubbles: true,
+    composed: true,
+  };
+  element.dispatchEvent(new CustomEvent<T>(type, eventOptions));
+}
+
+export function pressKey(
+  element: HTMLElement,
+  key: string | Key,
+  ...modifiers: Modifier[]
+) {
+  const eventOptions = {
+    key,
+    bubbles: true,
+    composed: true,
+    altKey: modifiers.includes(Modifier.ALT_KEY),
+    ctrlKey: modifiers.includes(Modifier.CTRL_KEY),
+    metaKey: modifiers.includes(Modifier.META_KEY),
+    shiftKey: modifiers.includes(Modifier.SHIFT_KEY),
+  };
+  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) => {
+      assert.fail('Promise resolved but should have failed');
+    })
+    .catch((e: unknown) => {
+      if (error) {
+        assert.equal(e, error);
+      }
+    });
+}
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 5040496..3e68d6c 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -42,6 +42,14 @@
 
     "allowUmdGlobalAccess": true,
 
+    /* We cannot just use the defaults, because of "webworker". */
+    "lib": [
+      "dom",
+      "dom.iterable",
+      "es2019",
+      "webworker"
+    ],
+
     /* typeRoots for IDE (see tsconfig_bazel.json for Bazel) */
     "typeRoots": [
       "node_modules/@types",
@@ -87,13 +95,14 @@
     "embed/**/*",
     "gr-diff/**/*",
     "mixins/**/*",
-    "samples/**/*",
+    "models/**/*",
     "scripts/**/*",
     "services/**/*",
     "styles/**/*",
     "types/**/*",
     "utils/**/*",
     "test/**/*",
+    "workers/**/*",
     "tmpl_out/**/*" //Created by template checker in dev-mode
   ]
 }
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
index dfd2078..730fc4d 100644
--- a/polygerrit-ui/app/tsconfig_bazel.json
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -16,12 +16,13 @@
     "embed/**/*",
     "gr-diff/**/*",
     "mixins/**/*",
-    "samples/**/*",
+    "models/**/*",
     "scripts/**/*",
     "services/**/*",
     "styles/**/*",
     "types/**/*",
-    "utils/**/*"
+    "utils/**/*",
+    "workers/**/*"
   ],
   "exclude": [
     "**/*_test.ts",
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
index 7137e23..096bd2f 100644
--- a/polygerrit-ui/app/tsconfig_bazel_test.json
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -7,7 +7,9 @@
       "../../external/ui_dev_npm/node_modules/@types"
     ],
     "paths": {
-      "@polymer/iron-test-helpers/*": ["../../ui_dev_npm/node_modules/@polymer/iron-test-helpers/*"]
+      "@polymer/iron-test-helpers/*": [
+        "../../ui_dev_npm/node_modules/@polymer/iron-test-helpers/*"
+      ]
     }
   },
   "include": [
@@ -20,13 +22,14 @@
     "embed/**/*",
     "gr-diff/**/*",
     "mixins/**/*",
-    "samples/**/*",
+    "models/**/*",
     "scripts/**/*",
     "services/**/*",
     "styles/**/*",
+    "test/**/*",
     "types/**/*",
     "utils/**/*",
-    "test/**/*"
+    "workers/**/*"
   ],
   "exclude": []
 }
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 1617aa3..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,
@@ -206,6 +206,7 @@
   UrlEncodedRepoName,
   UserConfigInfo,
   VotingRangeInfo,
+  WebLinkInfo,
   isDetailedLabelInfo,
   isQuickLabelInfo,
 };
@@ -277,7 +278,9 @@
   'current_revision' | 'revisions'
 >;
 
-export function isAccount(x: AccountInfo | GroupInfo): x is AccountInfo {
+export function isAccount(
+  x: AccountInfo | GroupInfo | GitPersonInfo
+): x is AccountInfo {
   const account = x as AccountInfo;
   return account._account_id !== undefined || account.email !== undefined;
 }
@@ -691,9 +694,10 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
 export interface CommentInfo {
-  // TODO(TS): Make this required.
-  patch_set?: PatchSetNum;
   id: UrlEncodedCommentId;
+  updated: Timestamp;
+  // TODO(TS): Make this required. Every comment must have patch_set set.
+  patch_set?: PatchSetNum;
   path?: string;
   side?: CommentSide;
   parent?: number;
@@ -701,7 +705,6 @@
   range?: CommentRange;
   in_reply_to?: UrlEncodedCommentId;
   message?: string;
-  updated: Timestamp;
   author?: AccountInfo;
   tag?: string;
   unresolved?: boolean;
@@ -963,14 +966,6 @@
 }
 
 /**
- * The AssigneeInput entity contains the identity of the user to be set as assignee
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#assignee-input
- */
-export interface AssigneeInput {
-  assignee: AccountId;
-}
-
-/**
  * The SshKeyInfo entity contains information about an SSH key of a user
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#ssh-key-info
  */
@@ -1147,8 +1142,6 @@
   work_in_progress_by_default?: boolean;
   // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
   email_format?: EmailFormat;
-  // The following property doesn't exist in RestAPI, it is added by GrRestApiInterface
-  default_diff_view?: DiffViewMode;
 }
 
 /**
@@ -1176,7 +1169,7 @@
 export interface ReviewInput {
   message?: string;
   tag?: ReviewInputTag;
-  labels?: LabelNameToValuesMap;
+  labels?: LabelNameToValueMap;
   comments?: PathToCommentsInputMap;
   robot_comments?: PathToRobotCommentsMap;
   drafts?: DraftsAction;
@@ -1218,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 223f290..562d47f 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -52,9 +52,6 @@
   /** Meta information about the file on side B as a DiffFileMetaInfo entity. */
   meta_b: DiffFileMetaInfo;
 
-  /** A list of strings representing the patch set diff header. */
-  diff_header?: string[];
-
   /**
    * Links to the file diff in external sites as a list of DiffWebLinkInfo
    * entries.
@@ -98,7 +95,7 @@
 
 export interface DiffPreferencesInfo extends DiffPreferenceInfoApi {
   expand_all_comments?: boolean;
-  cursor_blink_rate: number;
+  cursor_blink_rate?: number;
   manual_review?: boolean;
   retain_header?: boolean;
   skip_deleted?: boolean;
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index b6376c4..c1e5528 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -15,13 +15,13 @@
  * limitations under the License.
  */
 import {PatchSetNum} from './common';
-import {UIComment} from '../utils/comment-util';
+import {ChangeMessage, Comment} from '../utils/comment-util';
 import {FetchRequest} from './types';
 import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
-import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
 export enum EventType {
+  BIND_VALUE_CHANGED = 'bind-value-changed',
   CHANGE = 'change',
   CHANGED = 'changed',
   CHANGE_MESSAGE_DELETED = 'change-message-deleted',
@@ -56,6 +56,8 @@
 declare global {
   interface HTMLElementEventMap {
     /* prettier-ignore */
+    'bind-value-changed': BindValueChangeEvent;
+    /* prettier-ignore */
     'change': ChangeEvent;
     /* prettier-ignore */
     'changed': ChangedEvent;
@@ -99,9 +101,15 @@
     'server-error': ServerErrorEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
+    'location-change': LocationChangeEvent;
   }
 }
 
+export interface BindValueChangeEventDetail {
+  value: string;
+}
+export type BindValueChangeEvent = CustomEvent<BindValueChangeEventDetail>;
+
 export type ChangeEvent = InputEvent;
 
 export type ChangedEvent = CustomEvent<string>;
@@ -160,7 +168,7 @@
 
 export interface OpenFixPreviewEventDetail {
   patchNum?: PatchSetNum;
-  comment?: UIComment;
+  comment?: Comment;
 }
 export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
 
@@ -170,7 +178,7 @@
 export type CloseFixPreviewEvent = CustomEvent<CloseFixPreviewEventDetail>;
 export interface CreateFixCommentEventDetail {
   patchNum?: PatchSetNum;
-  comment?: UIComment;
+  comment?: Comment;
 }
 export type CreateFixCommentEvent = CustomEvent<CreateFixCommentEventDetail>;
 
@@ -232,6 +240,12 @@
 export interface ChecksTabState {
   statusOrCategory?: RunStatus | Category;
   checkName?: string;
+  /** regular expression for filtering runs */
+  filter?: string;
+  /** regular expression for selecting runs */
+  select?: string;
+  /** selected attempt for selected runs */
+  attempt?: number;
 }
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
 
@@ -241,3 +255,12 @@
   title: string;
 }
 export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
+
+/**
+ * This event can be used for Polymer properties that have `notify: true` set.
+ * But it is also generally recommended when you want to notify your parent
+ * elements about a property update, also for Lit elements.
+ *
+ * The name of the event should be `prop-name-changed`.
+ */
+export type ValueChangedEvent<T = string> = CustomEvent<{value: T}>;
diff --git a/polygerrit-ui/app/types/syntax-worker-api.ts b/polygerrit-ui/app/types/syntax-worker-api.ts
new file mode 100644
index 0000000..5dd8a36
--- /dev/null
+++ b/polygerrit-ui/app/types/syntax-worker-api.ts
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * This file defines the API of syntax-worker, which is a web worker for syntax
+ * highlighting based on the HighlightJS library.
+ *
+ * Workers communicate via `postMessage(e)` and `onMessage(e)` where `e` is a
+ * MessageEvent.
+ *
+ * SyntaxWorker expects incoming messages to be of type
+ * `MessageEvent<SyntaxWorkerMessage>`. And outgoing messages will be of type
+ * `MessageEvent<SyntaxWorkerResult>`.
+ */
+
+/** Type of incoming messages for SyntaxWorker. */
+export enum SyntaxWorkerMessageType {
+  INIT,
+  REQUEST,
+}
+
+/** Incoming message for SyntaxWorker. */
+export interface SyntaxWorkerMessage {
+  type: SyntaxWorkerMessageType;
+}
+
+/**
+ * Requests the worker to import the HighlightJS lib from the given URL and
+ * initializes and configures it. Has to be called once before you can send
+ * a SyntaxWorkerRequest message.
+ */
+export interface SyntaxWorkerInit extends SyntaxWorkerMessage {
+  type: SyntaxWorkerMessageType.INIT;
+  url: string;
+}
+
+export function isInit(
+  x: SyntaxWorkerMessage | undefined
+): x is SyntaxWorkerInit {
+  return !!x && x.type === SyntaxWorkerMessageType.INIT;
+}
+
+/**
+ * Requests the worker to highlight the given code. The worker must have been
+ * initialized before.
+ */
+export interface SyntaxWorkerRequest extends SyntaxWorkerMessage {
+  type: SyntaxWorkerMessageType.REQUEST;
+  language: string;
+  code: string;
+}
+
+export function isRequest(
+  x: SyntaxWorkerMessage | undefined
+): x is SyntaxWorkerRequest {
+  return !!x && x.type === SyntaxWorkerMessageType.REQUEST;
+}
+
+/** Type of outgoing messages of SyntaxWorker. */
+export interface SyntaxWorkerResult {
+  /** Unset or undefined means "success". */
+  error?: string;
+  /**
+   * Returned by SyntaxWorkerRequest calls. Every line gets its own array of
+   * ranges. `ranges[0]` are the ranges for line 1. Every line has an array,
+   * which may be empty. All ranges are guaranteed to be closed.
+   */
+  ranges?: SyntaxLayerLine[];
+}
+
+/** Ranges for one line. */
+export interface SyntaxLayerLine {
+  ranges: SyntaxLayerRange[];
+}
+
+/** Can be used for `length` in SyntaxLayerRange. */
+export const UNCLOSED = -1;
+
+/** Range of characters in a line to be syntax highlighted. */
+export interface SyntaxLayerRange {
+  /** 0-based inclusive. */
+  start: number;
+  /** Can only be UNCLOSED during processing. */
+  length: number;
+  /** HighlightJS specific names, e.g. 'literal'. */
+  className: string;
+}
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 2d4d412..ab78015 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -166,12 +166,14 @@
     languageName: string,
     code: string,
     ignore_illegals: boolean,
-    continuation: unknown
+    continuation?: unknown
   ): HighlightJSResult;
 }
 
 export type DiffLayerListener = (
+  /** 1-based inclusive */
   start: number,
+  /** 1-based inclusive */
   end: number,
   side: Side
 ) => void;
@@ -186,10 +188,8 @@
   patchRange: PatchRange | null;
   selectedFileIndex: number;
   showReplyDialog: boolean;
-  showDownloadDialog: boolean;
   diffMode: DiffViewMode | null;
   numFilesShown: number | null;
-  diffViewMode?: boolean;
 }
 
 export interface ChangeListViewState {
@@ -199,7 +199,6 @@
   selectedFileIndex?: number;
   selectedChangeIndex?: number;
   showReplyDialog?: boolean;
-  showDownloadDialog?: boolean;
   diffMode?: DiffViewMode;
   numFilesShown?: number;
   scrollTop?: number;
diff --git a/polygerrit-ui/app/utils/access-util.ts b/polygerrit-ui/app/utils/access-util.ts
index 165eacf..e66bbb1 100644
--- a/polygerrit-ui/app/utils/access-util.ts
+++ b/polygerrit-ui/app/utils/access-util.ts
@@ -25,7 +25,6 @@
   DELETE = 'delete',
   DELETE_CHANGES = 'deleteChanges',
   DELETE_OWN_CHANGES = 'deleteOwnChanges',
-  EDIT_ASSIGNEE = 'editAssignee',
   EDIT_HASHTAGS = 'editHashtags',
   EDIT_TOPIC_NAME = 'editTopicName',
   FORGE_AUTHOR = 'forgeAuthor',
@@ -79,10 +78,6 @@
     id: AccessPermissionId.DELETE_OWN_CHANGES,
     name: 'Delete Own Changes',
   },
-  [AccessPermissionId.EDIT_ASSIGNEE]: {
-    id: AccessPermissionId.EDIT_ASSIGNEE,
-    name: 'Edit Assignee',
-  },
   [AccessPermissionId.EDIT_HASHTAGS]: {
     id: AccessPermissionId.EDIT_HASHTAGS,
     name: 'Edit Hashtags',
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 08a625c..b7cc77b 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -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.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
index 8bd22ef..6688c19 100644
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -34,7 +34,7 @@
     name: 'Repositories',
     noBaseUrl: true,
     url: '/admin/repos',
-    view: 'gr-repo-list',
+    view: 'gr-repo-list' as GerritView,
     viewableToAll: true,
   },
   {
@@ -42,7 +42,7 @@
     section: 'Groups',
     noBaseUrl: true,
     url: '/admin/groups',
-    view: 'gr-admin-group-list',
+    view: 'gr-admin-group-list' as GerritView,
   },
   {
     name: 'Plugins',
@@ -50,7 +50,7 @@
     section: 'Plugins',
     noBaseUrl: true,
     url: '/admin/plugins',
-    view: 'gr-plugin-list',
+    view: 'gr-plugin-list' as GerritView,
   },
 ];
 
@@ -107,7 +107,7 @@
         name: link.text,
         capability: link.capability || undefined,
         noBaseUrl: !isExternalLink(link),
-        view: null,
+        view: undefined,
         viewableToAll: !link.capability,
         target: isExternalLink(link) ? '_blank' : null,
       };
@@ -252,10 +252,11 @@
   name: string;
   noBaseUrl: boolean;
   url: string;
-  view: string | null;
+  view?: GerritView;
   viewableToAll?: boolean;
   section?: string;
   capability?: string;
   target?: string | null;
   subsection?: SubsectionInterface;
+  children?: SubsectionInterface[];
 }
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 90ee5a5..3f51532 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -15,6 +15,9 @@
  * limitations under the License.
  */
 
+import {Observable} from 'rxjs';
+import {filter, take} from 'rxjs/operators';
+
 /**
  * @param fn An iteratee function to be passed each element of
  *     the array in order. Must return a promise, and the following
@@ -130,3 +133,16 @@
     fn(e);
   };
 }
+
+/**
+ * Let's you wait for an Observable to become true.
+ */
+export function until<T>(obs$: Observable<T>, predicate: (t: T) => boolean) {
+  return new Promise<void>(resolve => {
+    obs$.pipe(filter(predicate), take(1)).subscribe(() => {
+      resolve();
+    });
+  });
+}
+
+export const isFalse = (b: boolean) => b === false;
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/change-metadata-util.ts b/polygerrit-ui/app/utils/change-metadata-util.ts
index 9692ab31..84b324b 100644
--- a/polygerrit-ui/app/utils/change-metadata-util.ts
+++ b/polygerrit-ui/app/utils/change-metadata-util.ts
@@ -33,7 +33,6 @@
   UPLOADER = 'Uploader',
   AUTHOR = 'Author',
   COMMITTER = 'Committer',
-  ASSIGNEE = 'Assignee',
   CHERRY_PICK_OF = 'Cherry pick of',
 }
 
@@ -51,7 +50,6 @@
     Metadata.UPLOADER,
     Metadata.AUTHOR,
     Metadata.COMMITTER,
-    Metadata.ASSIGNEE,
     Metadata.CHERRY_PICK_OF,
   ],
   ALWAYS_HIDE: [
@@ -74,7 +72,6 @@
     case Metadata.UPLOADER:
     case Metadata.AUTHOR:
     case Metadata.COMMITTER:
-    case Metadata.ASSIGNEE:
       return false;
     case Metadata.CHERRY_PICK_OF:
       return !!change?.cherry_pick_of_change;
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 278e7f3..258f7cd 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -234,6 +234,18 @@
   return owner || uploader || reviewer || cc;
 }
 
+export function roleDetails(
+  change?: ChangeInfo | ParsedChangeInfo,
+  account?: AccountInfo
+) {
+  return {
+    isOwner: isOwner(change, account),
+    isUploader: isUploader(change, account),
+    isReviewer: isReviewer(change, account),
+    isCc: isCc(change, account),
+  };
+}
+
 export function getCurrentRevision(change?: ChangeInfo | ParsedChangeInfo) {
   if (!change?.revisions || !change?.current_revision) return undefined;
   return change.revisions[change.current_revision];
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 5b08fab..669c491 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -29,85 +29,138 @@
   RevisionPatchSetNum,
   AccountInfo,
   AccountDetailInfo,
+  ChangeMessageInfo,
+  VotingRangeInfo,
 } from '../types/common';
-import {CommentSide, Side, SpecialFilePath} from '../constants/constants';
+import {CommentSide, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
-import {LineNumber} from '../elements/diff/gr-diff/gr-diff-line';
 import {CommentIdToCommentThreadMap} from '../elements/diff/gr-comment-api/gr-comment-api';
 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 {
-  __draft?: boolean;
-  __draftID?: string;
-  __date?: Date;
+  // This must be true for all drafts. Drafts received from the backend will be
+  // modified immediately with __draft:true before allowing them to get into
+  // the application state.
+  __draft: boolean;
 }
 
-export type DraftInfo = CommentBasics & DraftCommentProps;
-
-/**
- * Each of the type implements or extends CommentBasics.
- */
-export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
-
-export interface UIStateCommentProps {
-  collapsed?: boolean;
+export interface UnsavedCommentProps {
+  // This must be true for all unsaved comment drafts. An unsaved draft is
+  // always just local to a comment component like <gr-comment> or
+  // <gr-comment-thread>. Unsaved drafts will never appear in the application
+  // state.
+  __unsaved: boolean;
 }
 
-export interface UIStateDraftProps {
-  __editing?: boolean;
-}
+export type DraftInfo = CommentInfo & DraftCommentProps;
 
-export type UIDraft = DraftInfo & UIStateCommentProps & UIStateDraftProps;
+export type UnsavedInfo = CommentBasics & UnsavedCommentProps;
 
-export type UIHuman = CommentInfo & UIStateCommentProps;
-
-export type UIRobot = RobotCommentInfo & UIStateCommentProps;
-
-export type UIComment = UIHuman | UIRobot | UIDraft;
+export type Comment = UnsavedInfo | DraftInfo | CommentInfo | RobotCommentInfo;
 
 export type CommentMap = {[path: string]: boolean};
 
-export function isRobot<T extends CommentInfo>(
+export function isRobot<T extends CommentBasics>(
   x: T | DraftInfo | RobotCommentInfo | undefined
 ): x is RobotCommentInfo {
   return !!x && !!(x as RobotCommentInfo).robot_id;
 }
 
-export function isDraft<T extends CommentInfo>(
-  x: T | UIDraft | undefined
-): x is UIDraft {
-  return !!x && !!(x as UIDraft).__draft;
+export function isDraft<T extends CommentBasics>(
+  x: T | DraftInfo | undefined
+): x is DraftInfo {
+  return !!x && !!(x as DraftInfo).__draft;
+}
+
+export function isUnsaved<T extends CommentBasics>(
+  x: T | UnsavedInfo | undefined
+): x is UnsavedInfo {
+  return !!x && !!(x as UnsavedInfo).__unsaved;
+}
+
+export function isDraftOrUnsaved<T extends CommentBasics>(
+  x: T | DraftInfo | UnsavedInfo | undefined
+): x is UnsavedInfo | DraftInfo {
+  return isDraft(x) || isUnsaved(x);
 }
 
 interface SortableComment {
-  __draft?: boolean;
-  __date?: Date;
-  updated?: Timestamp;
-  id?: UrlEncodedCommentId;
+  updated: Timestamp;
+  id: UrlEncodedCommentId;
 }
 
+export interface ChangeMessage extends ChangeMessageInfo {
+  // TODO(TS): maybe should be an enum instead
+  type: string;
+  expanded: boolean;
+  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 =
+  /^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
+
 export function sortComments<T extends SortableComment>(comments: T[]): T[] {
   return comments.slice(0).sort((c1, c2) => {
-    const d1 = !!c1.__draft;
-    const d2 = !!c2.__draft;
+    const d1 = isDraft(c1);
+    const d2 = isDraft(c2);
     if (d1 !== d2) return d1 ? 1 : -1;
 
-    const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
-    const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
-    const dateDiff = date1!.valueOf() - date2!.valueOf();
+    const date1 = parseDate(c1.updated);
+    const date2 = parseDate(c2.updated);
+    const dateDiff = date1.valueOf() - date2.valueOf();
     if (dateDiff !== 0) return dateDiff;
 
-    const id1 = c1.id ?? '';
-    const id2 = c2.id ?? '';
+    const id1 = c1.id;
+    const id2 = c2.id;
     return id1.localeCompare(id2);
   });
 }
 
-export function createCommentThreads(
-  comments: UIComment[],
-  patchRange?: PatchRange
-) {
+export function createUnsavedComment(thread: CommentThread): UnsavedInfo {
+  return {
+    path: thread.path,
+    patch_set: thread.patchNum,
+    side: thread.commentSide ?? CommentSide.REVISION,
+    line: typeof thread.line === 'number' ? thread.line : undefined,
+    range: thread.range,
+    parent: thread.mergeParentNum,
+    message: '',
+    unresolved: true,
+    __unsaved: true,
+  };
+}
+
+export function createUnsavedReply(
+  replyingTo: CommentInfo,
+  message: string,
+  unresolved: boolean
+): UnsavedInfo {
+  return {
+    path: replyingTo.path,
+    patch_set: replyingTo.patch_set,
+    side: replyingTo.side,
+    line: replyingTo.line,
+    range: replyingTo.range,
+    parent: replyingTo.parent,
+    in_reply_to: replyingTo.id,
+    message,
+    unresolved,
+    __unsaved: true,
+  };
+}
+
+export function createCommentThreads(comments: CommentInfo[]) {
   const sortedComments = sortComments(comments);
   const threads: CommentThread[] = [];
   const idThreadMap: CommentIdToCommentThreadMap = {};
@@ -129,7 +182,6 @@
     const newThread: CommentThread = {
       comments: [comment],
       patchNum: comment.patch_set,
-      diffSide: Side.LEFT,
       commentSide: comment.side ?? CommentSide.REVISION,
       mergeParentNum: comment.parent,
       path: comment.path,
@@ -137,13 +189,6 @@
       range: comment.range,
       rootId: comment.id,
     };
-    if (patchRange) {
-      if (isInBaseOfPatchRange(comment, patchRange))
-        newThread.diffSide = Side.LEFT;
-      else if (isInRevisionOfPatchRange(comment, patchRange))
-        newThread.diffSide = Side.RIGHT;
-      else throw new Error('comment does not belong in given patchrange');
-    }
     if (!comment.line && !comment.range) {
       newThread.line = 'FILE';
     }
@@ -154,68 +199,126 @@
 }
 
 export interface CommentThread {
-  comments: UIComment[];
+  /**
+   * This can only contain at most one draft. And if so, then it is the last
+   * comment in this list. This must not contain unsaved drafts.
+   */
+  comments: Array<CommentInfo | DraftInfo | RobotCommentInfo>;
+  /**
+   * Identical to the id of the first comment. If this is undefined, then the
+   * thread only contains an unsaved draft.
+   */
+  rootId?: UrlEncodedCommentId;
+  /**
+   * Note that all location information is typically identical to that of the
+   * first comment, but not for ported comments!
+   */
   path: string;
   commentSide: CommentSide;
   /* mergeParentNum is the merge parent number only valid for merge commits
      when commentSide is PARENT.
      mergeParentNum is undefined for auto merge commits
+     Same as `parent` in CommentInfo.
   */
   mergeParentNum?: number;
   patchNum?: PatchSetNum;
+  /* Different from CommentInfo, which just keeps the line undefined for
+     FILE comments. */
   line?: LineNumber;
-  /* rootId is optional since we create a empty comment thread element for
-     drafts and then create the draft which becomes the root */
-  rootId?: UrlEncodedCommentId;
-  diffSide?: Side;
   range?: CommentRange;
-  ported?: boolean; // is the comment ported over from a previous patchset
-  rangeInfoLost?: boolean; // if BE was unable to determine a range for this
+  /**
+   * Was the thread ported over from its original location to a newer patchset?
+   * If yes, then the location information above contains the ported location,
+   * but the comments still have the original location set.
+   */
+  ported?: boolean;
+  /**
+   * Only relevant when ported:true. Means that no ported range could be
+   * computed. `line` and `range` can be undefined then.
+   */
+  rangeInfoLost?: boolean;
 }
 
-export function getLastComment(thread?: CommentThread): UIComment | undefined {
-  const len = thread?.comments.length;
-  return thread && len ? thread.comments[len - 1] : undefined;
+export function equalLocation(t1?: CommentThread, t2?: CommentThread) {
+  if (t1 === t2) return true;
+  if (t1 === undefined || t2 === undefined) return false;
+  return (
+    t1.path === t2.path &&
+    t1.patchNum === t2.patchNum &&
+    t1.commentSide === t2.commentSide &&
+    t1.line === t2.line &&
+    t1.range?.start_line === t2.range?.start_line &&
+    t1.range?.start_character === t2.range?.start_character &&
+    t1.range?.end_line === t2.range?.end_line &&
+    t1.range?.end_character === t2.range?.end_character
+  );
 }
 
-export function getFirstComment(thread?: CommentThread): UIComment | undefined {
-  return thread?.comments?.[0];
+export function getLastComment(thread: CommentThread): CommentInfo | undefined {
+  const len = thread.comments.length;
+  return thread.comments[len - 1];
 }
 
-export function countComments(thread?: CommentThread) {
-  return thread?.comments?.length ?? 0;
+export function getLastPublishedComment(
+  thread: CommentThread
+): CommentInfo | undefined {
+  const publishedComments = thread.comments.filter(c => !isDraftOrUnsaved(c));
+  const len = publishedComments.length;
+  return publishedComments[len - 1];
 }
 
-export function isPatchsetLevel(thread?: CommentThread): boolean {
-  return thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+export function getFirstComment(
+  thread: CommentThread
+): CommentInfo | undefined {
+  return thread.comments[0];
 }
 
-export function isUnresolved(thread?: CommentThread): boolean {
+export function countComments(thread: CommentThread) {
+  return thread.comments.length;
+}
+
+export function isPatchsetLevel(thread: CommentThread): boolean {
+  return thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+}
+
+export function isUnresolved(thread: CommentThread): boolean {
   return !isResolved(thread);
 }
 
-export function isResolved(thread?: CommentThread): boolean {
-  return !getLastComment(thread)?.unresolved;
+export function isResolved(thread: CommentThread): boolean {
+  const lastUnresolved = getLastComment(thread)?.unresolved;
+  return !lastUnresolved ?? false;
 }
 
-export function isDraftThread(thread?: CommentThread): boolean {
+export function isDraftThread(thread: CommentThread): boolean {
   return isDraft(getLastComment(thread));
 }
 
-export function isRobotThread(thread?: CommentThread): boolean {
+export function isRobotThread(thread: CommentThread): boolean {
   return isRobot(getFirstComment(thread));
 }
 
-export function hasHumanReply(thread?: CommentThread): boolean {
+export function hasHumanReply(thread: CommentThread): boolean {
   return countComments(thread) > 1 && !isRobot(getLastComment(thread));
 }
 
+export function lastUpdated(thread: CommentThread): Date | undefined {
+  // We don't want to re-sort comments when you save a draft reply, so
+  // we stick to the timestampe of the last *published* comment.
+  const lastUpdated =
+    getLastPublishedComment(thread)?.updated ?? getLastComment(thread)?.updated;
+  return lastUpdated !== undefined ? parseDate(lastUpdated) : undefined;
+}
 /**
  * Whether the given comment should be included in the base side of the
  * given patch range.
  */
 export function isInBaseOfPatchRange(
-  comment: CommentBasics,
+  comment: {
+    patch_set?: PatchSetNum;
+    side?: CommentSide;
+    parent?: number;
+  },
   range: PatchRange
 ) {
   // If the base of the patch range is a parent of a merge, and the comment
@@ -249,7 +352,10 @@
  * given patch range.
  */
 export function isInRevisionOfPatchRange(
-  comment: CommentBasics,
+  comment: {
+    patch_set?: PatchSetNum;
+    side?: CommentSide;
+  },
   range: PatchRange
 ) {
   return (
@@ -271,7 +377,7 @@
 }
 
 export function getPatchRangeForCommentUrl(
-  comment: UIComment,
+  comment: Comment,
   latestPatchNum: RevisionPatchSetNum
 ) {
   if (!comment.patch_set) throw new Error('Missing comment.patch_set');
@@ -279,7 +385,7 @@
   // TODO(dhruvsri): Add handling for comment left on parents of merge commits
   if (comment.side === CommentSide.PARENT) {
     if (comment.patch_set === ParentPatchSetNum)
-      throw new Error('diffSide cannot be PARENT');
+      throw new Error('comment.patch_set cannot be PARENT');
     return {
       patchNum: comment.patch_set as RevisionPatchSetNum,
       basePatchNum: ParentPatchSetNum,
@@ -291,7 +397,7 @@
     };
   } else {
     return {
-      patchNum: latestPatchNum as RevisionPatchSetNum,
+      patchNum: latestPatchNum,
       basePatchNum: comment.patch_set as BasePatchSetNum,
     };
   }
@@ -355,30 +461,46 @@
   return authors;
 }
 
-export function computeId(comment: UIComment) {
-  if (comment.id) return comment.id;
-  if (isDraft(comment)) return comment.__draftID;
-  throw new Error('Missing id in root comment.');
-}
-
 /**
- * Add path info to every comment as CommentInfo returned
- * from server does not have that.
- *
- * TODO(taoalpha): should consider changing BE to send path
- * back within CommentInfo
+ * Add path info to every comment as CommentInfo returned from server does not
+ * have that.
  */
 export function addPath<T>(comments: {[path: string]: T[]} = {}): {
   [path: string]: Array<T & {path: string}>;
 } {
   const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
   for (const filePath of Object.keys(comments)) {
-    const allCommentsForPath = comments[filePath] || [];
-    if (allCommentsForPath.length) {
-      updatedComments[filePath] = allCommentsForPath.map(comment => {
-        return {...comment, path: filePath};
-      });
-    }
+    updatedComments[filePath] = (comments[filePath] || []).map(comment => {
+      return {...comment, path: filePath};
+    });
   }
   return updatedComments;
 }
+
+/**
+ * Add __draft:true to all drafts returned from server so that they can be told
+ * apart from published comments easily.
+ */
+export function addDraftProp(
+  draftsByPath: {[path: string]: CommentInfo[]} = {}
+) {
+  const updated: {[path: string]: DraftInfo[]} = {};
+  for (const filePath of Object.keys(draftsByPath)) {
+    updated[filePath] = (draftsByPath[filePath] ?? []).map(draft => {
+      return {...draft, __draft: true};
+    });
+  }
+  return updated;
+}
+
+export function reportingDetails(comment: CommentBasics) {
+  return {
+    id: comment?.id,
+    message_length: comment?.message?.trim().length,
+    in_reply_to: comment?.in_reply_to,
+    unresolved: comment?.unresolved,
+    path_length: comment?.path?.length,
+    line: comment?.range?.start_line ?? comment?.line,
+    unsaved: isUnsaved(comment),
+  };
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 3c8f26d..f5a2177 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -23,9 +23,8 @@
   sortComments,
 } from './comment-util';
 import {createComment, createCommentThread} from '../test/test-data-generators';
-import {CommentSide, Side} from '../constants/constants';
+import {CommentSide} from '../constants/constants';
 import {
-  BasePatchSetNum,
   ParentPatchSetNum,
   PatchSetNum,
   RevisionPatchSetNum,
@@ -37,7 +36,6 @@
   test('isUnresolved', () => {
     const thread = createCommentThread([createComment()]);
 
-    assert.isFalse(isUnresolved(undefined));
     assert.isFalse(isUnresolved(thread));
 
     assert.isTrue(
@@ -97,7 +95,6 @@
       {
         id: 'new_draft' as UrlEncodedCommentId,
         message: 'i do not like either of you',
-        diffSide: Side.LEFT,
         __draft: true,
         updated: '2015-12-20 15:01:20.396000000' as Timestamp,
       },
@@ -106,13 +103,11 @@
         message: 'i like you, jack',
         updated: '2015-12-23 15:00:20.396000000' as Timestamp,
         line: 1,
-        diffSide: Side.LEFT,
       },
       {
         id: 'jacks_reply' as UrlEncodedCommentId,
         message: 'i like you, too',
         updated: '2015-12-24 15:01:20.396000000' as Timestamp,
-        diffSide: Side.LEFT,
         line: 1,
         in_reply_to: 'sallys_confession',
       },
@@ -153,21 +148,16 @@
         },
       ];
 
-      const actualThreads = createCommentThreads(comments, {
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: 4 as RevisionPatchSetNum,
-      });
+      const actualThreads = createCommentThreads(comments);
 
       assert.equal(actualThreads.length, 2);
 
-      assert.equal(actualThreads[0].diffSide, Side.LEFT);
       assert.equal(actualThreads[0].comments.length, 2);
       assert.deepEqual(actualThreads[0].comments[0], comments[0]);
       assert.deepEqual(actualThreads[0].comments[1], comments[1]);
       assert.equal(actualThreads[0].patchNum, 1 as PatchSetNum);
       assert.equal(actualThreads[0].line, 1);
 
-      assert.equal(actualThreads[1].diffSide, Side.LEFT);
       assert.equal(actualThreads[1].comments.length, 1);
       assert.deepEqual(actualThreads[1].comments[0], comments[2]);
       assert.equal(actualThreads[1].patchNum, 1 as PatchSetNum);
@@ -194,7 +184,6 @@
 
       const expectedThreads = [
         {
-          diffSide: Side.LEFT,
           commentSide: CommentSide.REVISION,
           path: '/p',
           rootId: 'betsys_confession' as UrlEncodedCommentId,
@@ -226,13 +215,7 @@
         },
       ];
 
-      assert.deepEqual(
-        createCommentThreads(comments, {
-          basePatchNum: 5 as BasePatchSetNum,
-          patchNum: 10 as RevisionPatchSetNum,
-        }),
-        expectedThreads
-      );
+      assert.deepEqual(createCommentThreads(comments), expectedThreads);
     });
 
     test('does not thread unrelated comments at same location', () => {
@@ -241,14 +224,12 @@
           id: 'sallys_confession' as UrlEncodedCommentId,
           message: 'i like you, jack',
           updated: '2015-12-23 15:00:20.396000000' as Timestamp,
-          diffSide: Side.LEFT,
           path: '/p',
         },
         {
           id: 'jacks_reply' as UrlEncodedCommentId,
           message: 'i like you, too',
           updated: '2015-12-24 15:01:20.396000000' as Timestamp,
-          diffSide: Side.LEFT,
           path: '/p',
         },
       ];
diff --git a/polygerrit-ui/app/utils/compare-util.ts b/polygerrit-ui/app/utils/compare-util.ts
deleted file mode 100644
index dd20915..0000000
--- a/polygerrit-ui/app/utils/compare-util.ts
+++ /dev/null
@@ -1,39 +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.
- */
-export function deepEqualStringDict(
-  a: {[name: string]: string},
-  b: {[name: string]: string}
-): boolean {
-  const aKeys = Object.keys(a);
-  const bKeys = Object.keys(b);
-  if (aKeys.length !== bKeys.length) return false;
-  for (const key of aKeys) {
-    if (a[key] !== b[key]) return false;
-  }
-  return true;
-}
-
-export function equalArray(a?: unknown[], b?: unknown[]): boolean {
-  if (a === b) return true;
-  if (a === undefined) return b === undefined;
-  if (b === undefined) return a === undefined;
-  if (a.length !== b.length) return false;
-  for (let i = 0; i < a.length; i++) {
-    if (a[i] !== b[i]) return false;
-  }
-  return true;
-}
diff --git a/polygerrit-ui/app/utils/compare-util_test.ts b/polygerrit-ui/app/utils/compare-util_test.ts
deleted file mode 100644
index 7cd71bf..0000000
--- a/polygerrit-ui/app/utils/compare-util_test.ts
+++ /dev/null
@@ -1,43 +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 '../test/common-test-setup-karma';
-import {deepEqualStringDict, equalArray} from './compare-util';
-
-suite('compare-utils tests', () => {
-  test('deepEqual', () => {
-    assert.isTrue(deepEqualStringDict({}, {}));
-    assert.isTrue(deepEqualStringDict({x: 'y'}, {x: 'y'}));
-    assert.isTrue(deepEqualStringDict({x: 'y', p: 'q'}, {p: 'q', x: 'y'}));
-
-    assert.isFalse(deepEqualStringDict({}, {x: 'y'}));
-    assert.isFalse(deepEqualStringDict({x: 'y'}, {x: 'z'}));
-    assert.isFalse(deepEqualStringDict({x: 'y'}, {z: 'y'}));
-  });
-
-  test('equalArray', () => {
-    assert.isTrue(equalArray(undefined, undefined));
-    assert.isTrue(equalArray([], []));
-    assert.isTrue(equalArray([1], [1]));
-    assert.isTrue(equalArray(['a', 'b'], ['a', 'b']));
-
-    assert.isFalse(equalArray(undefined, []));
-    assert.isFalse(equalArray([], undefined));
-    assert.isFalse(equalArray([], [1]));
-    assert.isFalse(equalArray([1], [2]));
-    assert.isFalse(equalArray([1, 2], [1]));
-  });
-});
diff --git a/polygerrit-ui/app/utils/deep-util.ts b/polygerrit-ui/app/utils/deep-util.ts
new file mode 100644
index 0000000..694a8d7
--- /dev/null
+++ b/polygerrit-ui/app/utils/deep-util.ts
@@ -0,0 +1,50 @@
+/**
+ * @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.
+ */
+export function deepEqual<T>(a: T, b: T): boolean {
+  if (a === b) return true;
+  if (a === undefined || b === undefined) return false;
+  if (a === null || b === null) return false;
+  if (a instanceof Date && b instanceof Date)
+    return a.getTime() === b.getTime();
+
+  if (typeof a === 'object') {
+    if (typeof b !== 'object') return false;
+    const aObj = a as Record<string, unknown>;
+    const bObj = b as Record<string, unknown>;
+    const aKeys = Object.keys(aObj);
+    const bKeys = Object.keys(bObj);
+    if (aKeys.length !== bKeys.length) return false;
+    for (const key of aKeys) {
+      if (!deepEqual(aObj[key], bObj[key])) return false;
+    }
+    return true;
+  }
+
+  return false;
+}
+
+export function notDeepEqual<T>(a: T, b: T): boolean {
+  return !deepEqual(a, b);
+}
+
+/**
+ * @param obj Object
+ */
+export function deepClone(obj?: object) {
+  if (!obj) return undefined;
+  return JSON.parse(JSON.stringify(obj));
+}
diff --git a/polygerrit-ui/app/utils/deep-util_test.ts b/polygerrit-ui/app/utils/deep-util_test.ts
new file mode 100644
index 0000000..64c442b
--- /dev/null
+++ b/polygerrit-ui/app/utils/deep-util_test.ts
@@ -0,0 +1,71 @@
+/**
+ * @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 '../test/common-test-setup-karma';
+import {deepEqual} from './deep-util';
+
+suite('compare-util tests', () => {
+  test('deepEqual primitives', () => {
+    assert.isTrue(deepEqual(undefined, undefined));
+    assert.isTrue(deepEqual(null, null));
+    assert.isTrue(deepEqual(0, 0));
+    assert.isTrue(deepEqual('', ''));
+
+    assert.isFalse(deepEqual(1, 2));
+    assert.isFalse(deepEqual('a', 'b'));
+  });
+
+  test('deepEqual Dates', () => {
+    const a = new Date();
+    const b = new Date(a.getTime());
+    assert.isTrue(deepEqual(a, b));
+    assert.isFalse(deepEqual(a, undefined));
+    assert.isFalse(deepEqual(undefined, b));
+    assert.isFalse(deepEqual(a, new Date(a.getTime() + 1)));
+  });
+
+  test('deepEqual objects', () => {
+    assert.isTrue(deepEqual({}, {}));
+    assert.isTrue(deepEqual({x: 'y'}, {x: 'y'}));
+    assert.isTrue(deepEqual({x: 'y', p: 'q'}, {p: 'q', x: 'y'}));
+    assert.isTrue(deepEqual({x: {y: 'y'}}, {x: {y: 'y'}}));
+
+    assert.isFalse(deepEqual(undefined, {}));
+    assert.isFalse(deepEqual(null, {}));
+    assert.isFalse(deepEqual({}, undefined));
+    assert.isFalse(deepEqual({}, null));
+    assert.isFalse(deepEqual({}, {x: 'y'}));
+    assert.isFalse(deepEqual({x: 'y'}, {x: 'z'}));
+    assert.isFalse(deepEqual({x: 'y'}, {z: 'y'}));
+    assert.isFalse(deepEqual({x: {y: 'y'}}, {x: {y: 'z'}}));
+  });
+
+  test('deepEqual arrays', () => {
+    assert.isTrue(deepEqual([], []));
+    assert.isTrue(deepEqual([1], [1]));
+    assert.isTrue(deepEqual(['a', 'b'], ['a', 'b']));
+    assert.isTrue(deepEqual(['a', ['b']], ['a', ['b']]));
+
+    assert.isFalse(deepEqual(undefined, []));
+    assert.isFalse(deepEqual(null, []));
+    assert.isFalse(deepEqual([], undefined));
+    assert.isFalse(deepEqual([], null));
+    assert.isFalse(deepEqual([], [1]));
+    assert.isFalse(deepEqual([1], [2]));
+    assert.isFalse(deepEqual([1, 2], [1]));
+    assert.isFalse(deepEqual(['a', ['b']], ['a', ['c']]));
+  });
+});
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index dd408d3..16e0586 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {check} from './common-util';
 
 /**
  * Event emitted from polymer elements.
@@ -277,11 +276,12 @@
 ) {
   const observer = new IntersectionObserver(
     (entries: IntersectionObserverEntry[]) => {
-      check(entries.length === 1, 'Expected one intersection observer entry.');
-      const entry = entries[0];
-      if (entry.isIntersecting) {
-        observer.unobserve(entry.target);
-        callback();
+      for (const entry of entries) {
+        if (entry.isIntersecting) {
+          observer.unobserve(entry.target);
+          callback();
+          return;
+        }
       }
     },
     {rootMargin: `${marginPx}px`}
@@ -338,6 +338,8 @@
   combo?: ComboKey;
   /** Defaults to no modifiers. */
   modifiers?: Modifier[];
+  /** Defaults to false. If true, then `event.repeat === true` is allowed. */
+  allowRepeat?: boolean;
 }
 
 const ALPHA_NUM = new RegExp(/^[A-Za-z0-9]$/);
@@ -388,29 +390,51 @@
   return true;
 }
 
-export function addGlobalShortcut(
-  shortcut: Binding,
-  listener: (e: KeyboardEvent) => void
-) {
-  return addShortcut(document.body, shortcut, listener);
+export interface ShortcutOptions {
+  /**
+   * Do you want to suppress events from <input> elements and such?
+   */
+  shouldSuppress?: boolean;
+  /**
+   * Do you want to take care of calling preventDefault() and
+   * stopPropagation() yourself?
+   */
+  doNotPrevent?: boolean;
 }
 
+export function addGlobalShortcut(
+  shortcut: Binding,
+  listener: (e: KeyboardEvent) => void,
+  options: ShortcutOptions = {
+    shouldSuppress: true,
+    doNotPrevent: false,
+  }
+) {
+  return addShortcut(document.body, shortcut, listener, options);
+}
+
+/**
+ * Deprecated.
+ *
+ * For LitElement use the shortcut-controller.
+ * For PolymerElement use the keyboard-shortcut-mixin.
+ */
 export function addShortcut(
   element: HTMLElement,
   shortcut: Binding,
   listener: (e: KeyboardEvent) => void,
-  options: {
-    shouldSuppress: boolean;
-  } = {
+  options: ShortcutOptions = {
     shouldSuppress: false,
+    doNotPrevent: false,
   }
 ) {
   const wrappedListener = (e: KeyboardEvent) => {
-    if (e.repeat) return;
+    if (e.repeat && !shortcut.allowRepeat) return;
     if (options.shouldSuppress && shouldSuppress(e)) return;
-    if (eventMatchesShortcut(e, shortcut)) {
-      listener(e);
-    }
+    if (!eventMatchesShortcut(e, shortcut)) return;
+    if (!options.doNotPrevent) e.preventDefault();
+    if (!options.doNotPrevent) e.stopPropagation();
+    listener(e);
   };
   element.addEventListener('keydown', wrappedListener);
   return () => element.removeEventListener('keydown', wrappedListener);
@@ -466,3 +490,18 @@
   }
   return false;
 }
+
+/** Executes the given callback when the element's height is > 0. */
+export function whenRendered(el: HTMLElement, callback: () => void) {
+  if (el.clientHeight > 0) {
+    callback();
+    return;
+  }
+  const obs = new ResizeObserver(() => {
+    if (el.clientHeight > 0) {
+      callback();
+      obs.unobserve(el);
+    }
+  });
+  obs.observe(el);
+}
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
index 5429550..41e9857 100644
--- a/polygerrit-ui/app/utils/dom-util_test.ts
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -28,22 +28,28 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {queryAndAssert} from '../test/test-utils';
+import {mockPromise, queryAndAssert} from '../test/test-utils';
 
-async function keyEventOn(
+/**
+ * You might think that instead of passing in the callback with assertions as a
+ * parameter that you could as well just `await keyEventOn()` and *then* run
+ * your assertions. But at that point the event is not "hot" anymore, so most
+ * likely you want to assert stuff about the event within the callback
+ * parameter.
+ */
+function keyEventOn(
   el: HTMLElement,
   callback: (e: KeyboardEvent) => void,
   keyCode = 75,
   key = 'k'
 ): Promise<KeyboardEvent> {
-  let resolve: (e: KeyboardEvent) => void;
-  const promise = new Promise<KeyboardEvent>(r => (resolve = r));
+  const promise = mockPromise<KeyboardEvent>();
   el.addEventListener('keydown', (e: KeyboardEvent) => {
     callback(e);
-    resolve(e);
+    promise.resolve(e);
   });
   MockInteractions.keyDownOn(el, keyCode, null, key);
-  return await promise;
+  return promise;
 }
 
 class TestEle extends PolymerElement {
@@ -139,7 +145,7 @@
       MockInteractions.click(aLink);
       assert.equal(
         path,
-        `html>body>test-fixture#${basicFixture.fixtureId}>` +
+        `html.lightTheme>body>test-fixture#${basicFixture.fixtureId}>` +
           'div#test.a.b.c>a.testBtn'
       );
     });
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 2018eeb..418adbd 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -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 c50bbe2..f5703f4 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -15,10 +15,13 @@
  * limitations under the License.
  */
 import {
+  ChangeInfo,
   isQuickLabelInfo,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
+  LabelNameToValuesMap,
 } from '../api/rest-api';
+import {FlagsService, KnownExperimentId} from '../services/flags/flags';
 import {
   AccountInfo,
   ApprovalInfo,
@@ -28,7 +31,13 @@
   LabelNameToInfoMap,
   VotingRangeInfo,
 } from '../types/common';
-import {assertNever, unique} from './common-util';
+import {ParsedChangeInfo} from '../types/types';
+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 {
@@ -88,8 +97,10 @@
         : LabelStatus.RECOMMENDED;
     }
   } else if (isQuickLabelInfo(label)) {
-    if (label.approved) return LabelStatus.RECOMMENDED;
-    if (label.rejected) return LabelStatus.DISLIKED;
+    if (label.approved) return LabelStatus.APPROVED;
+    if (label.rejected) return LabelStatus.REJECTED;
+    if (label.disliked) return LabelStatus.DISLIKED;
+    if (label.recommended) return LabelStatus.RECOMMENDED;
   }
   return LabelStatus.NEUTRAL;
 }
@@ -143,7 +154,10 @@
   if (isDetailedLabelInfo(label)) {
     return !hasNeutralStatus(label, getApprovalInfo(label, account));
   } else if (isQuickLabelInfo(label)) {
-    return label.approved === account || label.rejected === account;
+    return (
+      label.approved?._account_id === account._account_id ||
+      label.rejected?._account_id === account._account_id
+    );
   }
   return false;
 }
@@ -176,7 +190,12 @@
     );
   }
   if (isQuickLabelInfo(labelInfo)) {
-    return !!labelInfo.rejected || !!labelInfo.approved;
+    return (
+      !!labelInfo.rejected ||
+      !!labelInfo.approved ||
+      !!labelInfo.recommended ||
+      !!labelInfo.disliked
+    );
   }
   return false;
 }
@@ -204,39 +223,64 @@
   return;
 }
 
-export function extractAssociatedLabels(
-  requirement: SubmitRequirementResultInfo
-): string[] {
+function extractLabelsFrom(expression: string) {
   const pattern = new RegExp('label[0-9]*:([\\w-]+)', 'g');
   const labels = [];
   let match;
-  while (
-    (match = pattern.exec(
-      requirement.submittability_expression_result.expression
-    )) !== null
-  ) {
+  while ((match = pattern.exec(expression)) !== null) {
     labels.push(match[1]);
   }
+  return labels;
+}
+
+export function extractAssociatedLabels(
+  requirement: SubmitRequirementResultInfo,
+  type: 'all' | 'onlyOverride' | 'onlySubmittability' = 'all'
+): string[] {
+  let labels: string[] = [];
+  if (requirement.submittability_expression_result && type !== 'onlyOverride') {
+    labels = labels.concat(
+      extractLabelsFrom(requirement.submittability_expression_result.expression)
+    );
+  }
+  if (requirement.override_expression_result && type !== 'onlySubmittability') {
+    labels = labels.concat(
+      extractLabelsFrom(requirement.override_expression_result.expression)
+    );
+  }
   return labels.filter(unique);
 }
 
 export function iconForStatus(status: SubmitRequirementStatus) {
   switch (status) {
     case SubmitRequirementStatus.SATISFIED:
-      return 'check';
+      return 'check-circle-filled';
     case SubmitRequirementStatus.UNSATISFIED:
-      return 'close';
+      return 'block';
     case SubmitRequirementStatus.OVERRIDDEN:
       return 'overridden';
     case SubmitRequirementStatus.NOT_APPLICABLE:
       return 'info';
+    case SubmitRequirementStatus.ERROR:
+      return 'error';
+    case SubmitRequirementStatus.FORCED:
+      return 'check-circle-filled';
     default:
       assertNever(status, `Unsupported status: ${status}`);
   }
 }
 
+/**
+ * Show only applicable.
+ */
+export function getRequirements(change?: ParsedChangeInfo | ChangeInfo) {
+  return (change?.submit_requirements ?? []).filter(
+    req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
+  );
+}
+
 // TODO(milutin): This may be temporary for demo purposes
-const PRIORITY_REQUIREMENTS_ORDER: string[] = [
+export const PRIORITY_REQUIREMENTS_ORDER: string[] = [
   StandardLabels.CODE_REVIEW,
   StandardLabels.CODE_OWNERS,
   StandardLabels.PRESUBMIT_VERIFIED,
@@ -255,3 +299,138 @@
   );
   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
+  // not_applicable requirements. In this case we don't want to filter out them,
+  // because trigger votes are labels not associated with any requirement.
+  const submitReqs = change?.submit_requirements ?? [];
+  const labelAssociatedWithSubmitReqs = submitReqs
+    .flatMap(req => extractAssociatedLabels(req))
+    .filter(unique);
+  return allLabels.filter(
+    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 9360688..e655789 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -24,9 +24,15 @@
   getRepresentativeValue,
   getVotingRange,
   getVotingRangeOrDefault,
+  getRequirements,
+  getTriggerVotes,
   hasNeutralStatus,
   labelCompare,
   LabelStatus,
+  computeLabels,
+  mergeLabelMaps,
+  computeOrderedLabelValues,
+  mergeLabelInfoMaps,
 } from './label-util';
 import {
   AccountId,
@@ -38,9 +44,18 @@
 } from '../types/common';
 import {
   createAccountWithEmail,
+  createChange,
   createSubmitRequirementExpressionInfo,
   createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
+  createDetailedLabelInfo,
+  createAccountWithId,
 } from '../test/test-data-generators';
+import {
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+  LabelNameToInfoMap,
+} from '../api/rest-api';
 
 const VALUES_0 = {
   '0': 'neutral',
@@ -179,8 +194,12 @@
     let labelInfo: QuickLabelInfo = {};
     assert.equal(getLabelStatus(labelInfo), LabelStatus.NEUTRAL);
     labelInfo = {approved: createAccountWithEmail()};
-    assert.equal(getLabelStatus(labelInfo), LabelStatus.RECOMMENDED);
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.APPROVED);
     labelInfo = {rejected: createAccountWithEmail()};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.REJECTED);
+    labelInfo = {recommended: createAccountWithEmail()};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.RECOMMENDED);
+    labelInfo = {disliked: createAccountWithEmail()};
     assert.equal(getLabelStatus(labelInfo), LabelStatus.DISLIKED);
   });
 
@@ -224,37 +243,360 @@
     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);
       assert.deepEqual(labels, ['Verified', 'Code-Review']);
     });
+    test('overridden label', () => {
+      const submitRequirement = {
+        ...createSubmitRequirementResultInfo(),
+        override_expression_result: createSubmitRequirementExpressionInfo(
+          'label:Build-cop-override'
+        ),
+      };
+      const labels = extractAssociatedLabels(submitRequirement);
+      assert.deepEqual(labels, ['Verified', 'Build-cop-override']);
+    });
+  });
+
+  suite('getRequirements()', () => {
+    function createChangeInfoWith(
+      submit_requirements: SubmitRequirementResultInfo[]
+    ) {
+      return {
+        ...createChange(),
+        submit_requirements,
+      };
+    }
+    test('only legacy', () => {
+      const requirement = {
+        ...createSubmitRequirementResultInfo(),
+        is_legacy: true,
+      };
+      const change = createChangeInfoWith([requirement]);
+      assert.deepEqual(getRequirements(change), [requirement]);
+    });
+    test('legacy and non-legacy - show all', () => {
+      const requirement = {
+        ...createSubmitRequirementResultInfo(),
+        is_legacy: true,
+      };
+      const requirement2 = {
+        ...createSubmitRequirementResultInfo(),
+        is_legacy: false,
+      };
+      const change = createChangeInfoWith([requirement, requirement2]);
+      assert.deepEqual(getRequirements(change), [requirement, requirement2]);
+    });
+    test('filter not applicable', () => {
+      const requirement = createSubmitRequirementResultInfo();
+      const requirement2 = createNonApplicableSubmitRequirementResultInfo();
+      const change = createChangeInfoWith([requirement, requirement2]);
+      assert.deepEqual(getRequirements(change), [requirement]);
+    });
+  });
+
+  suite('getTriggerVotes()', () => {
+    test('no requirements', () => {
+      const triggerVote = 'Trigger-Vote';
+      const change = {
+        ...createChange(),
+        labels: {
+          [triggerVote]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getTriggerVotes(change), [triggerVote]);
+    });
+    test('no trigger votes, all labels associated with sub requirement', () => {
+      const triggerVote = 'Trigger-Vote';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${triggerVote}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [triggerVote]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getTriggerVotes(change), []);
+    });
+
+    test('labels in not-applicable requirement are not trigger vote', () => {
+      const triggerVote = 'Trigger-Vote';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${triggerVote}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [triggerVote]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getTriggerVotes(change), []);
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/gr-app_html.ts b/polygerrit-ui/app/utils/math-util.ts
similarity index 63%
copy from polygerrit-ui/app/elements/gr-app_html.ts
copy to polygerrit-ui/app/utils/math-util.ts
index f6172c9..adec7d3 100644
--- a/polygerrit-ui/app/elements/gr-app_html.ts
+++ b/polygerrit-ui/app/utils/math-util.ts
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2020 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.
@@ -14,8 +14,11 @@
  * 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-app-element id="app-element"></gr-app-element>
-`;
+/**
+ * Returns a random integer between `from` and `to`, both included.
+ * So getRandomInt(0, 2) returns 0, 1, or 2 each with probability 1/3.
+ */
+export function getRandomInt(from: number, to: number) {
+  return Math.floor(Math.random() * (to + 1 - from) + from);
+}
diff --git a/polygerrit-ui/app/utils/math-util_test.ts b/polygerrit-ui/app/utils/math-util_test.ts
new file mode 100644
index 0000000..fca1d73
--- /dev/null
+++ b/polygerrit-ui/app/utils/math-util_test.ts
@@ -0,0 +1,63 @@
+/**
+ * @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 {getRandomInt} from './math-util';
+
+suite('math-util tests', () => {
+  test('getRandomInt', () => {
+    let r = 0;
+    const randomStub = sinon.stub(Math, 'random').callsFake(() => r);
+
+    assert.equal(getRandomInt(0, 0), 0);
+    assert.equal(getRandomInt(0, 2), 0);
+    assert.equal(getRandomInt(0, 100), 0);
+    assert.equal(getRandomInt(10, 10), 10);
+    assert.equal(getRandomInt(10, 12), 10);
+    assert.equal(getRandomInt(10, 100), 10);
+
+    r = 0.999;
+    assert.equal(getRandomInt(0, 0), 0);
+    assert.equal(getRandomInt(0, 2), 2);
+    assert.equal(getRandomInt(0, 100), 100);
+    assert.equal(getRandomInt(10, 10), 10);
+    assert.equal(getRandomInt(10, 12), 12);
+    assert.equal(getRandomInt(10, 100), 100);
+
+    r = 0.5;
+    assert.equal(getRandomInt(0, 0), 0);
+    assert.equal(getRandomInt(0, 2), 1);
+    assert.equal(getRandomInt(0, 100), 50);
+    assert.equal(getRandomInt(10, 10), 10);
+    assert.equal(getRandomInt(10, 12), 11);
+    assert.equal(getRandomInt(10, 100), 55);
+
+    r = 0.0;
+    assert.equal(getRandomInt(0, 2), 0);
+    r = 0.33;
+    assert.equal(getRandomInt(0, 2), 0);
+    r = 0.34;
+    assert.equal(getRandomInt(0, 2), 1);
+    r = 0.66;
+    assert.equal(getRandomInt(0, 2), 1);
+    r = 0.67;
+    assert.equal(getRandomInt(0, 2), 2);
+    r = 0.99;
+    assert.equal(getRandomInt(0, 2), 2);
+
+    randomStub.restore();
+  });
+});
diff --git a/polygerrit-ui/app/elements/gr-app_html.ts b/polygerrit-ui/app/utils/observable-util.ts
similarity index 60%
copy from polygerrit-ui/app/elements/gr-app_html.ts
copy to polygerrit-ui/app/utils/observable-util.ts
index f6172c9..e39aa48 100644
--- a/polygerrit-ui/app/elements/gr-app_html.ts
+++ b/polygerrit-ui/app/utils/observable-util.ts
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2020 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.
@@ -14,8 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {Observable} from 'rxjs';
+import {distinctUntilChanged, map, shareReplay} from 'rxjs/operators';
+import {deepEqual} from './deep-util';
 
-export const htmlTemplate = html`
-  <gr-app-element id="app-element"></gr-app-element>
-`;
+export function select<A, B>(obs$: Observable<A>, mapper: (_: A) => B) {
+  return obs$.pipe(
+    map(mapper),
+    distinctUntilChanged(deepEqual),
+    shareReplay(1)
+  );
+}
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index ce5e5a4..cccb8ec 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -95,7 +95,7 @@
  * @return The correspondent revision obj from {revisions}
  */
 export function getRevisionByPatchNum(
-  revisions: RevisionInfo[],
+  revisions: (RevisionInfo | EditRevisionInfo)[],
   patchNum: PatchSetNum
 ) {
   for (const rev of revisions) {
@@ -103,7 +103,7 @@
       return rev;
     }
   }
-  console.warn('no revision found');
+  if (revisions.length > 0) console.warn('no revision found');
   return;
 }
 
@@ -118,20 +118,26 @@
 }
 
 /**
+ * Find change edit revision if change edit exists.
+ */
+export function findEdit(
+  revisions: Array<RevisionInfo | EditRevisionInfo>
+): EditRevisionInfo | undefined {
+  const editRev = revisions.find(info => info._number === EditPatchSetNum);
+  return editRev as EditRevisionInfo | undefined;
+}
+
+/**
  * Find change edit base revision if change edit exists.
  *
  * @return change edit parent revision or null if change edit
  *     doesn't exist.
- *
  */
 export function findEditParentRevision(
   revisions: Array<RevisionInfo | EditRevisionInfo>
 ) {
-  const editInfo = revisions.find(info => info._number === EditPatchSetNum);
-
-  if (!editInfo) {
-    return null;
-  }
+  const editInfo = findEdit(revisions);
+  if (!editInfo) return null;
 
   return revisions.find(info => info._number === editInfo.basePatchNum) || null;
 }
@@ -309,10 +315,11 @@
  */
 export function findSortedIndex(
   patchNum: PatchSetNum,
-  revisions: RevisionInfo[]
+  revisions: (RevisionInfo | EditRevisionInfo)[]
 ) {
   revisions = revisions || [];
-  const findNum = (rev: RevisionInfo) => `${rev._number}` === `${patchNum}`;
+  const findNum = (rev: RevisionInfo | EditRevisionInfo) =>
+    `${rev._number}` === `${patchNum}`;
   return revisions.findIndex(findNum);
 }
 
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
index 411421e..605241a 100644
--- a/polygerrit-ui/app/utils/path-list-util.ts
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -45,7 +45,7 @@
   const bFile = b.substr(0, bLastDotIndex) || b;
 
   // Sort header files above others with the same base name.
-  const headerExts = ['h', 'hxx', 'hpp'];
+  const headerExts = ['h', 'hh', 'hxx', 'hpp'];
   if (aFile.length > 0 && aFile === bFile) {
     if (headerExts.includes(aExt) && headerExts.includes(bExt)) {
       return a.localeCompare(b);
diff --git a/polygerrit-ui/app/utils/path-list-util_test.ts b/polygerrit-ui/app/utils/path-list-util_test.ts
index 79b5f09..eb071a5 100644
--- a/polygerrit-ui/app/utils/path-list-util_test.ts
+++ b/polygerrit-ui/app/utils/path-list-util_test.ts
@@ -79,6 +79,12 @@
       ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']
     );
 
+    // Regression test for Issue 15635
+    assert.deepEqual(
+      ['manager.cc', 'manager.hh'].sort(specialFilePathCompare),
+      ['manager.hh', 'manager.cc']
+    );
+
     // Regression test for Issue 4448.
     assert.deepEqual(
       [
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 43c0765..0a0928e 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -38,3 +38,16 @@
   if (n % 10 === 3 && n % 100 !== 13) return `${n}rd`;
   return `${n}th`;
 }
+
+/**
+ * This converts any inputed value into string.
+ *
+ * This is so typescript checker doesn't fail.
+ */
+export function convertToString(key?: unknown) {
+  return key !== undefined ? String(key) : '';
+}
+
+export function capitalizeFirstLetter(str: string) {
+  return str.charAt(0).toUpperCase() + str.slice(1);
+}
diff --git a/polygerrit-ui/app/utils/syntax-util.ts b/polygerrit-ui/app/utils/syntax-util.ts
new file mode 100644
index 0000000..03774bd
--- /dev/null
+++ b/polygerrit-ui/app/utils/syntax-util.ts
@@ -0,0 +1,165 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  SyntaxLayerLine,
+  SyntaxLayerRange,
+  UNCLOSED,
+} from '../types/syntax-worker-api';
+
+/**
+ * Utilities related to working with the HighlightJS syntax highlighting lib.
+ *
+ * Note that this utility is mostly used by the syntax-worker, which is a Web
+ * Worker and can thus not depend on document, the DOM or any related
+ * functionality.
+ */
+
+/**
+ * With these expressions you can match exactly what HighlightJS produces. It
+ * is really that simple:
+ * https://github.com/highlightjs/highlight.js/blob/main/src/lib/html_renderer.js
+ */
+const openingSpan = new RegExp('<span class="([^"]*?)">');
+const closingSpan = new RegExp('</span>');
+
+/**
+ * Reverse what HighlightJS does in `escapeHTML()`, see:
+ * https://github.com/highlightjs/highlight.js/blob/main/src/lib/utils.js
+ */
+function unescapeHTML(value: string) {
+  return value
+    .replace(/&#x27;/g, "'")
+    .replace(/&quot;/g, '"')
+    .replace(/&gt;/g, '>')
+    .replace(/&lt;/g, '<')
+    .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
+ * of the line, and is not interested in the HTML that HighlightJS produces.
+ *
+ * So we are splitting the HTML string up into lines and process them one by
+ * one. Each <span> is detected, converted into a SyntaxLayerRange and removed.
+ * Unclosed spans will be carried over to the next line.
+ */
+export function highlightedStringToRanges(
+  highlightedCode: string
+): SyntaxLayerLine[] {
+  // What the function eventually returns.
+  const rangesPerLine: SyntaxLayerLine[] = [];
+  // The unclosed ranges that are carried over from one line to the next.
+  let carryOverRanges: SyntaxLayerRange[] = [];
+
+  for (let line of highlightedCode.split('\n')) {
+    const ranges: SyntaxLayerRange[] = [...carryOverRanges];
+    carryOverRanges = [];
+
+    // 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;
+    while ((removal = removeFirstSpan(line)) !== undefined) {
+      if (removal.type === SpanType.OPENING) {
+        ranges.push({
+          start: removal.offset,
+          length: UNCLOSED,
+          className: removal.class ?? '',
+        });
+      } else {
+        const unclosed = lastUnclosed(ranges);
+        unclosed.length = removal.offset - unclosed.start;
+      }
+      line = removal.lineAfter;
+    }
+
+    // All unclosed spans need to have the length set such that they extend to
+    // the end of the line. And they have to be carried over to the next line
+    // as cloned objects with start:0.
+    const lineLength = line.length;
+    for (const range of ranges) {
+      if (isUnclosed(range)) {
+        carryOverRanges.push({...range, start: 0});
+        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');
+  }
+  return rangesPerLine;
+}
+
+function isUnclosed(range: SyntaxLayerRange) {
+  return range.length === UNCLOSED;
+}
+
+function lastUnclosed(ranges: SyntaxLayerRange[]) {
+  const unclosed = [...ranges].reverse().find(isUnclosed);
+  if (!unclosed) throw new Error(`no unclosed range found ${ranges.length}`);
+  return unclosed;
+}
+
+/** Used for `type` in SpanRemoval. */
+export enum SpanType {
+  OPENING,
+  CLOSING,
+}
+
+/** Return type for removeFirstSpan(). */
+export interface SpanRemoval {
+  type: SpanType;
+  /** The line string after removing the matched span tag. */
+  lineAfter: string;
+  /** The matched css class for OPENING spans. undefined for CLOSING. */
+  class?: string;
+  /** At which char in the line did the removed span tag start? */
+  offset: number;
+}
+
+/**
+ * Finds the first <span ...> or </span>, removes it from the line and returns
+ * details about the removal. Returns `undefined`, if neither is found.
+ */
+export function removeFirstSpan(line: string): SpanRemoval | undefined {
+  const openingMatch = openingSpan.exec(line);
+  const openingIndex = openingMatch?.index ?? Number.MAX_VALUE;
+  const closingMatch = closingSpan.exec(line);
+  const closingIndex = closingMatch?.index ?? Number.MAX_VALUE;
+  if (openingIndex === Number.MAX_VALUE && closingIndex === Number.MAX_VALUE) {
+    return undefined;
+  }
+  const type =
+    openingIndex < closingIndex ? SpanType.OPENING : SpanType.CLOSING;
+  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: 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
new file mode 100644
index 0000000..4d381fb
--- /dev/null
+++ b/polygerrit-ui/app/utils/syntax-util_test.ts
@@ -0,0 +1,242 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup-karma';
+import './syntax-util';
+import {
+  highlightedStringToRanges,
+  removeFirstSpan,
+  SpanType,
+} from './syntax-util';
+
+suite('file syntax-util', () => {
+  suite('function removeFirstSpan()', () => {
+    test('no matches', async () => {
+      assert.isUndefined(removeFirstSpan(''));
+      assert.isUndefined(removeFirstSpan('span'));
+      assert.isUndefined(removeFirstSpan('<span>'));
+      assert.isUndefined(removeFirstSpan('</span'));
+      assert.isUndefined(removeFirstSpan('asdf'));
+    });
+
+    test('simple opening match', async () => {
+      const removal = removeFirstSpan('asdf<span class="c">asdf');
+      assert.deepEqual(removal, {
+        type: SpanType.OPENING,
+        lineAfter: 'asdfasdf',
+        class: 'c',
+        offset: 4,
+      });
+    });
+
+    test('simple closing match', async () => {
+      const removal = removeFirstSpan('asdf</span>asdf');
+      assert.deepEqual(removal, {
+        type: SpanType.CLOSING,
+        lineAfter: 'asdfasdf',
+        class: undefined,
+        offset: 4,
+      });
+    });
+  });
+
+  suite('function highlightedStringToRanges()', () => {
+    test('no ranges', async () => {
+      assert.deepEqual(highlightedStringToRanges(''), [{ranges: []}]);
+      assert.deepEqual(highlightedStringToRanges('\n'), [
+        {ranges: []},
+        {ranges: []},
+      ]);
+      assert.deepEqual(highlightedStringToRanges('asdf\nasdf\nasdf'), [
+        {ranges: []},
+        {ranges: []},
+        {ranges: []},
+      ]);
+    });
+
+    test('one line, one span', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges('asdf<span class="c">qwer</span>asdf'),
+        [{ranges: [{start: 4, length: 4, className: 'c'}]}]
+      );
+      assert.deepEqual(
+        highlightedStringToRanges('<span class="d">asdfqwer</span>'),
+        [{ranges: [{start: 0, length: 8, className: 'd'}]}]
+      );
+    });
+
+    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(
+          'asdf<span class="c">qwer</span>zxcv<span class="d">qwer</span>asdf'
+        ),
+        [
+          {
+            ranges: [
+              {start: 4, length: 4, className: 'c'},
+              {start: 12, length: 4, className: 'd'},
+            ],
+          },
+        ]
+      );
+    });
+
+    test('one line, two nested spans', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          'asdf<span class="c">qwer<span class="d">zxcv</span>qwer</span>asdf'
+        ),
+        [
+          {
+            ranges: [
+              {start: 4, length: 12, className: 'c'},
+              {start: 8, length: 4, className: 'd'},
+            ],
+          },
+        ]
+      );
+    });
+
+    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(
+          'asdf<span class="c">qwer</span>asdf\n' +
+            'asd<span class="d">qwe</span>asd'
+        ),
+        [
+          {ranges: [{start: 4, length: 4, className: 'c'}]},
+          {ranges: [{start: 3, length: 3, className: 'd'}]},
+        ]
+      );
+    });
+
+    test('one span over two lines', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          'asdf<span class="c">qwer\n' + 'asdf</span>qwer'
+        ),
+        [
+          {ranges: [{start: 4, length: 4, className: 'c'}]},
+          {ranges: [{start: 0, length: 4, className: 'c'}]},
+        ]
+      );
+    });
+
+    test('two spans over two lines', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          'asdf<span class="c">qwer<span class="d">zxcv\n' +
+            'asdf</span>qwer</span>zxcv'
+        ),
+        [
+          {
+            ranges: [
+              {start: 4, length: 8, className: 'c'},
+              {start: 8, length: 4, className: 'd'},
+            ],
+          },
+          {
+            ranges: [
+              {start: 0, length: 8, className: 'c'},
+              {start: 0, length: 4, className: 'd'},
+            ],
+          },
+        ]
+      );
+    });
+
+    test('two spans over four lines', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          'asdf<span class="c">qwer\n' +
+            'asdf<span class="d">qwer\n' +
+            'asdf</span>qwer\n' +
+            'asdf</span>qwer'
+        ),
+        [
+          {ranges: [{start: 4, length: 4, className: 'c'}]},
+          {
+            ranges: [
+              {start: 0, length: 8, className: 'c'},
+              {start: 4, length: 4, className: 'd'},
+            ],
+          },
+          {
+            ranges: [
+              {start: 0, length: 8, className: 'c'},
+              {start: 0, length: 4, className: 'd'},
+            ],
+          },
+          {ranges: [{start: 0, length: 4, className: 'c'}]},
+        ]
+      );
+    });
+  });
+});
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..2124fc6 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -1,5 +1,6 @@
-import {ServerInfo} from '../types/common';
+import {AuthInfo, ServerInfo} from '../types/common';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
+import {AuthType} from '../api/rest-api';
 
 /**
  * @license
@@ -24,6 +25,47 @@
   return window.CANONICAL_PATH || '';
 }
 
+/**
+ * Return the url to use for login. If the server configuration
+ * contains the `loginUrl` in the `auth` section then that custom url
+ * will be used, defaults to `/login` otherwise.
+ *
+ * @param authConfig the auth section of gerrit configuration if defined
+ */
+export function loginUrl(authConfig: AuthInfo | undefined): string {
+  const baseUrl = getBaseUrl();
+  const customLoginUrl = authConfig?.login_url;
+  const authType = authConfig?.auth_type;
+  if (
+    customLoginUrl &&
+    (authType === AuthType.HTTP || authType === AuthType.HTTP_LDAP)
+  ) {
+    return customLoginUrl.startsWith('http')
+      ? customLoginUrl
+      : baseUrl + sanitizeRelativeUrl(customLoginUrl);
+  } else {
+    // Strip the canonical path from the path since needing canonical in
+    // the path is unneeded and breaks the url.
+    const defaultUrl = `${baseUrl}/login/`;
+    const postFix = encodeURIComponent(
+      window.location.pathname.substring(baseUrl.length) +
+        window.location.search +
+        window.location.hash
+    );
+    return defaultUrl + postFix;
+  }
+}
+
+function sanitizeRelativeUrl(relativeUrl: string): string {
+  return relativeUrl.startsWith('/') ? relativeUrl : `/${relativeUrl}`;
+}
+
+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/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index 63dc81d..0816e47 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -15,9 +15,13 @@
  * limitations under the License.
  */
 
-import {ServerInfo} from '../api/rest-api';
+import {AuthType, ServerInfo} from '../api/rest-api';
 import '../test/common-test-setup-karma';
-import {createGerritInfo, createServerInfo} from '../test/test-data-generators';
+import {
+  createAuth,
+  createGerritInfo,
+  createServerInfo,
+} from '../test/test-data-generators';
 import {
   getBaseUrl,
   getDocsBaseUrl,
@@ -27,11 +31,13 @@
   toPath,
   toPathname,
   toSearchParams,
+  loginUrl,
 } from './url-util';
-import {appContext} from '../services/app-context';
+import {getAppContext, AppContext} from '../services/app-context';
 import {stubRestApi} from '../test/test-utils';
 
 suite('url-util tests', () => {
+  let appContext: AppContext;
   suite('getBaseUrl tests', () => {
     let originalCanonicalPath: string | undefined;
 
@@ -49,9 +55,55 @@
     });
   });
 
+  suite('loginUrl tests', () => {
+    const authConfig = createAuth();
+    const customLoginUrl = '/custom';
+
+    test('default url if auth.loginUrl is not defined', () => {
+      const current = encodeURIComponent(
+        window.location.pathname + window.location.search + window.location.hash
+      );
+      assert.deepEqual(loginUrl(undefined), '/login/' + current);
+      assert.deepEqual(loginUrl(authConfig), '/login/' + current);
+    });
+
+    test('default url if auth type is not HTTP or HTTP_LDAP', () => {
+      const defaultUrl =
+        '/login/' +
+        encodeURIComponent(
+          window.location.pathname +
+            window.location.search +
+            window.location.hash
+        );
+
+      authConfig.login_url = customLoginUrl;
+      authConfig.auth_type = AuthType.LDAP;
+      assert.deepEqual(loginUrl(authConfig), defaultUrl);
+      authConfig.auth_type = AuthType.OPENID_SSO;
+      assert.deepEqual(loginUrl(authConfig), defaultUrl);
+      authConfig.auth_type = AuthType.OAUTH;
+      assert.deepEqual(loginUrl(authConfig), defaultUrl);
+    });
+
+    test('use auth.loginUrl when defined', () => {
+      authConfig.login_url = customLoginUrl;
+      authConfig.auth_type = AuthType.HTTP;
+      assert.deepEqual(loginUrl(authConfig), customLoginUrl);
+      authConfig.auth_type = AuthType.HTTP_LDAP;
+      assert.deepEqual(loginUrl(authConfig), customLoginUrl);
+    });
+
+    test('auth.loginUrl is sanitized when defined as a relative url', () => {
+      authConfig.login_url = 'custom';
+      authConfig.auth_type = AuthType.HTTP;
+      assert.deepEqual(loginUrl(authConfig), '/custom');
+    });
+  });
+
   suite('getDocsBaseUrl tests', () => {
     setup(() => {
       _testOnly_clearDocsBaseUrlCache();
+      appContext = getAppContext();
     });
 
     test('null config', async () => {
diff --git a/polygerrit-ui/app/utils/worker-util.ts b/polygerrit-ui/app/utils/worker-util.ts
new file mode 100644
index 0000000..4ae41ff
--- /dev/null
+++ b/polygerrit-ui/app/utils/worker-util.ts
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * We cannot import the worker script from cdn directly, because that is
+ * creating cross-origin issues. Instead we have to create a worker script on
+ * the fly and pull the actual worker via `importScripts()`. Apparently that
+ * is a well established pattern.
+ */
+function wrapUrl(url: string) {
+  const content = `importScripts("${url}");`;
+  return URL.createObjectURL(new Blob([content], {type: 'text/javascript'}));
+}
+
+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));
+}
+
+export function importScript(scope: WorkerGlobalScope, url: string): void {
+  scope.importScripts(url);
+}
diff --git a/polygerrit-ui/app/workers/syntax-worker.ts b/polygerrit-ui/app/workers/syntax-worker.ts
new file mode 100644
index 0000000..1ddb9c6
--- /dev/null
+++ b/polygerrit-ui/app/workers/syntax-worker.ts
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {HighlightJS} from '../types/types';
+import {
+  SyntaxWorkerMessage,
+  SyntaxWorkerResult,
+  isRequest,
+  isInit,
+} from '../types/syntax-worker-api';
+import {highlightedStringToRanges} from '../utils/syntax-util';
+import {importScript} from '../utils/worker-util';
+
+// This is an entry point file of a bundle. Keep free of exports!
+
+/**
+ * This is a web worker for calling the HighlightJS library for syntax
+ * highlighting. Files can be large and highlighting does not require
+ * the `document` or the `DOM`, so it is a perfect fit for a web worker.
+ *
+ * This file is a just a hub hooking into the web worker API. The message
+ * events for communicating with the main app are defined in the file
+ * `types/worker-api.ts`. And the `meat` of the computation is done in the
+ * file `syntax-util.ts`.
+ */
+
+/**
+ * `self` is for a worker what `window` is for the web app. It is called
+ * the `DedicatedWorkerGlobalScope`, see
+ * https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope
+ *
+ * Once imported the HighlightJS lib exposes its functionality via the global
+ * `hljs` variable.
+ */
+const ctx = self as DedicatedWorkerGlobalScope & {hljs?: HighlightJS};
+
+/**
+ * We are encapsulating the web worker API here, so this is the only place
+ * where you need to know about it and the MessageEvents in this file.
+ */
+ctx.onmessage = function (e: MessageEvent<SyntaxWorkerMessage>) {
+  try {
+    const message = e.data;
+    if (isInit(message)) {
+      worker.init(message.url);
+      const result: SyntaxWorkerResult = {ranges: []};
+      ctx.postMessage(result);
+    }
+    if (isRequest(message)) {
+      const ranges = worker.highlightCode(message.language, message.code);
+      const result: SyntaxWorkerResult = {ranges};
+      ctx.postMessage(result);
+    }
+  } catch (err) {
+    let error = 'syntax worker error';
+    if (err instanceof Error) error = err.message;
+    const result: SyntaxWorkerResult = {error, ranges: []};
+    ctx.postMessage(result);
+  }
+};
+
+class SyntaxWorker {
+  private highlightJsLib?: HighlightJS;
+
+  init(highlightJsLibUrl: string) {
+    importScript(ctx, highlightJsLibUrl);
+    if (!ctx.hljs) {
+      throw new Error('HighlightJS lib not available after import');
+    }
+    this.highlightJsLib = ctx.hljs;
+    this.highlightJsLib.configure({classPrefix: ''});
+  }
+
+  highlightCode(language: string, code: string) {
+    if (!this.highlightJsLib) throw new Error('worker not initialized');
+    const highlighted = this.highlightJsLib.highlight(language, code, true);
+    return highlightedStringToRanges(highlighted.value);
+  }
+}
+
+/** Singleton instance being referenced in `onmessage` function above. */
+const worker = new SyntaxWorker();
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index d3b22eb..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.0.0":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.1.tgz#853cacd4d78d79059f33f66f8e7b0e5c34bee294"
-  integrity sha512-nSD5AA2AZkKuXuvGs8IK7K5ZczLAogfDd26zT9l6S7WzvqALdVWcW5vMUiTnZyj5SPcNwNNANj0koeV1ieqTFQ==
+"@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,6 +604,33 @@
   resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
   integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
 
+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==
+
+"highlight.js@^11.3.1 || ^10.4.1":
+  version "11.3.1"
+  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"
+  dependencies:
+    highlight.js "^11.3.1 || ^10.4.1"
+
+"highlightjs-structured-text@https://github.com/highlightjs/highlightjs-structured-text":
+  version "1.4.2"
+  resolved "https://github.com/highlightjs/highlightjs-structured-text#4879fbc15ee4a62a08b45fe785b6a6249cbcf62a"
+  dependencies:
+    highlight.js "^10.4.1"
+
 https-proxy-agent@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2"
@@ -652,29 +679,29 @@
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
   integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
 
-lit-element@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.1.tgz#3c545af17d8a46268bc1dd5623a47486e6ff76f4"
-  integrity sha512-vs9uybH9ORyK49CFjoNGN85HM9h5bmisU4TQ63phe/+GYlwvY/3SIFYKdjV6xNvzz8v2MnVC+9+QOkPqh+Q3Ew==
+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.0.0"
-    lit-html "^2.0.0"
+    "@lit/reactive-element" "^1.3.0"
+    lit-html "^2.2.0"
 
-lit-html@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.1.tgz#63241015efa07bc9259b6f96f04abd052d2a1f95"
-  integrity sha512-KF5znvFdXbxTYM/GjpdOOnMsjgRcFGusTnB54ixnCTya5zUR0XqrDRj29ybuLS+jLXv1jji6Y8+g4W7WP8uL4w==
+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.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.2.tgz#5e6f422924e0732258629fb379556b6d23f7179c"
-  integrity sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==
+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.0.0"
-    lit-element "^3.0.0"
-    lit-html "^2.0.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/karma.conf.js b/polygerrit-ui/karma.conf.js
index a3b694f..d656eb7 100644
--- a/polygerrit-ui/karma.conf.js
+++ b/polygerrit-ui/karma.conf.js
@@ -111,6 +111,8 @@
       ...additionalFiles,
       getUiDevNpmFilePath('source-map-support/browser-source-map-support.js'),
       getUiDevNpmFilePath('accessibility-developer-tools/dist/js/axs_testing.js'),
+      {pattern: getUiDevNpmFilePath('@open-wc/semantic-dom-diff/index.js'), type: 'module' },
+      {pattern: getUiDevNpmFilePath('@open-wc/testing-helpers/index.js'), type: 'module' },
       getUiDevNpmFilePath('sinon/pkg/sinon.js'),
       { pattern: testFilesPattern, type: 'module' },
     ],
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 793703e..cdc03aa 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -4,25 +4,25 @@
   "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",
     "@polymer/test-fixture": "^4.0.2",
     "accessibility-developer-tools": "^2.12.0",
     "chai": "^4.3.4",
-    "karma": "^6.3.4",
+    "karma": "^6.3.6",
     "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 7c7ef45..44dd946 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -2,6 +2,13 @@
 # yarn lockfile v1
 
 
+"@babel/code-frame@^7.12.11":
+  version "7.16.0"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.0.tgz#0dfc80309beec8411e65e706461c408b0bb9b431"
+  integrity sha512-IF4EOMEV+bfYwOmNxGzSnjR2EmQod7f1UXOpZM3l4i4o4QNwzjtJAu/HxdjHq0aYBvdqMuQEY1eg0nqW9ZPORA==
+  dependencies:
+    "@babel/highlight" "^7.16.0"
+
 "@babel/code-frame@^7.14.5":
   version "7.14.5"
   resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb"
@@ -218,6 +225,11 @@
   resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz#6654d171b2024f6d8ee151bf2509699919131d48"
   integrity sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==
 
+"@babel/helper-validator-identifier@^7.15.7":
+  version "7.15.7"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz#220df993bfe904a4a6b02ab4f3385a5ebf6e2389"
+  integrity sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w==
+
 "@babel/helper-validator-option@^7.14.5":
   version "7.14.5"
   resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz#6e72a1fff18d5dfcb878e1e62f1a021c4b72d5a3"
@@ -251,6 +263,15 @@
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
+"@babel/highlight@^7.16.0":
+  version "7.16.0"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.16.0.tgz#6ceb32b2ca4b8f5f361fb7fd821e3fddf4a1725a"
+  integrity sha512-t8MH41kUQylBtu2+4IQA3atqevA2lRgqA2wyVB/YiWmsDSuylZZuXOUy9ric30hfzauEFfdsuk/eXTRrGrfd0g==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.15.7"
+    chalk "^2.0.0"
+    js-tokens "^4.0.0"
+
 "@babel/parser@^7.1.0", "@babel/parser@^7.14.5", "@babel/parser@^7.15.0", "@babel/parser@^7.4.3":
   version "7.15.3"
   resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.15.3.tgz#3416d9bea748052cfcb63dbcc27368105b1ed862"
@@ -882,7 +903,33 @@
   dependencies:
     vary "^1.1.2"
 
-"@open-wc/building-utils@^2.18.0", "@open-wc/building-utils@^2.18.3":
+"@lit/reactive-element@^1.0.0":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.2.tgz#daa7a7c7a6c63d735f0c9634de6b7dbd70a702ab"
+  integrity sha512-oz3d3MKjQ2tXynQgyaQaMpGTDNyNDeBdo6dXf1AbjTwhA1IRINHmA7kSaVYv9ttKweNkEoNqp9DqteDdgWzPEg==
+
+"@nodelib/fs.scandir@2.1.5":
+  version "2.1.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
+  integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
+  dependencies:
+    "@nodelib/fs.stat" "2.0.5"
+    run-parallel "^1.1.9"
+
+"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b"
+  integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
+
+"@nodelib/fs.walk@^1.2.3":
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
+  integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
+  dependencies:
+    "@nodelib/fs.scandir" "2.1.5"
+    fastq "^1.6.0"
+
+"@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==
@@ -914,22 +961,52 @@
     whatwg-fetch "^3.5.0"
     whatwg-url "^7.1.0"
 
-"@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/dedupe-mixin@^1.3.0":
+  version "1.3.0"
+  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@^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"
 
+"@open-wc/scoped-elements@^2.0.1":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.0.1.tgz#6b1c3535f809bd90710574db80093a81e3a1fc2d"
+  integrity sha512-JS6ozxUFwFX3+Er91v9yQzNIaFn7OnE0iESKTbFvkkKdNwvAPtp1fpckBKIvWk8Ae9ZcoI9DYZuT2DDbMPcadA==
+  dependencies:
+    "@lit/reactive-element" "^1.0.0"
+    "@open-wc/dedupe-mixin" "^1.3.0"
+    "@webcomponents/scoped-custom-element-registry" "^0.0.3"
+
+"@open-wc/semantic-dom-diff@^0.19.5":
+  version "0.19.5"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.19.5.tgz#8d3d7f69140b9ba477a4adf8099c79e0efe18955"
+  integrity sha512-Wi0Fuj3dzqlWClU0y+J4k/nqTcH0uwgOWxZXPyeyG3DdvuyyjgiT4L4I/s6iVShWQvvEsyXnj7yVvixAo3CZvg==
+  dependencies:
+    "@types/chai" "^4.2.11"
+    "@web/test-runner-commands" "^0.5.7"
+
+"@open-wc/testing-helpers@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.0.2.tgz#ca1833bf76036d9bdc03547415e79b6d502c78f6"
+  integrity sha512-wJlvDmWo+fIbgykRP21YSP9I9Pf/fo2+dZGaWG77Hw0sIuyB+7sNUDJDkL6kMkyyRecPV6dVRmbLt6HuOwvZ1w==
+  dependencies:
+    "@open-wc/scoped-elements" "^2.0.1"
+    lit "^2.0.0"
+
 "@polymer/iron-test-helpers@^3.0.1":
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@polymer/iron-test-helpers/-/iron-test-helpers-3.0.1.tgz#ec2b9c6567e2967a191b3d800a04b1167b2d1394"
@@ -1004,6 +1081,11 @@
   dependencies:
     "@types/node" "*"
 
+"@types/babel__code-frame@^7.0.2":
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/@types/babel__code-frame/-/babel__code-frame-7.0.3.tgz#eda94e1b7c9326700a4b69c485ebbc9498a0b63f"
+  integrity sha512-2TN6oiwtNjOezilFVl77zwdNPwQWaDBBCCWWxyo1ctiO3vAtd7H/aB/CBJdw9+kqq3+latD0SXoedIuHySSZWw==
+
 "@types/babel__core@^7.1.3":
   version "7.1.15"
   resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.15.tgz#2ccfb1ad55a02c83f8e0ad327cbc332f55eb1024"
@@ -1062,11 +1144,24 @@
   resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.2.tgz#684ba0c284b2a58346abf0000bd0a735ad072d75"
   integrity sha512-YfCDMn7R59n7GFFfwjPAM0zLJQy4UvveC32rOJBmTqJJY8uSRqM4Dc7IJj8V9unA48Qy4nj5Bj3jD6Q8VZ1Seg==
 
+"@types/chai@^4.2.11":
+  version "4.2.22"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.22.tgz#47020d7e4cf19194d43b5202f35f75bd2ad35ce7"
+  integrity sha512-tFfcE+DSTzWAgifkjik9AySNqIyNoYwmR+uecPwwD/XRNfvOjmC/FjCxpiUGDkDVDphPfCUecSQVFw+lN3M3kQ==
+
 "@types/chai@^4.2.16":
   version "4.2.21"
   resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.21.tgz#9f35a5643129df132cf3b5c1ec64046ea1af0650"
   integrity sha512-yd+9qKmJxm496BOV9CMNaey8TWsikaZOwMRwPHQIjcOJM9oV+fi9ZMNw3JsVnbEEbo2gRTDnGEBv8pjyn67hNg==
 
+"@types/co-body@^6.1.0":
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/@types/co-body/-/co-body-6.1.0.tgz#b52625390eb0d113c9b697ea92c3ffae7740cdb9"
+  integrity sha512-3e0q2jyDAnx/DSZi0z2H0yoZ2wt5yRDZ+P7ymcMObvq0ufWRT4tsajyO+Q1VwVWiv9PRR4W3YEjEzBjeZlhF+w==
+  dependencies:
+    "@types/node" "*"
+    "@types/qs" "*"
+
 "@types/command-line-args@^5.0.0":
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.0.tgz#adbb77980a1cc376bb208e3f4142e907410430f6"
@@ -1094,7 +1189,12 @@
   resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.4.tgz#de48cf01c79c9f1560bcfd8ae43217ab028657f8"
   integrity sha512-0mPF08jn9zYI0n0Q/Pnz7C4kThdSt+6LD4amsrYDDpgBfrVWa3TcCOxKX1zkGgYniGagRv8heN2cbh+CAn+uuQ==
 
-"@types/cookie@^0.4.0":
+"@types/convert-source-map@^1.5.1":
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-1.5.2.tgz#318dc22d476632a4855594c16970c6dc3ed086e7"
+  integrity sha512-tHs++ZeXer40kCF2JpE51Hg7t4HPa18B1b1Dzy96S0eCw8QKECNMYMfwa1edK/x8yCN0r4e6ewvLcc5CsVGkdg==
+
+"@types/cookie@^0.4.1":
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d"
   integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==
@@ -1109,7 +1209,7 @@
     "@types/keygrip" "*"
     "@types/node" "*"
 
-"@types/cors@^2.8.8":
+"@types/cors@^2.8.12":
   version "2.8.12"
   resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
   integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==
@@ -1160,6 +1260,25 @@
   resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.1.tgz#e81ad28a60bee0328c6d2384e029aec626f1ae67"
   integrity sha512-e+2rjEwK6KDaNOm5Aa9wNGgyS9oSZU/4pfSMMPYNOfjvFI0WVXm29+ITRFr6aKDvvKo7uU1jV68MW4ScsfDi7Q==
 
+"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.3":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762"
+  integrity sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw==
+
+"@types/istanbul-lib-report@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686"
+  integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==
+  dependencies:
+    "@types/istanbul-lib-coverage" "*"
+
+"@types/istanbul-reports@^3.0.0":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff"
+  integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==
+  dependencies:
+    "@types/istanbul-lib-report" "*"
+
 "@types/keygrip@*":
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72"
@@ -1203,7 +1322,7 @@
     "@types/koa" "*"
     "@types/koa-send" "*"
 
-"@types/koa@*", "@types/koa@^2.0.48":
+"@types/koa@*", "@types/koa@^2.0.48", "@types/koa@^2.11.6":
   version "2.13.4"
   resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.4.tgz#10620b3f24a8027ef5cbae88b393d1b31205726b"
   integrity sha512-dfHYMfU+z/vKtQB7NUrthdAEiSvnLebvBjwHtfFmpZmB7em2N3WVQdHgnFq+xvyVgxW5jKDmjWfLD3lw4g4uTw==
@@ -1224,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"
@@ -1259,6 +1373,11 @@
   resolved "https://registry.yarnpkg.com/@types/node/-/node-16.6.1.tgz#aee62c7b966f55fc66c7b6dfa1d58db2a616da61"
   integrity sha512-Sr7BhXEAer9xyGuCN3Ek9eg9xPviCF2gfu9kTfuU2HkTVAMYSDeX40fvpmo72n5nansg3nsBjuQBrsS28r+NUw==
 
+"@types/parse5@^6.0.1":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.2.tgz#99f6b72d82e34cea03a4d8f2ed72114d909c1c61"
+  integrity sha512-+hQX+WyJAOne7Fh3zF5CxPemILIbuhNcqHHodzK9caYOLnC8pD5efmPleRnw0z++LfKUC/sVNMwk0Gap+B0baA==
+
 "@types/path-is-inside@^1.0.0":
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.0.tgz#02d6ff38975d684bdec96204494baf9f29f0e17f"
@@ -1296,6 +1415,11 @@
   dependencies:
     "@sinonjs/fake-timers" "^7.1.0"
 
+"@types/trusted-types@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
+  integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
+
 "@types/whatwg-url@^6.4.0":
   version "6.4.0"
   resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-6.4.0.tgz#1e59b8c64bc0dbdf66d037cf8449d1c3d5270237"
@@ -1303,11 +1427,102 @@
   dependencies:
     "@types/node" "*"
 
+"@types/ws@^7.4.0":
+  version "7.4.7"
+  resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702"
+  integrity sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==
+  dependencies:
+    "@types/node" "*"
+
 "@ungap/promise-all-settled@1.1.2":
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44"
   integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==
 
+"@web/browser-logs@^0.2.1":
+  version "0.2.5"
+  resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.2.5.tgz#0895efb641eacb0fbc1138c6092bd18c01df2734"
+  integrity sha512-Qxo1wY/L7yILQqg0jjAaueh+tzdORXnZtxQgWH23SsTCunz9iq9FvsZa8Q5XlpjnZ3vLIsFEuEsCMqFeohJnEg==
+  dependencies:
+    errorstacks "^2.2.0"
+
+"@web/dev-server-core@^0.3.16":
+  version "0.3.17"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.3.17.tgz#95e87681b63644a955e29e13ffc6b48fd2c51264"
+  integrity sha512-vN1dwQ8yDHGiAvCeUo9xFfjo+pFl8TW+pON7k9kfhbegrrB8CKhJDUxmHbZsyQUmjf/iX57/LhuWj1xGhRL8AA==
+  dependencies:
+    "@types/koa" "^2.11.6"
+    "@types/ws" "^7.4.0"
+    "@web/parse5-utils" "^1.2.0"
+    chokidar "^3.4.3"
+    clone "^2.1.2"
+    es-module-lexer "^0.9.0"
+    get-stream "^6.0.0"
+    is-stream "^2.0.0"
+    isbinaryfile "^4.0.6"
+    koa "^2.13.0"
+    koa-etag "^4.0.0"
+    koa-send "^5.0.1"
+    koa-static "^5.0.0"
+    lru-cache "^6.0.0"
+    mime-types "^2.1.27"
+    parse5 "^6.0.1"
+    picomatch "^2.2.2"
+    ws "^7.4.2"
+
+"@web/parse5-utils@^1.2.0":
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-1.3.0.tgz#e2e9e98b31a4ca948309f74891bda8d77399f6bd"
+  integrity sha512-Pgkx3ECc8EgXSlS5EyrgzSOoUbM6P8OKS471HLAyvOBcP1NCBn0to4RN/OaKASGq8qa3j+lPX9H14uA5AHEnQg==
+  dependencies:
+    "@types/parse5" "^6.0.1"
+    parse5 "^6.0.1"
+
+"@web/test-runner-commands@^0.5.7":
+  version "0.5.13"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.5.13.tgz#57ea472c00ee2ada99eb9bb5a0371200922707c2"
+  integrity sha512-FXnpUU89ALbRlh9mgBd7CbSn5uzNtr8gvnQZPOvGLDAJ7twGvZdUJEAisPygYx2BLPSFl3/Mre8pH8zshJb8UQ==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    mkdirp "^1.0.4"
+
+"@web/test-runner-core@^0.10.20":
+  version "0.10.22"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.10.22.tgz#34bb67d12a79b01dc79c816f3d76f3419ef50eaf"
+  integrity sha512-0jzJIl/PTZa6PCG/noHAFZT2DTcp+OYGmYOnZ2wcHAO3KwtJKnBVSuxgdOzFdmfvoO7TYAXo5AH+MvTZXMWsZw==
+  dependencies:
+    "@babel/code-frame" "^7.12.11"
+    "@types/babel__code-frame" "^7.0.2"
+    "@types/co-body" "^6.1.0"
+    "@types/convert-source-map" "^1.5.1"
+    "@types/debounce" "^1.2.0"
+    "@types/istanbul-lib-coverage" "^2.0.3"
+    "@types/istanbul-reports" "^3.0.0"
+    "@web/browser-logs" "^0.2.1"
+    "@web/dev-server-core" "^0.3.16"
+    chokidar "^3.4.3"
+    cli-cursor "^3.1.0"
+    co-body "^6.1.0"
+    convert-source-map "^1.7.0"
+    debounce "^1.2.0"
+    dependency-graph "^0.11.0"
+    globby "^11.0.1"
+    ip "^1.1.5"
+    istanbul-lib-coverage "^3.0.0"
+    istanbul-lib-report "^3.0.0"
+    istanbul-reports "^3.0.2"
+    log-update "^4.0.0"
+    nanocolors "^0.2.1"
+    nanoid "^3.1.25"
+    open "^8.0.2"
+    picomatch "^2.2.2"
+    source-map "^0.7.3"
+
+"@webcomponents/scoped-custom-element-registry@^0.0.3":
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/@webcomponents/scoped-custom-element-registry/-/scoped-custom-element-registry-0.0.3.tgz#774591a886b0b0e4914717273ba53fd8d5657522"
+  integrity sha512-lpSzgDCGbM99dytb3+J3Suo4+Bk1E13MPnWB42JK8GwxSAxFz+tC7TTv2hhDSIE2IirGNKNKCf3m08ecu6eAsQ==
+
 "@webcomponents/shadycss@^1.10.2", "@webcomponents/shadycss@^1.9.1":
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
@@ -1351,6 +1566,13 @@
   resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
   integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
 
+ansi-escapes@^4.3.0:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
+  integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==
+  dependencies:
+    type-fest "^0.21.3"
+
 ansi-regex@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
@@ -1408,6 +1630,11 @@
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e"
   integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==
 
+array-union@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
+  integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==
+
 arrify@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
@@ -1430,6 +1657,11 @@
   resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
   integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
 
+astral-regex@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
+  integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
+
 async@^2.6.2:
   version "2.6.3"
   resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
@@ -1498,10 +1730,10 @@
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
   integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
 
-base64-arraybuffer@0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz#9818c79e059b1355f97e0428a017c838e90ba812"
-  integrity sha1-mBjHngWbE1X5fgQooBfIOOkLqBI=
+base64-arraybuffer@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.1.tgz#87bd13525626db4a9838e00a508c2b73efcf348c"
+  integrity sha512-vFIUq7FdLtjZMhATwDul5RZWv2jpXQ09Pd6jcVEOvIsqCWTRFD/ONHNfyOS8dA/Ippi5dsIgpyKWKZaAKZltbA==
 
 base64id@2.0.0, base64id@~2.0.0:
   version "2.0.0"
@@ -1544,7 +1776,7 @@
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
-braces@^3.0.2, braces@~3.0.2:
+braces@^3.0.1, braces@^3.0.2, braces@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
   integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
@@ -1716,6 +1948,13 @@
   dependencies:
     source-map "~0.6.0"
 
+cli-cursor@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
+  integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==
+  dependencies:
+    restore-cursor "^3.1.0"
+
 cliui@^7.0.2:
   version "7.0.4"
   resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
@@ -1730,6 +1969,16 @@
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
   integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
 
+co-body@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/co-body/-/co-body-6.1.0.tgz#d87a8efc3564f9bfe3aced8ef5cd04c7a8766547"
+  integrity sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==
+  dependencies:
+    inflation "^2.0.0"
+    qs "^6.5.2"
+    raw-body "^2.3.3"
+    type-is "^1.6.16"
+
 co@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -1939,7 +2188,7 @@
   dependencies:
     ms "^2.1.1"
 
-debug@^4.1.0, debug@^4.1.1, debug@~4.3.1:
+debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@~4.3.1, debug@~4.3.2:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
   integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
@@ -1980,6 +2229,11 @@
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
   integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
 
+define-lazy-prop@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
+  integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
+
 define-properties@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
@@ -2007,6 +2261,11 @@
   resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
   integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
 
+dependency-graph@^0.11.0:
+  version "0.11.0"
+  resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27"
+  integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==
+
 destroy@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
@@ -2027,6 +2286,13 @@
   resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
   integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
 
+dir-glob@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
+  integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==
+  dependencies:
+    path-type "^4.0.0"
+
 dom-serialize@^2.2.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b"
@@ -2085,25 +2351,28 @@
   dependencies:
     once "^1.4.0"
 
-engine.io-parser@~4.0.0:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-4.0.2.tgz#e41d0b3fb66f7bf4a3671d2038a154024edb501e"
-  integrity sha512-sHfEQv6nmtJrq6TKuIz5kyEKH/qSdK56H/A+7DnAuUPWosnIZAS2NHNcPLmyjtY3cGS/MqJdZbUjW97JU72iYg==
+engine.io-parser@~5.0.0:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.0.1.tgz#6695fc0f1e6d76ad4a48300ff80db5f6b3654939"
+  integrity sha512-j4p3WwJrG2k92VISM0op7wiq60vO92MlF3CRGxhKHy9ywG1/Dkc72g0dXeDQ+//hrcDn8gqQzoEkdO9FN0d9AA==
   dependencies:
-    base64-arraybuffer "0.1.4"
+    base64-arraybuffer "~1.0.1"
 
-engine.io@~4.1.0:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-4.1.1.tgz#9a8f8a5ac5a5ea316183c489bf7f5b6cf91ace5b"
-  integrity sha512-t2E9wLlssQjGw0nluF6aYyfX8LwYU8Jj0xct+pAhfWfv/YrBn6TSNtEYsgxHIfaMqfrLx07czcMg9bMN6di+3w==
+engine.io@~6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.0.0.tgz#2b993fcd73e6b3a6abb52b40b803651cd5747cf0"
+  integrity sha512-Ui7yl3JajEIaACg8MOUwWvuuwU7jepZqX3BKs1ho7NQRuP4LhN4XIykXhp8bEy+x/DhA0LBZZXYSCkZDqrwMMg==
   dependencies:
+    "@types/cookie" "^0.4.1"
+    "@types/cors" "^2.8.12"
+    "@types/node" ">=10.0.0"
     accepts "~1.3.4"
     base64id "2.0.0"
     cookie "~0.4.1"
     cors "~2.8.5"
     debug "~4.3.1"
-    engine.io-parser "~4.0.0"
-    ws "~7.4.2"
+    engine.io-parser "~5.0.0"
+    ws "~8.2.3"
 
 ent@~2.2.0:
   version "2.2.0"
@@ -2117,7 +2386,12 @@
   dependencies:
     is-arrayish "^0.2.1"
 
-es-dev-server@^1.57.0:
+errorstacks@^2.2.0:
+  version "2.3.2"
+  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.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==
@@ -2193,6 +2467,11 @@
   resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.3.26.tgz#7b507044e97d5b03b01d4392c74ffeb9c177a83b"
   integrity sha512-Va0Q/xqtrss45hWzP8CZJwzGSZJjDM5/MJRE3IXXnUCcVLElR9BRaE9F62BopysASyc4nM3uwhSW7FFB9nlWAA==
 
+es-module-lexer@^0.9.0:
+  version "0.9.3"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19"
+  integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==
+
 es-module-shims@^0.4.6, es-module-shims@^0.4.7:
   version "0.4.7"
   resolved "https://registry.yarnpkg.com/es-module-shims/-/es-module-shims-0.4.7.tgz#1419b65bbd38dfe91ab8ea5d7b4b454561e44641"
@@ -2228,7 +2507,7 @@
   resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
   integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
 
-etag@^1.3.0:
+etag@^1.3.0, etag@^1.8.1:
   version "1.8.1"
   resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
   integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
@@ -2258,11 +2537,29 @@
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
   integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
 
+fast-glob@^3.1.1:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1"
+  integrity sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==
+  dependencies:
+    "@nodelib/fs.stat" "^2.0.2"
+    "@nodelib/fs.walk" "^1.2.3"
+    glob-parent "^5.1.2"
+    merge2 "^1.3.0"
+    micromatch "^4.0.4"
+
 fast-json-stable-stringify@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
   integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
 
+fastq@^1.6.0:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c"
+  integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==
+  dependencies:
+    reusify "^1.0.4"
+
 fill-range@^7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
@@ -2394,6 +2691,11 @@
   dependencies:
     pump "^3.0.0"
 
+get-stream@^6.0.0:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
+  integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
+
 getpass@^0.1.1:
   version "0.1.7"
   resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
@@ -2401,7 +2703,7 @@
   dependencies:
     assert-plus "^1.0.0"
 
-glob-parent@~5.1.0, glob-parent@~5.1.2:
+glob-parent@^5.1.2, glob-parent@~5.1.0, glob-parent@~5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
   integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
@@ -2437,6 +2739,18 @@
   resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
   integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
 
+globby@^11.0.1:
+  version "11.0.4"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5"
+  integrity sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==
+  dependencies:
+    array-union "^2.1.0"
+    dir-glob "^3.0.1"
+    fast-glob "^3.1.1"
+    ignore "^5.1.4"
+    merge2 "^1.3.0"
+    slash "^3.0.0"
+
 graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.6:
   version "4.2.8"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
@@ -2499,6 +2813,11 @@
   resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
   integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
 
+html-escaper@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453"
+  integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==
+
 html-minifier-terser@^5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz#922e96f1f3bb60832c2634b79884096389b1f054"
@@ -2531,6 +2850,17 @@
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
+http-errors@1.7.3, http-errors@~1.7.2:
+  version "1.7.3"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
+  integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.4"
+    setprototypeof "1.1.1"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.0"
+
 http-errors@^1.6.3, http-errors@^1.7.3:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.0.tgz#75d1bbe497e1044f51e4ee9e704a62f28d336507"
@@ -2552,17 +2882,6 @@
     setprototypeof "1.1.0"
     statuses ">= 1.4.0 < 2"
 
-http-errors@~1.7.2:
-  version "1.7.3"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
-  integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.4"
-    setprototypeof "1.1.1"
-    statuses ">= 1.5.0 < 2"
-    toidentifier "1.0.0"
-
 http-proxy@^1.18.1:
   version "1.18.1"
   resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
@@ -2588,6 +2907,16 @@
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
+ignore@^5.1.4:
+  version "5.1.9"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.9.tgz#9ec1a5cbe8e1446ec60d4420060d43aa6e7382fb"
+  integrity sha512-2zeMQpbKz5dhZ9IwL0gbxSW5w0NK/MSAMtNuhgIHEPmaU3vPdKPL0UdvUCXs5SS4JAwsBxysK5sFMW8ocFiVjQ==
+
+inflation@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f"
+  integrity sha1-i0F+R8KPklpFEz2RTKH9OJEH8w8=
+
 inflight@^1.0.4:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
@@ -2611,6 +2940,11 @@
   resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.7.0.tgz#ee16bee978db53516ead2f0a8154b09b400bbdc9"
   integrity sha512-Id0Fij0HsB/vKWGeBe9PxeY45ttRiBmhFyyt/geBdDHBYNctMRTE3dC1U3ujzz3lap+hVXlEcVaB56kZP/eEUg==
 
+ip@^1.1.5:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
+  integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=
+
 is-arrayish@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
@@ -2630,7 +2964,7 @@
   dependencies:
     has "^1.0.3"
 
-is-docker@^2.0.0:
+is-docker@^2.0.0, is-docker@^2.1.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
   integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
@@ -2689,7 +3023,7 @@
   resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
   integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
 
-is-wsl@^2.1.1:
+is-wsl@^2.1.1, is-wsl@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
   integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
@@ -2701,7 +3035,7 @@
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
   integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
 
-isbinaryfile@^4.0.2, isbinaryfile@^4.0.8:
+isbinaryfile@^4.0.2, isbinaryfile@^4.0.6, isbinaryfile@^4.0.8:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.8.tgz#5d34b94865bd4946633ecc78a026fc76c5b11fcf"
   integrity sha512-53h6XFniq77YdW+spoRrebh0mnmTxRPTlcuIArO57lmMdq4uBKFKaeTjnb92oYWrSn/LVL+LT+Hap2tFQj8V+w==
@@ -2721,6 +3055,11 @@
   resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz#675f0ab69503fad4b1d849f736baaca803344f49"
   integrity sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==
 
+istanbul-lib-coverage@^3.0.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3"
+  integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==
+
 istanbul-lib-instrument@^3.3.0:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-3.3.0.tgz#a5f63d91f0bbc0c3e479ef4c5de027335ec6d630"
@@ -2734,6 +3073,23 @@
     istanbul-lib-coverage "^2.0.5"
     semver "^6.0.0"
 
+istanbul-lib-report@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6"
+  integrity sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==
+  dependencies:
+    istanbul-lib-coverage "^3.0.0"
+    make-dir "^3.0.0"
+    supports-color "^7.1.0"
+
+istanbul-reports@^3.0.2:
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.0.5.tgz#a2580107e71279ea6d661ddede929ffc6d693384"
+  integrity sha512-5+19PlhnGabNWB7kOFnuxT8H3T/iIyQzIbQMxXsURmmvKg86P2sbkrGOT77VnHw0Qr0gc2XzRaRfMZYYbSQCJQ==
+  dependencies:
+    html-escaper "^2.0.0"
+    istanbul-lib-report "^3.0.0"
+
 js-tokens@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@@ -2833,10 +3189,10 @@
   dependencies:
     minimist "^1.2.3"
 
-karma@^6.3.4:
-  version "6.3.4"
-  resolved "https://registry.yarnpkg.com/karma/-/karma-6.3.4.tgz#359899d3aab3d6b918ea0f57046fd2a6b68565e6"
-  integrity sha512-hbhRogUYIulfkBTZT7xoPrCYhRBnBoqbbL4fszWD0ReFGUxU+LYBr3dwKdAluaDQ/ynT9/7C+Lf7pPNW4gSx4Q==
+karma@^6.3.6:
+  version "6.3.6"
+  resolved "https://registry.yarnpkg.com/karma/-/karma-6.3.6.tgz#6f64cdd558c7d0c9da6fcdece156089582694611"
+  integrity sha512-xsiu3D6AjCv6Uq0YKXJgC6TvXX2WloQ5+XtHXmC1lwiLVG617DDV3W2DdM4BxCMKHlmz6l3qESZHFQGHAKvrew==
   dependencies:
     body-parser "^1.19.0"
     braces "^3.0.2"
@@ -2856,10 +3212,10 @@
     qjobs "^1.2.0"
     range-parser "^1.2.1"
     rimraf "^3.0.2"
-    socket.io "^3.1.0"
+    socket.io "^4.2.0"
     source-map "^0.6.1"
     tmp "^0.2.1"
-    ua-parser-js "^0.7.28"
+    ua-parser-js "^0.7.30"
     yargs "^16.1.1"
 
 keygrip@~1.1.0:
@@ -2899,6 +3255,14 @@
     co "^4.6.0"
     koa-compose "^3.0.0"
 
+koa-convert@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-2.0.0.tgz#86a0c44d81d40551bae22fee6709904573eea4f5"
+  integrity sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==
+  dependencies:
+    co "^4.6.0"
+    koa-compose "^4.1.0"
+
 koa-etag@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/koa-etag/-/koa-etag-3.0.0.tgz#9ef7382ddd5a82ab0deb153415c915836f771d3f"
@@ -2907,12 +3271,19 @@
     etag "^1.3.0"
     mz "^2.1.0"
 
+koa-etag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/koa-etag/-/koa-etag-4.0.0.tgz#2c2bb7ae69ca1ac6ced09ba28dcb78523c810414"
+  integrity sha512-1cSdezCkBWlyuB9l6c/IFoe1ANCDdPBxkDkRiaIup40xpUub6U/wwRXoKBZw/O5BifX9OlqAjYnDyzM6+l+TAg==
+  dependencies:
+    etag "^1.8.1"
+
 koa-is-json@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/koa-is-json/-/koa-is-json-1.0.0.tgz#273c07edcdcb8df6a2c1ab7d59ee76491451ec14"
   integrity sha1-JzwH7c3Ljfaiwat9We52SRRR7BQ=
 
-koa-send@^5.0.0:
+koa-send@^5.0.0, koa-send@^5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/koa-send/-/koa-send-5.0.1.tgz#39dceebfafb395d0d60beaffba3a70b4f543fe79"
   integrity sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==
@@ -2929,6 +3300,35 @@
     debug "^3.1.0"
     koa-send "^5.0.0"
 
+koa@^2.13.0:
+  version "2.13.4"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.4.tgz#ee5b0cb39e0b8069c38d115139c774833d32462e"
+  integrity sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==
+  dependencies:
+    accepts "^1.3.5"
+    cache-content-type "^1.0.0"
+    content-disposition "~0.5.2"
+    content-type "^1.0.4"
+    cookies "~0.8.0"
+    debug "^4.3.2"
+    delegates "^1.0.0"
+    depd "^2.0.0"
+    destroy "^1.0.4"
+    encodeurl "^1.0.2"
+    escape-html "^1.0.3"
+    fresh "~0.5.2"
+    http-assert "^1.3.0"
+    http-errors "^1.6.3"
+    is-generator-function "^1.0.7"
+    koa-compose "^4.1.0"
+    koa-convert "^2.0.0"
+    on-finished "^2.3.0"
+    only "~0.0.2"
+    parseurl "^1.3.2"
+    statuses "^1.5.0"
+    type-is "^1.6.16"
+    vary "^1.1.2"
+
 koa@^2.7.0:
   version "2.13.1"
   resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.1.tgz#6275172875b27bcfe1d454356a5b6b9f5a9b1051"
@@ -2958,6 +3358,30 @@
     type-is "^1.6.16"
     vary "^1.1.2"
 
+lit-element@^3.0.0:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.2.tgz#6422b68ba166a32695f524d6f3eb41712610bf50"
+  integrity sha512-9vTJ47D2DSE4Jwhle7aMzEwO2ZcOPRikqfT3CVG7Qol2c9/I4KZwinZNW5Xv8hNm+G/enSSfIwqQhIXi6ioAUg==
+  dependencies:
+    "@lit/reactive-element" "^1.0.0"
+    lit-html "^2.0.0"
+
+lit-html@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.2.tgz#6a17caac4135757710c5fb3e4becc622c476e431"
+  integrity sha512-dON7Zg8btb14/fWohQLQBdSgkoiQA4mIUy87evmyJHtxRq7zS6LlC32bT5EPWiof5PUQaDpF45v2OlrxHA5Clg==
+  dependencies:
+    "@types/trusted-types" "^2.0.2"
+
+lit@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.2.tgz#5e6f422924e0732258629fb379556b6d23f7179c"
+  integrity sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==
+  dependencies:
+    "@lit/reactive-element" "^1.0.0"
+    lit-element "^3.0.0"
+    lit-html "^2.0.0"
+
 load-json-file@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b"
@@ -3032,6 +3456,16 @@
   dependencies:
     chalk "^2.0.1"
 
+log-update@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1"
+  integrity sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==
+  dependencies:
+    ansi-escapes "^4.3.0"
+    cli-cursor "^3.1.0"
+    slice-ansi "^4.0.0"
+    wrap-ansi "^6.2.0"
+
 log4js@^6.3.0:
   version "6.3.0"
   resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.3.0.tgz#10dfafbb434351a3e30277a00b9879446f715bcb"
@@ -3072,11 +3506,31 @@
   dependencies:
     yallist "^4.0.0"
 
+make-dir@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
+  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
+  dependencies:
+    semver "^6.0.0"
+
 media-typer@0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
   integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
 
+merge2@^1.3.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
+  integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
+
+micromatch@^4.0.4:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9"
+  integrity sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==
+  dependencies:
+    braces "^3.0.1"
+    picomatch "^2.2.3"
+
 mime-db@1.49.0, "mime-db@>= 1.43.0 < 2":
   version "1.49.0"
   resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.49.0.tgz#f3dfde60c99e9cf3bc9701d687778f537001cbed"
@@ -3094,6 +3548,11 @@
   resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe"
   integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==
 
+mimic-fn@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
+  integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
+
 minimatch@3.0.4, minimatch@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
@@ -3113,6 +3572,11 @@
   dependencies:
     minimist "^1.2.5"
 
+mkdirp@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
+  integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
+
 mocha@8.3.2:
   version "8.3.2"
   resolved "https://registry.yarnpkg.com/mocha/-/mocha-8.3.2.tgz#53406f195fa86fbdebe71f8b1c6fb23221d69fcc"
@@ -3168,11 +3632,21 @@
     object-assign "^4.0.1"
     thenify-all "^1.0.0"
 
+nanocolors@^0.2.1:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/nanocolors/-/nanocolors-0.2.13.tgz#dfd1ed0bfab05e9fe540eb6874525f0a1684099b"
+  integrity sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==
+
 nanoid@3.1.20:
   version "3.1.20"
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788"
   integrity sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==
 
+nanoid@^3.1.25:
+  version "3.1.30"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.30.tgz#63f93cc548d2a113dc5dfbc63bfa09e2b9b64362"
+  integrity sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ==
+
 negotiator@0.6.2:
   version "0.6.2"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
@@ -3232,6 +3706,11 @@
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
   integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
 
+object-inspect@^1.9.0:
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1"
+  integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==
+
 object-keys@^1.0.12, object-keys@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
@@ -3261,6 +3740,13 @@
   dependencies:
     wrappy "1"
 
+onetime@^5.1.0:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
+  integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
+  dependencies:
+    mimic-fn "^2.1.0"
+
 only@~0.0.2:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4"
@@ -3274,6 +3760,15 @@
     is-docker "^2.0.0"
     is-wsl "^2.1.1"
 
+open@^8.0.2:
+  version "8.4.0"
+  resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8"
+  integrity sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==
+  dependencies:
+    define-lazy-prop "^2.0.0"
+    is-docker "^2.1.1"
+    is-wsl "^2.2.0"
+
 os-tmpdir@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
@@ -3333,6 +3828,11 @@
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
   integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
 
+parse5@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
+  integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
+
 parseurl@^1.3.2, parseurl@~1.3.3:
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
@@ -3385,6 +3885,11 @@
   dependencies:
     pify "^3.0.0"
 
+path-type@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
+  integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
+
 pathval@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d"
@@ -3395,7 +3900,7 @@
   resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
   integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
 
-picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2:
+picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
   integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
@@ -3405,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==
@@ -3468,11 +3973,23 @@
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
   integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
 
+qs@^6.5.2:
+  version "6.10.1"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a"
+  integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==
+  dependencies:
+    side-channel "^1.0.4"
+
 qs@~6.5.2:
   version "6.5.2"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
   integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
 
+queue-microtask@^1.2.2:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
+  integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+
 randombytes@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
@@ -3495,6 +4012,16 @@
     iconv-lite "0.4.24"
     unpipe "1.0.0"
 
+raw-body@^2.3.3:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
+  integrity sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==
+  dependencies:
+    bytes "3.1.0"
+    http-errors "1.7.3"
+    iconv-lite "0.4.24"
+    unpipe "1.0.0"
+
 read-pkg-up@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-4.0.0.tgz#1b221c6088ba7799601c808f91161c66e58f8978"
@@ -3646,6 +4173,19 @@
     is-core-module "^2.2.0"
     path-parse "^1.0.6"
 
+restore-cursor@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
+  integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==
+  dependencies:
+    onetime "^5.1.0"
+    signal-exit "^3.0.2"
+
+reusify@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
+  integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
+
 rfdc@^1.1.4:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b"
@@ -3665,6 +4205,13 @@
   optionalDependencies:
     fsevents "~2.3.2"
 
+run-parallel@^1.1.9:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
+  integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
+  dependencies:
+    queue-microtask "^1.2.2"
+
 safe-buffer@5.1.2, safe-buffer@~5.1.1:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@@ -3729,6 +4276,20 @@
   resolved "https://registry.yarnpkg.com/shady-css-scoped-element/-/shady-css-scoped-element-0.0.2.tgz#c538fcfe2317e979cd02dfec533898b95b4ea8fe"
   integrity sha512-Dqfl70x6JiwYDujd33ZTbtCK0t52E7+H2swdWQNSTzfsolSa6LJHnTpN4T9OpJJEq4bxuzHRLFO9RBcy/UfrMQ==
 
+side-channel@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
+  integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
+  dependencies:
+    call-bind "^1.0.0"
+    get-intrinsic "^1.0.2"
+    object-inspect "^1.9.0"
+
+signal-exit@^3.0.2:
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.5.tgz#9e3e8cc0c75a99472b44321033a7702e7738252f"
+  integrity sha512-KWcOiKeQj6ZyXx7zq4YxSMgHRlod4czeBQZrPb8OKcohcqAXShm7E20kEMle9WBt26hFcAf0qLOcp5zmY7kOqQ==
+
 sinon@^10.0.0:
   version "10.0.1"
   resolved "https://registry.yarnpkg.com/sinon/-/sinon-10.0.1.tgz#0d1a13ecb86f658d15984f84273e57745b1f4c57"
@@ -3741,12 +4302,26 @@
     nise "^5.0.1"
     supports-color "^7.1.0"
 
-socket.io-adapter@~2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.1.0.tgz#edc5dc36602f2985918d631c1399215e97a1b527"
-  integrity sha512-+vDov/aTsLjViYTwS9fPy5pEtTkrbEKsw2M+oVSoFGw6OD1IpvlV1VPhUzNbofCQ8oyMbdYJqDtGdmHQK6TdPg==
+slash@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
+  integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
 
-socket.io-parser@~4.0.3:
+slice-ansi@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b"
+  integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==
+  dependencies:
+    ansi-styles "^4.0.0"
+    astral-regex "^2.0.0"
+    is-fullwidth-code-point "^3.0.0"
+
+socket.io-adapter@~2.3.2:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.3.2.tgz#039cd7c71a52abad984a6d57da2c0b7ecdd3c289"
+  integrity sha512-PBZpxUPYjmoogY0aoaTmo1643JelsaS1CiAwNjRVdrI0X9Seuc19Y2Wife8k88avW6haG8cznvwbubAZwH4Mtg==
+
+socket.io-parser@~4.0.4:
   version "4.0.4"
   resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.0.4.tgz#9ea21b0d61508d18196ef04a2c6b9ab630f4c2b0"
   integrity sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g==
@@ -3755,20 +4330,17 @@
     component-emitter "~1.3.0"
     debug "~4.3.1"
 
-socket.io@^3.1.0:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-3.1.2.tgz#06e27caa1c4fc9617547acfbb5da9bc1747da39a"
-  integrity sha512-JubKZnTQ4Z8G4IZWtaAZSiRP3I/inpy8c/Bsx2jrwGrTbKeVU5xd6qkKMHpChYeM3dWZSO0QACiGK+obhBNwYw==
+socket.io@^4.2.0:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.3.1.tgz#c0aa14f3f916a8ab713e83a5bd20c16600245763"
+  integrity sha512-HC5w5Olv2XZ0XJ4gOLGzzHEuOCfj3G0SmoW3jLHYYh34EVsIr3EkW9h6kgfW+K3TFEcmYy8JcPWe//KUkBp5jA==
   dependencies:
-    "@types/cookie" "^0.4.0"
-    "@types/cors" "^2.8.8"
-    "@types/node" ">=10.0.0"
     accepts "~1.3.4"
     base64id "~2.0.0"
-    debug "~4.3.1"
-    engine.io "~4.1.0"
-    socket.io-adapter "~2.1.0"
-    socket.io-parser "~4.0.3"
+    debug "~4.3.2"
+    engine.io "~6.0.0"
+    socket.io-adapter "~2.3.2"
+    socket.io-parser "~4.0.4"
 
 source-map-support@^0.5.19, source-map-support@~0.5.12:
   version "0.5.19"
@@ -3788,6 +4360,11 @@
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
   integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
 
+source-map@^0.7.3:
+  version "0.7.3"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
+  integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
+
 spdx-correct@^3.0.0:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
@@ -4038,6 +4615,11 @@
   resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
   integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
 
+type-fest@^0.21.3:
+  version "0.21.3"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
+  integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==
+
 type-is@^1.6.16, type-is@~1.6.17:
   version "1.6.18"
   resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
@@ -4056,10 +4638,10 @@
   resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066"
   integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
 
-ua-parser-js@^0.7.28:
-  version "0.7.28"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
-  integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==
+ua-parser-js@^0.7.30:
+  version "0.7.30"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.30.tgz#4cf5170e8b55ac553fe8b38df3a82f0669671f0b"
+  integrity sha512-uXEtSresNUlXQ1QL4/3dQORcGv7+J2ookOG2ybA/ga9+HYEXueT2o+8dUJQkpedsyTyCJ6jCCirRcKtdtx1kbg==
 
 unicode-canonical-property-names-ecmascript@^1.0.4:
   version "1.0.4"
@@ -4204,6 +4786,15 @@
   resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.1.0.tgz#a8e038b4c94569596852de7a8ea4228eefdeb37b"
   integrity sha512-toV7q9rWNYha963Pl/qyeZ6wG+3nnsyvolaNUS8+R5Wtw6qJPTxIlOP1ZSvcGhEJw+l3HMMmtiNo9Gl61G4GVg==
 
+wrap-ansi@^6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
+  integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
+  dependencies:
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
+
 wrap-ansi@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
@@ -4218,10 +4809,15 @@
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
-ws@~7.4.2:
-  version "7.4.6"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
-  integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==
+ws@^7.4.2:
+  version "7.5.5"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881"
+  integrity sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==
+
+ws@~8.2.3:
+  version "8.2.3"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba"
+  integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==
 
 y18n@^5.0.5:
   version "5.0.8"
diff --git a/proto/cache.proto b/proto/cache.proto
index 16e5e95..950e63e 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -448,7 +448,7 @@
 }
 
 // Serialized form of com.google.gerrit.common.data.LabelType.
-// Next ID: 21
+// Next ID: 22
 message LabelTypeProto {
   string name = 1;
   string function = 2; // ENUM as String
@@ -470,6 +470,7 @@
   repeated string ref_patterns = 18;
   bool copy_all_scores_if_list_of_files_did_not_change = 19;
   string copy_condition = 20;
+  string description = 21;
 }
 
 // Serialized form of com.google.gerrit.entities.SubmitRequirement.
@@ -484,7 +485,7 @@
 }
 
 // Serialized form of com.google.gerrit.entities.SubmitRequirementResult.
-// Next ID: 7
+// Next ID: 8
 message SubmitRequirementResultProto {
   SubmitRequirementProto submit_requirement = 1;
   SubmitRequirementExpressionResultProto applicability_expression_result = 2;
@@ -496,6 +497,10 @@
 
   // Whether this result was created from a legacy submit record.
   bool legacy = 6;
+
+  // Whether the submit requirement was bypassed during submission (i.e. by
+  // performing a push with the %submit option).
+  bool forced = 7;
 }
 
 // Serialized form of com.google.gerrit.entities.SubmitRequirementExpressionResult.
@@ -718,18 +723,3 @@
   ComparisonType comparison_type = 11;
   bool negative = 12;
 }
-
-// Serialized form of com.google.gerrit.server.approval.ApprovalCacheImpl.Key.
-// Next ID: 5
-message PatchSetApprovalsKeyProto {
-  string project = 1;
-  int32 change_id = 2;
-  int32 patch_set_id = 3;
-  bytes id = 4;
-}
-
-// Repeated version of PatchSetApprovalProto
-// Next ID: 2
-message AllPatchSetApprovalsProto {
-  repeated devtools.gerritcodereview.PatchSetApproval approval = 1;
-}
diff --git a/proto/entities.proto b/proto/entities.proto
index de8f647..191cca7 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -125,7 +125,7 @@
 }
 
 // Serialized form of com.google.gerrit.entities.PatchSetApproval.
-// Next ID: 9
+// Next ID: 11
 message PatchSetApproval {
   required PatchSetApproval_Key key = 1;
   optional int32 value = 2;
@@ -134,6 +134,7 @@
   optional Account_Id real_account_id = 7;
   optional bool post_submit = 8;
   optional bool copied = 9;
+  optional string uuid = 10;
 
   // Deleted fields, should not be reused:
   reserved 4;  // changeOpen
diff --git a/resources/BUILD b/resources/BUILD
index b53ae4c..d4d0df3 100644
--- a/resources/BUILD
+++ b/resources/BUILD
@@ -11,5 +11,5 @@
     name = "log4j-config__jar",
     srcs = ["log4j.properties"],
     outs = ["log4j-config.jar"],
-    cmd = "cd resources && zip -9Dqr $$ROOT/$@ .",
+    cmd = "cd $$(dirname $(location log4j.properties)) && zip -9Dqr $$ROOT/$@ .",
 )
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 9428c2f..8c97a49 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -29,7 +29,6 @@
   {@param? useGoogleFonts: ?}
   {@param? changeRequestsPath: ?}
   {@param? defaultChangeDetailHex: ?}
-  {@param? defaultDiffDetailHex: ?}
   {@param? defaultDashboardHex: ?}
   {@param? dashboardQuery: ?}
   {@param? userIsAuthenticated: ?}
@@ -52,9 +51,6 @@
       {if $defaultChangeDetailHex}
         changePage: '{$defaultChangeDetailHex}',
       {/if}
-      {if $defaultDiffDetailHex}
-        diffPage: '{$defaultDiffDetailHex}',
-      {/if}
       {if $defaultDashboardHex}
         dashboardPage: '{$defaultDashboardHex}',
       {/if}
@@ -99,18 +95,10 @@
         <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/?download-commands=true" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
       {/if}
     {/if}
-    {if $defaultDiffDetailHex}
-      <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/detail?O={$defaultDiffDetailHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
-      {if $userIsAuthenticated}
-        <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
-      {/if}
-      <link rel="preload" href="{$staticResourcePath}/bower_components/highlightjs/highlight.min.js" as="script"/>
-    {/if}
-    <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/comments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     <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}
-      <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+      <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts?enable-context=true&context-padding=3" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     {/if}
   {/if}
   {if $userIsAuthenticated and $defaultDashboardHex and $dashboardQuery}
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeader.soy b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
index 3d0edab..027d78b 100644
--- a/resources/com/google/gerrit/server/mail/ChangeHeader.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
@@ -28,5 +28,6 @@
       {/if}
     {/for}
     {\n}
+    {\n}
   {/if}
 {/template}
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/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 61606de..9e977c8 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -5,6 +5,7 @@
 asp = application/x-aspx
 aspx = application/x-aspx
 asterisk = text/x-asterisk
+awl = text/x-iecst
 b = text/x-brainfuck
 bash = text/x-sh
 bf = text/x-brainfuck
@@ -68,6 +69,7 @@
 f = text/x-fortran
 factor = text/x-factor
 feathre = text/x-feature
+feature = text/x-gherkin
 fcl = text/x-fcl
 for = text/x-fortran
 formula = text/x-spreadsheet
@@ -203,6 +205,7 @@
 sas = text/x-sas
 sass = text/x-sass
 scala = text/x-scala
+scl = text/x-iecst
 scm = text/x-scheme
 scss = text/x-scss
 sh = text/x-sh
@@ -220,6 +223,7 @@
 st = text/x-stsrc
 star = text/x-python
 stex = text/x-stex
+stl = text/x-iecst
 sv = text/x-systemverilog
 svg = application/xml
 svh = text/x-systemverilog
diff --git a/tools/BUILD b/tools/BUILD
index fb4701e..26db1fa 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -67,7 +67,7 @@
         "-Xep:AsyncFunctionReturnsNull:ERROR",
         "-Xep:AutoValueConstructorOrderChecker:ERROR",
         "-Xep:AutoValueFinalMethods:ERROR",
-        # "-Xep:AutoValueImmutableFields:WARN",
+        "-Xep:AutoValueImmutableFields:ERROR",
         # "-Xep:AutoValueSubclassLeaked:WARN",
         "-Xep:BadAnnotationImplementation:ERROR",
         "-Xep:BadComparable:ERROR",
@@ -84,7 +84,7 @@
         "-Xep:CacheLoaderNull:ERROR",
         "-Xep:CannotMockFinalClass:ERROR",
         "-Xep:CanonicalDuration:ERROR",
-        # "-Xep:CatchAndPrintStackTrace:WARN",
+        "-Xep:CatchAndPrintStackTrace:ERROR",
         "-Xep:CatchFail:ERROR",
         "-Xep:ChainedAssertionLosesContext:ERROR",
         "-Xep:ChainingConstructorIgnoresParameter:ERROR",
@@ -116,7 +116,7 @@
         "-Xep:DeadException:ERROR",
         "-Xep:DeadThread:ERROR",
         "-Xep:DefaultCharset:ERROR",
-        # "-Xep:DefaultPackage:WARN",
+        "-Xep:DefaultPackage:ERROR",
         "-Xep:DepAnn:ERROR",
         "-Xep:DeprecatedVariable:ERROR",
         "-Xep:DiscardedPostfixExpression:ERROR",
@@ -133,9 +133,9 @@
         "-Xep:DurationTemporalUnit:ERROR",
         "-Xep:DurationToLongTimeUnit:ERROR",
         "-Xep:EmptyBlockTag:ERROR",
-        # "-Xep:EmptyCatch:WARN",
+        "-Xep:EmptyCatch:ERROR",
         "-Xep:EmptySetMultibindingContributions:ERROR",
-        # "-Xep:EqualsGetClass:WARN",
+        "-Xep:EqualsGetClass:ERROR",
         "-Xep:EqualsHashCode:ERROR",
         "-Xep:EqualsIncompatibleType:ERROR",
         "-Xep:EqualsNaN:ERROR",
@@ -145,7 +145,7 @@
         "-Xep:EqualsUsingHashCode:ERROR",
         "-Xep:EqualsWrongThing:ERROR",
         "-Xep:ErroneousThreadPoolConstructorChecker:ERROR",
-        # "-Xep:EscapedEntity:WARN",
+        "-Xep:EscapedEntity:WARN",
         "-Xep:ExpectedExceptionChecker:ERROR",
         "-Xep:ExtendingJUnitAssert:ERROR",
         "-Xep:ExtendsAutoValue:ERROR",
@@ -154,12 +154,12 @@
         "-Xep:FloatCast:ERROR",
         "-Xep:FloatingPointAssertionWithinEpsilon:ERROR",
         "-Xep:FloatingPointLiteralPrecision:ERROR",
-        "-Xep:FloggerArgumentToString:WARN",
+        "-Xep:FloggerArgumentToString:ERROR",
         "-Xep:FloggerFormatString:ERROR",
         "-Xep:FloggerLogString:WARN",
         "-Xep:FloggerLogVarargs:ERROR",
         "-Xep:FloggerSplitLogStatement:ERROR",
-        "-Xep:FloggerStringConcatenation:WARN",
+        "-Xep:FloggerStringConcatenation:ERROR",
         "-Xep:ForOverride:ERROR",
         "-Xep:FormatString:ERROR",
         "-Xep:FormatStringAnnotation:ERROR",
@@ -189,7 +189,7 @@
         "-Xep:Incomparable:ERROR",
         "-Xep:IncompatibleArgumentType:ERROR",
         "-Xep:IncompatibleModifiers:ERROR",
-        # "-Xep:InconsistentCapitalization:WARN",
+        "-Xep:InconsistentCapitalization:ERROR",
         "-Xep:InconsistentHashCode:ERROR",
         "-Xep:IncrementInForLoopAndHeader:ERROR",
         "-Xep:IndexOfChar:ERROR",
@@ -197,7 +197,7 @@
         "-Xep:InfiniteRecursion:ERROR",
         "-Xep:InjectOnConstructorOfAbstractClass:ERROR",
         "-Xep:InheritDoc:ERROR",
-        # "-Xep:InlineFormatString:WARN",
+        "-Xep:InlineFormatString:ERROR",
         "-Xep:InlineMeInliner:ERROR",
         "-Xep:InlineMeSuggester:ERROR",
         "-Xep:InlineMeValidator:ERROR",
@@ -210,7 +210,7 @@
         "-Xep:InvalidInlineTag:ERROR",
         "-Xep:InvalidJavaTimeConstant:ERROR",
         "-Xep:InvalidLink:ERROR",
-        # "-Xep:InvalidParam:WARN",
+        "-Xep:InvalidParam:ERROR",
         "-Xep:InvalidPatternSyntax:ERROR",
         "-Xep:InvalidThrows:ERROR",
         "-Xep:InvalidThrowsLink:ERROR",
@@ -242,7 +242,7 @@
         "-Xep:JavaPeriodGetDays:ERROR",
         "-Xep:JavaTimeDefaultTimeZone:ERROR",
         "-Xep:JavaUtilDate:WARN",
-        # "-Xep:JdkObsolete:WARN",
+        "-Xep:JdkObsolete:ERROR",
         "-Xep:JodaConstructors:ERROR",
         "-Xep:JodaDateTimeConstants:ERROR",
         "-Xep:JodaDurationWithMillis:ERROR",
@@ -277,7 +277,7 @@
         "-Xep:MisusedDayOfYear:ERROR",
         "-Xep:MisusedWeekYear:ERROR",
         "-Xep:MixedDescriptors:ERROR",
-        # "-Xep:MixedMutabilityReturnType:WARN",
+        "-Xep:MixedMutabilityReturnType:ERROR",
         "-Xep:MockitoUsage:ERROR",
         "-Xep:ModifiedButNotUsed:ERROR",
         "-Xep:ModifyCollectionInEnhancedForLoop:ERROR",
@@ -287,13 +287,13 @@
         "-Xep:MultipleUnaryOperatorsInMethodCall:ERROR",
         "-Xep:MustBeClosedChecker:ERROR",
         "-Xep:MutableConstantField:ERROR",
-        # "-Xep:MutablePublicArray:WARN",
+        "-Xep:MutablePublicArray:ERROR",
         "-Xep:NCopiesOfChar:ERROR",
         "-Xep:NarrowingCompoundAssignment:ERROR",
         "-Xep:NestedInstanceOfConditions:ERROR",
         "-Xep:NonAtomicVolatileUpdate:ERROR",
         "-Xep:NonCanonicalStaticImport:ERROR",
-        # "-Xep:NonCanonicalType:WARN",
+        "-Xep:NonCanonicalType:ERROR",
         "-Xep:NonFinalCompileTimeConstant:ERROR",
         "-Xep:NonOverridingEquals:ERROR",
         "-Xep:NonRuntimeAnnotation:ERROR",
@@ -330,7 +330,7 @@
         "-Xep:PreconditionsInvalidPlaceholder:ERROR",
         "-Xep:PrimitiveAtomicReference:ERROR",
         "-Xep:PrivateSecurityContractProtoAccess:ERROR",
-        # "-Xep:ProtectedMembersInFinalClass:WARN",
+        "-Xep:ProtectedMembersInFinalClass:ERROR",
         "-Xep:ProtoBuilderReturnValueIgnored:ERROR",
         "-Xep:ProtoDurationGetSecondsGetNano:ERROR",
         "-Xep:ProtoFieldNullComparison:ERROR",
@@ -351,9 +351,9 @@
         "-Xep:RestrictedApiChecker:ERROR",
         "-Xep:RethrowReflectiveOperationExceptionAsLinkageError:ERROR",
         "-Xep:ReturnFromVoid:ERROR",
-        "-Xep:ReturnValueIgnored:WARN",
+        "-Xep:ReturnValueIgnored:ERROR",
         "-Xep:RxReturnValueIgnored:ERROR",
-        # "-Xep:SameNameButDifferent:WARN",
+        "-Xep:SameNameButDifferent:ERROR",
         "-Xep:SelfAssignment:ERROR",
         "-Xep:SelfComparison:ERROR",
         "-Xep:SelfEquals:ERROR",
@@ -367,7 +367,7 @@
         "-Xep:StreamToString:ERROR",
         "-Xep:StringBuilderInitWithChar:ERROR",
         "-Xep:StringEquality:ERROR",
-        # "-Xep:StringSplitter:WARN",
+        "-Xep:StringSplitter:ERROR",
         "-Xep:SubstringOfZero:ERROR",
         "-Xep:SuppressWarningsDeprecated:ERROR",
         "-Xep:SwigMemoryLeak:ERROR",
@@ -375,9 +375,9 @@
         "-Xep:TemporalAccessorGetChronoField:ERROR",
         "-Xep:TestParametersNotInitialized:ERROR",
         "-Xep:TheoryButNoTheories:ERROR",
-        # "-Xep:ThreadJoinLoop:WARN",
+        "-Xep:ThreadJoinLoop:ERROR",
         "-Xep:ThreadLocalUsage:ERROR",
-        # "-Xep:ThreadPriorityCheck:WARN",
+        "-Xep:ThreadPriorityCheck:ERROR",
         "-Xep:ThreeLetterTimeZoneID:ERROR",
         "-Xep:ThrowIfUncheckedKnownChecked:ERROR",
         "-Xep:ThrowNull:ERROR",
@@ -396,14 +396,14 @@
         "-Xep:TypeParameterShadowing:ERROR",
         "-Xep:TypeParameterUnusedInFormals:ERROR",
         "-Xep:URLEqualsHashCode:ERROR",
-        # "-Xep:UndefinedEquals:WARN",
+        "-Xep:UndefinedEquals:ERROR",
         "-Xep:UnescapedEntity:ERROR",
         "-Xep:UnnecessaryAssignment:ERROR",
         "-Xep:UnnecessaryCheckNotNull:ERROR",
-        # "-Xep:UnnecessaryLambda:WARN",
+        "-Xep:UnnecessaryLambda:ERROR",
         "-Xep:UnnecessaryMethodInvocationMatcher:ERROR",
         "-Xep:UnnecessaryMethodReference:ERROR",
-        # "-Xep:UnnecessaryParentheses:WARN",
+        "-Xep:UnnecessaryParentheses:ERROR",
         "-Xep:UnnecessaryTypeArgument:ERROR",
         "-Xep:UnrecognisedJavadocTag:ERROR",
         "-Xep:UnsafeFinalization:ERROR",
diff --git a/tools/bzl/asciidoc.bzl b/tools/bzl/asciidoc.bzl
index 7977cf0..703d5b7 100644
--- a/tools/bzl/asciidoc.bzl
+++ b/tools/bzl/asciidoc.bzl
@@ -300,13 +300,14 @@
         backend = None,
         searchbox = True,
         resources = True,
+        webfonts = True,
         **kwargs):
     SUFFIX = "_htmlonly"
 
     _genasciidoc_htmlonly_zip(
         name = name + SUFFIX if resources else name,
         srcs = srcs,
-        attributes = attributes,
+        attributes = attributes + ([] if webfonts else ["webfonts!"]),
         backend = backend,
         searchbox = searchbox,
         **kwargs
diff --git a/tools/bzl/javadoc.bzl b/tools/bzl/javadoc.bzl
index 3add025..abf3b7a 100644
--- a/tools/bzl/javadoc.bzl
+++ b/tools/bzl/javadoc.bzl
@@ -37,6 +37,7 @@
         "mkdir %s" % dir,
         " ".join([
             "%s/bin/javadoc" % ctx.attr._jdk[java_common.JavaRuntimeInfo].java_home,
+            " ".join(["-J%s" % opt for opt in ctx.fragments.java.default_jvm_opts]),
             "-Xdoclint:-missing",
             "-protected",
             "-encoding UTF-8",
@@ -75,4 +76,5 @@
     },
     outputs = {"zip": "%{name}.zip"},
     implementation = _impl,
+    fragments = ["java"],
 )
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index c8d6e4b..8025dab 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -110,6 +110,7 @@
         entry_point = entry_point,
         format = "iife",
         rollup_bin = "//tools/node_tools:rollup-bin",
+        silent = True,
         sourcemap = "hidden",
         config_file = "//plugins:rollup.config.js",
         deps = [
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index 46da9d6..4659c48 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -25,6 +25,7 @@
 
 @RunWith(Suite.class)
 @Suite.SuiteClasses({%s})
+@SuppressWarnings("DefaultPackage")
 public class %s {}
 """
 
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
index 43b172c..79285e6 100644
--- a/tools/bzl/license-map.py
+++ b/tools/bzl/license-map.py
@@ -170,7 +170,7 @@
 Gerrit includes an SSH daemon (Apache SSHD), to support authenticated
 uploads of changes directly from `git push` command line clients.
 
-Gerrit includes an SSH client (JSch), to support authenticated
+Gerrit includes an SSH client (Apache SSHD), to support authenticated
 replication of changes to remote systems, such as for automatic
 updates of mirror servers, or realtime backups.
 
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index 2b473bc..c9ac0fe 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -23,7 +23,6 @@
     "//lib/bouncycastle:bcprov",
     "//lib/bouncycastle:bcpg",
     "//lib/log:impl-log4j",
-    "//lib:jgit-ssh-jsch",
     "//prolog:gerrit-prolog-common",
     "//resources:log4j-config",
 ]
diff --git a/tools/deps.bzl b/tools/deps.bzl
new file mode 100644
index 0000000..6dd2eaf
--- /dev/null
+++ b/tools/deps.bzl
@@ -0,0 +1,690 @@
+load("//tools/bzl:maven_jar.bzl", "GERRIT", "maven_jar")
+load("@bazel_tools//tools/build_defs/repo:java.bzl", "java_import_external")
+
+CAFFEINE_VERS = "2.9.2"
+ANTLR_VERS = "3.5.2"
+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_VALUE_VERSION = "1.7.4"
+AUTO_VALUE_GSON_VERSION = "1.3.1"
+PROLOG_VERS = "1.4.4"
+PROLOG_REPO = GERRIT
+GITILES_VERS = "1.0.0"
+GITILES_REPO = GERRIT
+
+# When updating Bouncy Castle, also update it in bazlets.
+BC_VERS = "1.72"
+HTTPCOMP_VERS = "4.5.2"
+JETTY_VERS = "9.4.53.v20231009"
+BYTE_BUDDY_VERSION = "1.10.7"
+
+def java_dependencies():
+    maven_jar(
+        name = "java-runtime",
+        artifact = "org.antlr:antlr-runtime:" + ANTLR_VERS,
+        sha1 = "cd9cd41361c155f3af0f653009dcecb08d8b4afd",
+    )
+
+    maven_jar(
+        name = "stringtemplate",
+        artifact = "org.antlr:stringtemplate:4.0.2",
+        sha1 = "e28e09e2d44d60506a7bcb004d6c23ff35c6ac08",
+    )
+
+    maven_jar(
+        name = "org-antlr",
+        artifact = "org.antlr:antlr:" + ANTLR_VERS,
+        sha1 = "c4a65c950bfc3e7d04309c515b2177c00baf7764",
+    )
+
+    maven_jar(
+        name = "antlr27",
+        artifact = "antlr:antlr:2.7.7",
+        attach_source = False,
+        sha1 = "83cd2cd674a217ade95a4bb83a8a14f351f48bd0",
+    )
+
+    maven_jar(
+        name = "aopalliance",
+        artifact = "aopalliance:aopalliance:1.0",
+        sha1 = "0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8",
+    )
+
+    maven_jar(
+        name = "javax_inject",
+        artifact = "javax.inject:javax.inject:1",
+        sha1 = "6975da39a7040257bd51d21a231b76c915872d38",
+    )
+
+    maven_jar(
+        name = "servlet-api",
+        artifact = "javax.servlet:javax.servlet-api:3.1.0",
+        sha1 = "3cd63d075497751784b2fa84be59432f4905bf7c",
+    )
+
+    # JGit's transitive dependencies
+
+    maven_jar(
+        name = "javaewah",
+        artifact = "com.googlecode.javaewah:JavaEWAH:1.1.12",
+        attach_source = False,
+        sha1 = "9feecc2b24d6bc9ff865af8d082f192238a293eb",
+    )
+
+    maven_jar(
+        name = "gson",
+        artifact = "com.google.code.gson:gson:2.9.0",
+        sha1 = "8a1167e089096758b49f9b34066ef98b2f4b37aa",
+    )
+
+    maven_jar(
+        name = "caffeine",
+        artifact = "com.github.ben-manes.caffeine:caffeine:" + CAFFEINE_VERS,
+        sha1 = "0a17ed335e0ce2d337750772c0709b79af35a842",
+    )
+
+    maven_jar(
+        name = "guava-failureaccess",
+        artifact = "com.google.guava:failureaccess:1.0.1",
+        sha1 = "1dcf1de382a0bf95a3d8b0849546c88bac1292c9",
+    )
+
+    maven_jar(
+        name = "juniversalchardet",
+        artifact = "com.github.albfernandez:juniversalchardet:2.0.0",
+        sha1 = "28c59f58f5adcc307604602e2aa89e2aca14c554",
+    )
+
+    maven_jar(
+        name = "json-smart",
+        artifact = "net.minidev:json-smart:1.1.1",
+        sha1 = "24a2f903d25e004de30ac602c5b47f2d4e420a59",
+    )
+
+    maven_jar(
+        name = "args4j",
+        artifact = "args4j:args4j:2.33",
+        sha1 = "bd87a75374a6d6523de82fef51fc3cfe9baf9fc9",
+    )
+
+    maven_jar(
+        name = "commons-codec",
+        artifact = "commons-codec:commons-codec:1.10",
+        sha1 = "4b95f4897fa13f2cd904aee711aeafc0c5295cd8",
+    )
+
+    # When upgrading commons-compress, also upgrade tukaani-xz
+    maven_jar(
+        name = "commons-compress",
+        artifact = "org.apache.commons:commons-compress:1.22",
+        sha1 = "691a8b4e6cf4248c3bc72c8b719337d5cb7359fa",
+    )
+
+    maven_jar(
+        name = "commons-lang3",
+        artifact = "org.apache.commons:commons-lang3:3.8.1",
+        sha1 = "6505a72a097d9270f7a9e7bf42c4238283247755",
+    )
+
+    maven_jar(
+        name = "commons-text",
+        artifact = "org.apache.commons:commons-text:1.2",
+        sha1 = "74acdec7237f576c4803fff0c1008ab8a3808b2b",
+    )
+
+    maven_jar(
+        name = "commons-dbcp",
+        artifact = "commons-dbcp:commons-dbcp:1.4",
+        sha1 = "30be73c965cc990b153a100aaaaafcf239f82d39",
+    )
+
+    # Transitive dependency of commons-dbcp, do not update without
+    # also updating commons-dbcp
+    maven_jar(
+        name = "commons-pool",
+        artifact = "commons-pool:commons-pool:1.5.5",
+        sha1 = "7d8ffbdc47aa0c5a8afe5dc2aaf512f369f1d19b",
+    )
+
+    maven_jar(
+        name = "commons-net",
+        artifact = "commons-net:commons-net:3.6",
+        sha1 = "b71de00508dcb078d2b24b5fa7e538636de9b3da",
+    )
+
+    maven_jar(
+        name = "commons-validator",
+        artifact = "commons-validator:commons-validator:1.6",
+        sha1 = "e989d1e87cdd60575df0765ed5bac65c905d7908",
+    )
+
+    maven_jar(
+        name = "automaton",
+        artifact = "dk.brics:automaton:1.12-1",
+        sha1 = "959a0c62f9a5c2309e0ad0b0589c74d69e101241",
+    )
+
+    # commonmark must match the version used in Gitiles
+    maven_jar(
+        name = "commonmark",
+        artifact = "com.atlassian.commonmark:commonmark:" + COMMONMARK_VERS,
+        sha1 = "119cb7bedc3570d9ecb64ec69ab7686b5c20559b",
+    )
+
+    maven_jar(
+        name = "cm-autolink",
+        artifact = "com.atlassian.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERS,
+        sha1 = "a6056a5efbd68f57d420bc51bbc54b28a5d3c56b",
+    )
+
+    maven_jar(
+        name = "gfm-strikethrough",
+        artifact = "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERS,
+        sha1 = "40837da951b421b545edddac57012e15fcc9e63c",
+    )
+
+    maven_jar(
+        name = "gfm-tables",
+        artifact = "com.atlassian.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERS,
+        sha1 = "c075db2a3301100cf70c7dced8ecf86b494458a2",
+    )
+
+    maven_jar(
+        name = "flexmark",
+        artifact = "com.vladsch.flexmark:flexmark:" + FLEXMARK_VERS,
+        sha1 = "7f61e190cff7e1bea64906408ccfa583b5ac9fc2",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-abbreviation",
+        artifact = "com.vladsch.flexmark:flexmark-ext-abbreviation:" + FLEXMARK_VERS,
+        sha1 = "8f62f49cfaf8d33391e48a3b79e941d94e444e50",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-anchorlink",
+        artifact = "com.vladsch.flexmark:flexmark-ext-anchorlink:" + FLEXMARK_VERS,
+        sha1 = "1d530e44b4439abf53ce9dcc784ffa5b9fd6ce89",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-autolink",
+        artifact = "com.vladsch.flexmark:flexmark-ext-autolink:" + FLEXMARK_VERS,
+        sha1 = "4026aa60fd6e146c2d4931acb19b2409c6a79b8a",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-definition",
+        artifact = "com.vladsch.flexmark:flexmark-ext-definition:" + FLEXMARK_VERS,
+        sha1 = "bb74d36459e8e34653761aaa0095220b8b7b6cb6",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-emoji",
+        artifact = "com.vladsch.flexmark:flexmark-ext-emoji:" + FLEXMARK_VERS,
+        sha1 = "36d36cb227f831b81b636d3f901f07db06b8d84d",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-escaped-character",
+        artifact = "com.vladsch.flexmark:flexmark-ext-escaped-character:" + FLEXMARK_VERS,
+        sha1 = "09f0cef3260b6f6371d6066cf32ce6a7dc2a7922",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-footnotes",
+        artifact = "com.vladsch.flexmark:flexmark-ext-footnotes:" + FLEXMARK_VERS,
+        sha1 = "cdf0b79f026b09c6b87d91e61eb29ada1577aa5c",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-gfm-issues",
+        artifact = "com.vladsch.flexmark:flexmark-ext-gfm-issues:" + FLEXMARK_VERS,
+        sha1 = "e40d347e242e35d4344553120cb9e69c1c975f41",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-gfm-strikethrough",
+        artifact = "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:" + FLEXMARK_VERS,
+        sha1 = "a6948f6e4fc2ec1059d6b53e73d4a30c24f6e05d",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-gfm-tables",
+        artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tables:" + FLEXMARK_VERS,
+        sha1 = "92d3eb0c5dc79924448c186d89717f1df853aaa0",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-gfm-tasklist",
+        artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:" + FLEXMARK_VERS,
+        sha1 = "e6fb4d9e46c96e61a07fbb40cf653db58ed95a95",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-gfm-users",
+        artifact = "com.vladsch.flexmark:flexmark-ext-gfm-users:" + FLEXMARK_VERS,
+        sha1 = "44c83bdccfd41a399f3f7b11ba72728382dd3f5a",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-ins",
+        artifact = "com.vladsch.flexmark:flexmark-ext-ins:" + FLEXMARK_VERS,
+        sha1 = "b0d174d86ac2348420993dc2c997fad5f02c68fa",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-jekyll-front-matter",
+        artifact = "com.vladsch.flexmark:flexmark-ext-jekyll-front-matter:" + FLEXMARK_VERS,
+        sha1 = "2bfb31c67fd10af058b90f1b364f881f6276de80",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-superscript",
+        artifact = "com.vladsch.flexmark:flexmark-ext-superscript:" + FLEXMARK_VERS,
+        sha1 = "d210b007b46339082f79b6caf632b19ac8efc341",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-tables",
+        artifact = "com.vladsch.flexmark:flexmark-ext-tables:" + FLEXMARK_VERS,
+        sha1 = "be77790470aa9bd90011067221504162a1d7b083",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-toc",
+        artifact = "com.vladsch.flexmark:flexmark-ext-toc:" + FLEXMARK_VERS,
+        sha1 = "91d6d63ff5b70c3ebae98867bd7a346d71cb0ce1",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-typographic",
+        artifact = "com.vladsch.flexmark:flexmark-ext-typographic:" + FLEXMARK_VERS,
+        sha1 = "f51e247df80628df509a3af92a1efa1efb83d746",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-wikilink",
+        artifact = "com.vladsch.flexmark:flexmark-ext-wikilink:" + FLEXMARK_VERS,
+        sha1 = "d14aeb078dbaf3e166621ae9499595a4a57b22ab",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-yaml-front-matter",
+        artifact = "com.vladsch.flexmark:flexmark-ext-yaml-front-matter:" + FLEXMARK_VERS,
+        sha1 = "aba328b70e15f2553aac0a74e391edab37bf7b30",
+    )
+
+    maven_jar(
+        name = "flexmark-formatter",
+        artifact = "com.vladsch.flexmark:flexmark-formatter:" + FLEXMARK_VERS,
+        sha1 = "3e4ad3b1be29d41e8c35cadb70761768505f2164",
+    )
+
+    maven_jar(
+        name = "flexmark-html-parser",
+        artifact = "com.vladsch.flexmark:flexmark-html-parser:" + FLEXMARK_VERS,
+        sha1 = "3c59b0061c50c9a8a48c46d4f4498d8eba249921",
+    )
+
+    maven_jar(
+        name = "flexmark-profile-pegdown",
+        artifact = "com.vladsch.flexmark:flexmark-profile-pegdown:" + FLEXMARK_VERS,
+        sha1 = "c01a2c2eebe06230858956d45847cee233790d7c",
+    )
+
+    maven_jar(
+        name = "flexmark-util",
+        artifact = "com.vladsch.flexmark:flexmark-util:" + FLEXMARK_VERS,
+        sha1 = "fdfce72f5eb9ec53085804fd0c8d15f3680ae359",
+    )
+
+    # Transitive dependency of flexmark and gitiles
+    maven_jar(
+        name = "autolink",
+        artifact = "org.nibor.autolink:autolink:0.7.0",
+        sha1 = "649f9f13422cf50c926febe6035662ae25dc89b2",
+    )
+
+    maven_jar(
+        name = "greenmail",
+        artifact = "com.icegreen:greenmail:" + GREENMAIL_VERS,
+        sha1 = "9ea96384ad2cb8118c22f493b529eb72c212691c",
+    )
+
+    maven_jar(
+        name = "mail",
+        artifact = "com.sun.mail:javax.mail:" + MAIL_VERS,
+        sha1 = "a055c648842c4954c1f7db7254f45d9ad565e278",
+    )
+
+    maven_jar(
+        name = "mime4j-core",
+        artifact = "org.apache.james:apache-mime4j-core:" + MIME4J_VERS,
+        sha1 = "c62dfe18a3b827a2c626ade0ffba44562ddf3f61",
+    )
+
+    maven_jar(
+        name = "mime4j-dom",
+        artifact = "org.apache.james:apache-mime4j-dom:" + MIME4J_VERS,
+        sha1 = "f2d653c617004193f3350330d907f77b60c88c56",
+    )
+
+    maven_jar(
+        name = "jsoup",
+        artifact = "org.jsoup:jsoup:1.9.2",
+        sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
+    )
+
+    maven_jar(
+        name = "ow2-asm",
+        artifact = "org.ow2.asm:asm:" + OW2_VERS,
+        sha1 = "81a03f76019c67362299c40e0ba13405f5467bff",
+    )
+
+    maven_jar(
+        name = "ow2-asm-analysis",
+        artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
+        sha1 = "7487dd756daf96cab9986e44b9d7bcb796a61c10",
+    )
+
+    maven_jar(
+        name = "ow2-asm-commons",
+        artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
+        sha1 = "f4d7f0fc9054386f2893b602454d48e07d4fbead",
+    )
+
+    maven_jar(
+        name = "ow2-asm-tree",
+        artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
+        sha1 = "d96c99a30f5e1a19b0e609dbb19a44d8518ac01e",
+    )
+
+    maven_jar(
+        name = "ow2-asm-util",
+        artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
+        sha1 = "fbc178fc5ba3dab50fd7e8a5317b8b647c8e8946",
+    )
+
+    maven_jar(
+        name = "auto-value",
+        artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
+        sha1 = "6b126cb218af768339e4d6e95a9b0ae41f74e73d",
+    )
+
+    maven_jar(
+        name = "auto-value-annotations",
+        artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
+        sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
+    )
+
+    maven_jar(
+        name = "auto-value-gson-runtime",
+        artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
+        sha1 = "addda2ae6cce9f855788274df5de55dde4de7b71",
+    )
+
+    maven_jar(
+        name = "auto-value-gson-extension",
+        artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION,
+        sha1 = "0c4c01a3e10e5b10df2e5f5697efa4bb3f453ac1",
+    )
+
+    maven_jar(
+        name = "auto-value-gson-factory",
+        artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION,
+        sha1 = "9ed8d79144ee8d60cc94cc11f847b5ed8ee9f19c",
+    )
+
+    maven_jar(
+        name = "javapoet",
+        artifact = "com.squareup:javapoet:1.13.0",
+        sha1 = "d6562d385049f35eb50403fa86bb11cce76b866a",
+    )
+
+    maven_jar(
+        name = "autotransient",
+        artifact = "io.sweers.autotransient:autotransient:1.0.0",
+        sha1 = "38b1c630b8e76560221622289f37be40105abb3d",
+    )
+
+    maven_jar(
+        name = "mime-util",
+        artifact = "eu.medsea.mimeutil:mime-util:2.1.3",
+        attach_source = False,
+        sha1 = "0c9cfae15c74f62491d4f28def0dff1dabe52a47",
+    )
+
+    maven_jar(
+        name = "prolog-runtime",
+        artifact = "com.googlecode.prolog-cafe:prolog-runtime:" + PROLOG_VERS,
+        attach_source = False,
+        repository = PROLOG_REPO,
+        sha1 = "e9a364f4233481cce63239e8e68a6190c8f58acd",
+    )
+
+    maven_jar(
+        name = "prolog-compiler",
+        artifact = "com.googlecode.prolog-cafe:prolog-compiler:" + PROLOG_VERS,
+        attach_source = False,
+        repository = PROLOG_REPO,
+        sha1 = "570295026f6aa7b905e423d107cb2e081eecdc04",
+    )
+
+    maven_jar(
+        name = "prolog-io",
+        artifact = "com.googlecode.prolog-cafe:prolog-io:" + PROLOG_VERS,
+        attach_source = False,
+        repository = PROLOG_REPO,
+        sha1 = "1f25c4e27d22bdbc31481ee0c962a2a2853e4428",
+    )
+
+    maven_jar(
+        name = "cafeteria",
+        artifact = "com.googlecode.prolog-cafe:prolog-cafeteria:" + PROLOG_VERS,
+        attach_source = False,
+        repository = PROLOG_REPO,
+        sha1 = "0e6c2deeaf5054815a561cbd663566fd59b56c6c",
+    )
+
+    maven_jar(
+        name = "guava-retrying",
+        artifact = "com.github.rholder:guava-retrying:2.0.0",
+        sha1 = "974bc0a04a11cc4806f7c20a34703bd23c34e7f4",
+    )
+
+    maven_jar(
+        name = "jsr305",
+        artifact = "com.google.code.findbugs:jsr305:3.0.1",
+        sha1 = "f7be08ec23c21485b9b5a1cf1654c2ec8c58168d",
+    )
+
+    maven_jar(
+        name = "blame-cache",
+        artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
+        attach_source = False,
+        repository = GITILES_REPO,
+        sha1 = "f46833f8aa6f33ce3e443c8a414c295559eaf43e",
+    )
+
+    maven_jar(
+        name = "gitiles-servlet",
+        artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
+        repository = GITILES_REPO,
+        sha1 = "90e107da00c2cd32490dd9ae8e3fb1ee095ea675",
+    )
+
+    # prettify must match the version used in Gitiles
+    maven_jar(
+        name = "prettify",
+        artifact = "com.github.twalcari:java-prettify:1.2.2",
+        sha1 = "b8ba1c1eb8b2e45cfd465d01218c6060e887572e",
+    )
+
+    maven_jar(
+        name = "html-types",
+        artifact = "com.google.common.html.types:types:1.0.8",
+        sha1 = "9e9cf7bc4b2a60efeb5f5581fe46d17c068e0777",
+    )
+
+    maven_jar(
+        name = "icu4j",
+        artifact = "com.ibm.icu:icu4j:57.1",
+        sha1 = "198ea005f41219f038f4291f0b0e9f3259730e92",
+    )
+
+    maven_jar(
+        name = "bcprov",
+        artifact = "org.bouncycastle:bcprov-jdk18on:" + BC_VERS,
+        sha1 = "d8dc62c28a3497d29c93fee3e71c00b27dff41b4",
+    )
+
+    maven_jar(
+        name = "bcpg",
+        artifact = "org.bouncycastle:bcpg-jdk18on:" + BC_VERS,
+        sha1 = "1a36a1740d07869161f6f0d01fae8d72dd1d8320",
+    )
+
+    maven_jar(
+        name = "bcpkix",
+        artifact = "org.bouncycastle:bcpkix-jdk18on:" + BC_VERS,
+        sha1 = "bb3fdb5162ccd5085e8d7e57fada4d8eaa571f5a",
+    )
+
+    maven_jar(
+        name = "bcutil",
+        artifact = "org.bouncycastle:bcutil-jdk18on:" + BC_VERS,
+        sha1 = "41f19a69ada3b06fa48781120d8bebe1ba955c77",
+    )
+
+    maven_jar(
+        name = "h2",
+        artifact = "com.h2database:h2:1.3.176",
+        sha1 = "fd369423346b2f1525c413e33f8cf95b09c92cbd",
+    )
+
+    maven_jar(
+        name = "fluent-hc",
+        artifact = "org.apache.httpcomponents:fluent-hc:" + HTTPCOMP_VERS,
+        sha1 = "7bfdfa49de6d720ad3c8cedb6a5238eec564dfed",
+    )
+
+    maven_jar(
+        name = "httpclient",
+        artifact = "org.apache.httpcomponents:httpclient:" + HTTPCOMP_VERS,
+        sha1 = "733db77aa8d9b2d68015189df76ab06304406e50",
+    )
+
+    maven_jar(
+        name = "httpcore",
+        artifact = "org.apache.httpcomponents:httpcore:4.4.4",
+        sha1 = "b31526a230871fbe285fbcbe2813f9c0839ae9b0",
+    )
+
+    # Test-only dependencies below.
+    maven_jar(
+        name = "junit",
+        artifact = "junit:junit:4.12",
+        sha1 = "2973d150c0dc1fefe998f834810d68f278ea58ec",
+    )
+
+    maven_jar(
+        name = "hamcrest-core",
+        artifact = "org.hamcrest:hamcrest-core:1.3",
+        sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
+    )
+
+    maven_jar(
+        name = "diffutils",
+        artifact = "com.googlecode.java-diff-utils:diffutils:1.3.0",
+        sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
+    )
+
+    maven_jar(
+        name = "jetty-servlet",
+        artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
+        sha1 = "6670d6a54cdcaedd8090e8cf420fd5dd7d08e859",
+    )
+
+    maven_jar(
+        name = "jetty-security",
+        artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
+        sha1 = "6fbc8ebe9046954dc2f51d4ba69c8f8344b05f7f",
+    )
+
+    maven_jar(
+        name = "jetty-server",
+        artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
+        sha1 = "8b0e761a0b359db59dae77c00b4213b0586cb994",
+    )
+
+    maven_jar(
+        name = "jetty-jmx",
+        artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
+        sha1 = "f0392f756b59f65ea7d6be41bf7a2f7b2c7c98d5",
+    )
+
+    maven_jar(
+        name = "jetty-http",
+        artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
+        sha1 = "87faf21eb322753f0527bcb88c43e67044786369",
+    )
+
+    maven_jar(
+        name = "jetty-io",
+        artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
+        sha1 = "70cf7649b27c964ad29bfddf58f3bfe0d30346cf",
+    )
+
+    maven_jar(
+        name = "jetty-util",
+        artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
+        sha1 = "f72bb4f687b4454052c6f06528ba9910714df947",
+    )
+
+    maven_jar(
+        name = "jetty-util-ajax",
+        artifact = "org.eclipse.jetty:jetty-util-ajax:" + JETTY_VERS,
+        sha1 = "4d20f6206eb7747293697c5f64c2dc5bf4bd54a4",
+        src_sha1 = "1aed8017c3c8a449323901639de6b4eb3b1f02ea",
+    )
+
+    maven_jar(
+        name = "asciidoctor",
+        artifact = "org.asciidoctor:asciidoctorj:1.5.7",
+        sha1 = "8e8c1d8fc6144405700dd8df3b177f2801ac5987",
+    )
+
+    maven_jar(
+        name = "javax-activation",
+        artifact = "javax.activation:activation:1.1.1",
+        sha1 = "485de3a253e23f645037828c07f1d7f1af40763a",
+    )
+
+    maven_jar(
+        name = "mockito",
+        artifact = "org.mockito:mockito-core:3.3.3",
+        sha1 = "4878395d4e63173f3825e17e5e0690e8054445f1",
+    )
+
+    maven_jar(
+        name = "bytebuddy",
+        artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION,
+        sha1 = "1eefb7dd1b032b33c773ca0a17d5cc9e6b56ea1a",
+    )
+
+    maven_jar(
+        name = "bytebuddy-agent",
+        artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION,
+        sha1 = "c472fad33f617228601172682aa64f8b78508045",
+    )
+
+    maven_jar(
+        name = "objenesis",
+        artifact = "org.objenesis:objenesis:3.0.1",
+        sha1 = "11cfac598df9dc48bb9ed9357ed04212694b7808",
+    )
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 841d9e7..dc136ed 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -96,8 +96,7 @@
             build = True
         cmd.append(arg)
     if custom_java:
-        cmd.append('--host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
-        cmd.append('--java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
+        cmd.append('--config=java%s' % custom_java)
     return cmd
 
 
@@ -179,8 +178,6 @@
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.http.server/src')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.http.server/resources')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.junit/src')
-        classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.jsch/src')
-        classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.jsch/resources')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.apache/src')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.apache/resources')
 
diff --git a/tools/js/eslint.bzl b/tools/js/eslint.bzl
index eb4d37a..3f48cf6 100644
--- a/tools/js/eslint.bzl
+++ b/tools/js/eslint.bzl
@@ -31,6 +31,7 @@
             "//plugins:.eslintrc.js",
             "//plugins:.prettierrc.js",
             "//plugins:tsconfig-plugins-base.json",
+            "@npm//typescript",
         ],
         extensions = [".ts"],
         ignore = "//plugins:.eslintignore",
@@ -39,7 +40,9 @@
             "@npm//eslint-plugin-html",
             "@npm//eslint-plugin-import",
             "@npm//eslint-plugin-jsdoc",
+            "@npm//eslint-plugin-lit",
             "@npm//eslint-plugin-prettier",
+            "@npm//eslint-plugin-regex",
             "@npm//gts",
         ],
     )
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 84404048..390d46d 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.5.7-SNAPSHOT</version>
+  <version>3.6.9-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
@@ -104,7 +104,7 @@
   </mailingLists>
 
   <issueManagement>
-    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
+    <url>https://issues.gerritcodereview.com/issues?q=is:open</url>
     <system>Gerrit Issue Tracker</system>
   </issueManagement>
 </project>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index 90a194e..b3790ed 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.5.7-SNAPSHOT</version>
+  <version>3.6.9-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
@@ -104,7 +104,7 @@
   </mailingLists>
 
   <issueManagement>
-    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
+    <url>https://issues.gerritcodereview.com/issues?q=is:open</url>
     <system>Gerrit Issue Tracker</system>
   </issueManagement>
 </project>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index e390053..5cf4033 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.5.7-SNAPSHOT</version>
+  <version>3.6.9-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
@@ -104,7 +104,7 @@
   </mailingLists>
 
   <issueManagement>
-    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
+    <url>https://issues.gerritcodereview.com/issues?q=is:open</url>
     <system>Gerrit Issue Tracker</system>
   </issueManagement>
 </project>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 4fc6ff4..e997143 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.5.7-SNAPSHOT</version>
+  <version>3.6.9-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
@@ -104,7 +104,7 @@
   </mailingLists>
 
   <issueManagement>
-    <url>https://bugs.chromium.org/p/gerrit/issues/list</url>
+    <url>https://issues.gerritcodereview.com/issues?q=is:open</url>
     <system>Gerrit Issue Tracker</system>
   </issueManagement>
 </project>
diff --git a/tools/maven/package.bzl b/tools/maven/package.bzl
index eacb02b..b25656d 100644
--- a/tools/maven/package.bzl
+++ b/tools/maven/package.bzl
@@ -40,7 +40,7 @@
         src = {},
         doc = {},
         war = {}):
-    build_cmd = ["bazel_cmd", "build", "--java_toolchain=//tools:error_prone_warnings_toolchain_java11"]
+    build_cmd = ["bazel_cmd", "build"]
     mvn_cmd = ["python", "tools/maven/mvn.py", "-v", version]
     api_cmd = mvn_cmd[:]
     api_targets = []
diff --git a/tools/node_tools/launchpad.patch b/tools/node_tools/launchpad.patch
deleted file mode 100644
index 565494b..0000000
--- a/tools/node_tools/launchpad.patch
+++ /dev/null
@@ -1,240 +0,0 @@
-From d430b5d912bebe87529b887f408ee55c82a0e003 Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Fri, 26 Jun 2020 20:16:47 +0200
-Subject: [PATCH 1/7] Update version.js
-
----
- lib/local/version.js | 15 ++++++++++++---
- 1 file changed, 12 insertions(+), 3 deletions(-)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/version.js b/tools/node_tools/node_modules/launchpad/lib/g/local/version.js
-index 0110a74..2c02bef 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/version.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/version.js
-@@ -6,6 +6,15 @@ var plist = require('plist');
- var utils = require('./utils');
- var debug = require('debug')('launchpad:local:version');
- 
-+var validPath = function (filename){
-+  var filter = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,<>\/?~]/;
-+  if (filter.test(filename)){
-+    console.log('\nInvalid characters inside the path to the browser\n');
-+    return
-+  }
-+  return filename;
-+}
-+
- module.exports = function(browser) {
-   if (!browser || !browser.path) {
-     return Q(null);
-@@ -18,7 +27,7 @@ module.exports = function(browser) {
- 
-     debug('Retrieving version for windows executable', command);
-     // Can't use Q.nfcall here unfortunately because of non 0 exit code
--    exec(command, function(error, stdout) {
-+    exec(command.split(' ')[0], command.split(' ').slice(1), function(error, stdout) {
-       var regex = /ProductVersion:\s*(.*)/;
-       // ShowVer.exe returns a non zero status code even if it works
-       if (typeof stdout === 'string' && regex.test(stdout)) {
-@@ -47,8 +56,8 @@ module.exports = function(browser) {
-   }
- 
-   // Try executing <browser> --version (everything else)
--  return Q.nfcall(exec, browser.path + ' --version').then(function(stdout) {
--    debug('Ran ' + browser.path + ' --version', stdout);
-+  return Q.nfcall(exec, validPath(browser.path) + ' --version').then(function(stdout) {
-+    debug('Ran ' + validPath(browser.path) + ' --version', stdout);
-     var version = utils.getStdout(stdout);
-     if (version) {
-       browser.version = version;
-
-From 09ce4fab2fd53cab893ceaa3b4d7f997af9b41d8 Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Fri, 26 Jun 2020 20:18:35 +0200
-Subject: [PATCH 2/7] Update instance.js
-
----
- lib/local/instance.js | 11 +++++++++--
- 1 file changed, 9 insertions(+), 2 deletions(-)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/instance.js b/tools/node_tools/node_modules/launchpad/lib/g/local/instance.js
-index 484a866..b49990f 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/instance.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/instance.js
-@@ -5,8 +5,15 @@ var EventEmitter = require('events').EventEmitter;
- var debug = require('debug')('launchpad:local:instance');
- var rimraf = require('rimraf');
- 
-+var safe = function (str) {
-+   // Avoid quotes makes impossible escape the `multi command` scenario
-+   return str.replace(/['"]+/g, '');
-+}
-+
- var getProcessId = function (name, callback) {
- 
-+  name = safe(name);
-+
-   var commands = {
-     darwin: "ps -clx | grep '" + name + "$' | awk '{print $2}' | head -1",
-     linux: "ps -ax | grep '" + name + "$' | awk '{print $2}' | head -1",
-@@ -90,11 +97,11 @@ Instance.prototype.stop = function (callback) {
-     } catch (error) {}
-   } else {
-     if (this.options.command.indexOf('open') === 0) {
--      command = 'osascript -e \'tell application "' + self.options.process + '" to quit\'';
-+      command = 'osascript -e \'tell application "' + safe(self.options.process) + '" to quit\'';
-       debug('Executing shutdown AppleScript', command);
-       exec(command);
-     } else if (process.platform === 'win32') {
--      command = 'taskkill /IM ' + (this.options.imageName || path.basename(this.cmd));
-+      command = 'taskkill /IM "' + safe(this.options.imageName || path.basename(this.cmd)) + '"';
-       debug('Executing shutdown taskkil', command);
-       exec(command).once('exit', function(data) {
-         self.emit('stop', data);
-
-From d3993fce090ed6ef378c1f0594eff18d125dad1e Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Fri, 26 Jun 2020 20:19:17 +0200
-Subject: [PATCH 3/7] Update version.js
-
----
- lib/local/version.js | 1 +
- 1 file changed, 1 insertion(+)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/version.js b/tools/node_tools/node_modules/launchpad/lib/g/local/version.js
-index 2c02bef..5eac082 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/version.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/version.js
-@@ -6,6 +6,7 @@ var plist = require('plist');
- var utils = require('./utils');
- var debug = require('debug')('launchpad:local:version');
- 
-+// Validate paths supplied by the user in order to avoid "arbitrary command execution"
- var validPath = function (filename){
-   var filter = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,<>\/?~]/;
-   if (filter.test(filename)){
-
-From abf3dbcc79e6b338338594ab2dbef834550e8f65 Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Mon, 29 Jun 2020 13:32:50 +0200
-Subject: [PATCH 4/7] Update instance.js
-
----
- lib/local/instance.js | 10 +++++++---
- 1 file changed, 7 insertions(+), 3 deletions(-)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/instance.js b/tools/node_tools/node_modules/launchpad/lib/g/local/instance.js
-index b49990f..9375d1f 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/instance.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/instance.js
-@@ -1,6 +1,7 @@
- var path = require('path');
- var spawn = require("child_process").spawn;
- var exec = require("child_process").exec;
-+var execFile = require("child_process").execFile;
- var EventEmitter = require('events').EventEmitter;
- var debug = require('debug')('launchpad:local:instance');
- var rimraf = require('rimraf');
-@@ -99,11 +100,14 @@ Instance.prototype.stop = function (callback) {
-     if (this.options.command.indexOf('open') === 0) {
-       command = 'osascript -e \'tell application "' + safe(self.options.process) + '" to quit\'';
-       debug('Executing shutdown AppleScript', command);
--      exec(command);
-+      command = command.split(' ');
-+      execFile(command[0], command.slice(1));
-     } else if (process.platform === 'win32') {
--      command = 'taskkill /IM "' + safe(this.options.imageName || path.basename(this.cmd)) + '"';
-+      //Adding `"` wasn't safe/functional on Win systems
-+      command = 'taskkill /IM ' + (this.options.imageName || path.basename(this.cmd); 
-       debug('Executing shutdown taskkil', command);
--      exec(command).once('exit', function(data) {
-+      command = command.split(' ');
-+      execFile(command[0], command.slice(1)).once('exit', function(data) {
-         self.emit('stop', data);
-       });
-     } else {
-
-From 68518b274c9351f799d41ce85f23499ca4a785e9 Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Tue, 30 Jun 2020 00:01:31 +0200
-Subject: [PATCH 5/7] Update instance.js
-
----
- lib/local/instance.js | 2 +-
- 1 file changed, 1 insertion(+), 1 deletion(-)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/instance.js b/tools/node_tools/node_modules/launchpad/lib/g/local/instance.js
-index 9375d1f..f157dd4 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/instance.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/instance.js
-@@ -104,7 +104,7 @@ Instance.prototype.stop = function (callback) {
-       execFile(command[0], command.slice(1));
-     } else if (process.platform === 'win32') {
-       //Adding `"` wasn't safe/functional on Win systems
--      command = 'taskkill /IM ' + (this.options.imageName || path.basename(this.cmd); 
-+      command = 'taskkill /IM ' + (this.options.imageName || path.basename(this.cmd)); 
-       debug('Executing shutdown taskkil', command);
-       command = command.split(' ');
-       execFile(command[0], command.slice(1)).once('exit', function(data) {
-
-From e711d07d40d39162ea4bdb1ed344c58f92bfa10b Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Fri, 3 Jul 2020 12:30:31 +0200
-Subject: [PATCH 6/7] Update version.js
-
----
- lib/local/version.js | 5 +++--
- 1 file changed, 3 insertions(+), 2 deletions(-)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/version.js b/tools/node_tools/node_modules/launchpad/lib/g/local/version.js
-index 5eac082..d1403a0 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/version.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/version.js
-@@ -1,5 +1,6 @@
- var fs = require('fs');
- var exec = require('child_process').exec;
-+var execFile = require('child_process').execFile;
- var Q = require('q');
- var path = require('path');
- var plist = require('plist');
-@@ -8,7 +9,7 @@ var debug = require('debug')('launchpad:local:version');
- 
- // Validate paths supplied by the user in order to avoid "arbitrary command execution"
- var validPath = function (filename){
--  var filter = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,<>\/?~]/;
-+  var filter = /[`!@#$%^&*()_+\-=\[\]{};':"|,<>?~]/;
-   if (filter.test(filename)){
-     console.log('\nInvalid characters inside the path to the browser\n');
-     return
-@@ -28,7 +29,7 @@ module.exports = function(browser) {
- 
-     debug('Retrieving version for windows executable', command);
-     // Can't use Q.nfcall here unfortunately because of non 0 exit code
--    exec(command.split(' ')[0], command.split(' ').slice(1), function(error, stdout) {
-+    execFile(command.split(' ')[0], command.split(' ').slice(1), function(error, stdout) {
-       var regex = /ProductVersion:\s*(.*)/;
-       // ShowVer.exe returns a non zero status code even if it works
-       if (typeof stdout === 'string' && regex.test(stdout)) {
-
-From a3ff1804f0aacfb4fa20dad1312427b81280bb3e Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Fri, 3 Jul 2020 12:31:31 +0200
-Subject: [PATCH 7/7] Update version.js
-
----
- lib/local/version.js | 2 +-
- 1 file changed, 1 insertion(+), 1 deletion(-)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/version.js b/tools/node_tools/node_modules/launchpad/lib/g/local/version.js
-index d1403a0..d937be4 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/version.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/version.js
-@@ -9,7 +9,7 @@ var debug = require('debug')('launchpad:local:version');
- 
- // Validate paths supplied by the user in order to avoid "arbitrary command execution"
- var validPath = function (filename){
--  var filter = /[`!@#$%^&*()_+\-=\[\]{};':"|,<>?~]/;
-+  var filter = /[`!@#$%^&*()_+\-=\[\]{};'"|,<>?~]/;
-   if (filter.test(filename)){
-     console.log('\nInvalid characters inside the path to the browser\n');
-     return
diff --git a/tools/node_tools/node_modules_licenses/BUILD b/tools/node_tools/node_modules_licenses/BUILD
index 1437e75..03e9c2b5 100644
--- a/tools/node_tools/node_modules_licenses/BUILD
+++ b/tools/node_tools/node_modules_licenses/BUILD
@@ -12,8 +12,7 @@
     compiler = "//tools/node_tools:tsc_wrapped-bin",
     tsconfig = "tsconfig.json",
     deps = [
-        "@tools_npm//@bazel/typescript",
-        "@tools_npm//@types/node",
+        "@tools_npm//:node_modules",
     ],
 )
 
@@ -29,6 +28,7 @@
     entry_point = "license-map-generator.ts",
     format = "cjs",
     rollup_bin = "//tools/node_tools:rollup-bin",
+    silent = True,
     deps = [
         ":licenses-map",
         "@tools_npm//rollup",
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index a5bf124..bda73f3 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -3,9 +3,9 @@
   "description": "Gerrit Build Tools",
   "browser": false,
   "dependencies": {
-    "@bazel/concatjs": "^5.1.0",
     "@bazel/rollup": "^5.1.0",
     "@bazel/typescript": "^5.1.0",
+    "@bazel/concatjs": "^5.1.0",
     "@types/node": "^10.17.12",
     "@types/parse5": "^4.0.0",
     "@types/parse5-html-rewriting-stream": "^5.1.2",
@@ -15,17 +15,17 @@
     "polymer-bundler": "^4.0.10",
     "polymer-cli": "^1.9.11",
     "rollup": "^2.3.4",
+    "rollup-plugin-commonjs": "^10.1.0",
     "rollup-plugin-node-resolve": "^5.2.0",
     "rollup-plugin-terser": "^5.1.3",
     "typescript": "4.3.2"
   },
   "devDependencies": {},
-  "scripts": {
-    "postinstall": "(git apply --reverse --ignore-whitespace launchpad.patch || true) && git apply --ignore-whitespace launchpad.patch"
-  },
   "license": "Apache-2.0",
   "private": true,
   "resolutions": {
-    "lodash": "4.17.21"
+    "lodash": "4.17.21",
+    "wct-local": "2.1.6",
+    "launchpad": "git+https://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be"
   }
 }
diff --git a/tools/node_tools/polygerrit_app_preprocessor/BUILD b/tools/node_tools/polygerrit_app_preprocessor/BUILD
index 47f2a41..8d39d2b 100644
--- a/tools/node_tools/polygerrit_app_preprocessor/BUILD
+++ b/tools/node_tools/polygerrit_app_preprocessor/BUILD
@@ -23,6 +23,7 @@
     entry_point = "preprocessor.ts",
     format = "cjs",
     rollup_bin = "//tools/node_tools:rollup-bin",
+    silent = True,
     deps = [
         ":preprocessor",
         "@tools_npm//rollup-plugin-node-resolve",
@@ -35,6 +36,7 @@
     entry_point = "links-updater.ts",
     format = "cjs",
     rollup_bin = "//tools/node_tools:rollup-bin",
+    silent = True,
     deps = [
         ":preprocessor",
         "@tools_npm//rollup-plugin-node-resolve",
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index edb4c98..3f9d3a4 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -4870,6 +4870,13 @@
   dependencies:
     has "^1.0.3"
 
+is-core-module@^2.8.1:
+  version "2.8.1"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211"
+  integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==
+  dependencies:
+    has "^1.0.3"
+
 is-data-descriptor@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
@@ -5092,6 +5099,13 @@
   resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
   integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=
 
+is-reference@^1.1.2:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7"
+  integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==
+  dependencies:
+    "@types/estree" "*"
+
 is-retry-allowed@^1.0.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4"
@@ -5365,10 +5379,9 @@
   dependencies:
     package-json "^4.0.0"
 
-launchpad@^0.7.0:
+"launchpad@git+https://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be", "launchpad@git://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be":
   version "0.7.5"
-  resolved "https://registry.yarnpkg.com/launchpad/-/launchpad-0.7.5.tgz#a16950c937572f10ef01c9be945a96f7aef8e427"
-  integrity sha512-gsYFgT8XKL3X2XZHPPPrgwM0JqeQwGpSWnzg7EYadBY3MirbQrTVq6L4fm6l7UE2T+7gnfuhiGkKr/xxuU/fdw==
+  resolved "git+https://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be"
   dependencies:
     async "^2.0.1"
     browserstack "^1.2.0"
@@ -5630,6 +5643,13 @@
   dependencies:
     vlq "^0.2.2"
 
+magic-string@^0.25.2:
+  version "0.25.7"
+  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051"
+  integrity sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==
+  dependencies:
+    sourcemap-codec "^1.4.4"
+
 make-dir@^1.0.0, make-dir@^1.1.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c"
@@ -6498,7 +6518,7 @@
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
   integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
 
-path-parse@^1.0.6:
+path-parse@^1.0.6, path-parse@^1.0.7:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
   integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
@@ -7402,6 +7422,15 @@
     is-core-module "^2.2.0"
     path-parse "^1.0.6"
 
+resolve@^1.11.0:
+  version "1.22.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198"
+  integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==
+  dependencies:
+    is-core-module "^2.8.1"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
 responselike@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.0.tgz#26391bcc3174f750f9a79eacc40a12a5c42d7723"
@@ -7451,6 +7480,17 @@
   dependencies:
     glob "^7.1.3"
 
+rollup-plugin-commonjs@^10.1.0:
+  version "10.1.0"
+  resolved "https://registry.yarnpkg.com/rollup-plugin-commonjs/-/rollup-plugin-commonjs-10.1.0.tgz#417af3b54503878e084d127adf4d1caf8beb86fb"
+  integrity sha512-jlXbjZSQg8EIeAAvepNwhJj++qJWNJw1Cl0YnOqKtP5Djx+fFGkp3WRh+W0ASCaFG5w1jhmzDxgu3SJuVxPF4Q==
+  dependencies:
+    estree-walker "^0.6.1"
+    is-reference "^1.1.2"
+    magic-string "^0.25.2"
+    resolve "^1.11.0"
+    rollup-pluginutils "^2.8.1"
+
 rollup-plugin-node-resolve@^5.2.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-5.2.0.tgz#730f93d10ed202473b1fb54a5997a7db8c6d8523"
@@ -7929,6 +7969,11 @@
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
   integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
 
+sourcemap-codec@^1.4.4:
+  version "1.4.8"
+  resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
+  integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
+
 spawn-sync@^1.0.15:
   version "1.0.15"
   resolved "https://registry.yarnpkg.com/spawn-sync/-/spawn-sync-1.0.15.tgz#b00799557eb7fb0c8376c29d44e8a1ea67e57476"
@@ -8227,6 +8272,11 @@
   dependencies:
     has-flag "^4.0.0"
 
+supports-preserve-symlinks-flag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+  integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+
 sw-precache@^5.1.1:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/sw-precache/-/sw-precache-5.2.1.tgz#06134f319eec68f3b9583ce9a7036b1c119f7179"
@@ -8939,10 +8989,10 @@
   dependencies:
     minimalistic-assert "^1.0.0"
 
-wct-local@^2.1.1:
-  version "2.1.5"
-  resolved "https://registry.yarnpkg.com/wct-local/-/wct-local-2.1.5.tgz#f7986753e3ad9a35d39178a9989350523561fff1"
-  integrity sha512-eqoZhjGy4Xq2tY0uB46Grkw/ztq+/rC0ImbYKl62unFHXtOgal+kkvnxR3SLRFNM8ty9+ItgycPeH0IpTqVL+w==
+wct-local@2.1.6, wct-local@^2.1.1:
+  version "2.1.6"
+  resolved "https://registry.yarnpkg.com/wct-local/-/wct-local-2.1.6.tgz#2d099c52996e77265d16e03a5d6d897b77ea9967"
+  integrity sha512-jvTzgOIIfJ43H3DXUfruHPTQ/TJ269SDk4R2CfCpU13EYbwxn3U1B6L5NHYRFu/cgdJmOHraGrn/wREHH6xeXQ==
   dependencies:
     "@types/express" "^4.0.30"
     "@types/freeport" "^1.0.19"
@@ -8951,7 +9001,7 @@
     chalk "^2.3.0"
     cleankill "^2.0.0"
     freeport "^1.0.4"
-    launchpad "^0.7.0"
+    launchpad "git://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be"
     selenium-standalone "^6.7.0"
     which "^1.0.8"
 
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 924c5d0..e36a383 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -4,6 +4,8 @@
 
 GUAVA_BIN_SHA1 = "00d0c3ce2311c9e36e73228da25a6e99b2ab826f"
 
+GUAVA_TESTLIB_BIN_SHA1 = "798c3827308605cd69697d8f1596a1735d3ef6e2"
+
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
 
 def declare_nongoogle_deps():
@@ -137,24 +139,24 @@
         sha1 = "9bc20b94d3ac42489cf6ce1e42509c86f6f861a1",
     )
 
-    FLOGGER_VERS = "0.6"
+    FLOGGER_VERS = "0.7.4"
 
     maven_jar(
         name = "flogger",
         artifact = "com.google.flogger:flogger:" + FLOGGER_VERS,
-        sha1 = "155dc6e303a58f7bbff5d2cd1a259de86827f4fe",
+        sha1 = "cec29ed8b58413c2e935d86b12d6b696dc285419",
     )
 
     maven_jar(
         name = "flogger-log4j-backend",
         artifact = "com.google.flogger:flogger-log4j-backend:" + FLOGGER_VERS,
-        sha1 = "9743841bf10309163effd8ddf882b5d5190cc9d9",
+        sha1 = "7486b1c0138647cd7714eccb8ce37b5f2ae20a76",
     )
 
     maven_jar(
         name = "flogger-system-backend",
         artifact = "com.google.flogger:flogger-system-backend:" + FLOGGER_VERS,
-        sha1 = "0f0ccf8923c6c315f2f57b108bcc6e46ccd88777",
+        sha1 = "4bee7ebbd97c63ca7fb17529aeb49a57b670d061",
     )
 
     maven_jar(
@@ -163,6 +165,12 @@
         sha1 = GUAVA_BIN_SHA1,
     )
 
+    maven_jar(
+        name = "guava-testlib",
+        artifact = "com.google.guava:guava-testlib:" + GUAVA_VERSION,
+        sha1 = GUAVA_TESTLIB_BIN_SHA1,
+    )
+
     GUICE_VERS = "5.0.1"
 
     maven_jar(
diff --git a/tools/release_noter/release_noter.py b/tools/release_noter/release_noter.py
index 167f68a..184e089 100755
--- a/tools/release_noter/release_noter.py
+++ b/tools/release_noter/release_noter.py
@@ -96,7 +96,8 @@
 CHANGE_URL = "/c/gerrit/+/"
 COMMIT_URL = "/changes/?q=commit%3A"
 GERRIT_URL = "https://gerrit-review.googlesource.com"
-ISSUE_URL = "https://bugs.chromium.org/p/gerrit/issues/detail?id="
+ISSUE_URL_MONORAIL = "https://bugs.chromium.org/p/gerrit/issues/detail?id="
+ISSUE_URL_TRACKER = "https://issues.gerritcodereview.com/issues/"
 
 MARKDOWN = "release_noter"
 GIT_COMMAND = "git"
@@ -301,7 +302,10 @@
 def print_from(this_change, md):
     md.write("\n*")
     for issue in sorted(this_change.issues):
-        md.write(f" [Issue {issue}]({ISSUE_URL}{issue});\n ")
+      if len(issue) > 5:
+        md.write(f" [Issue {issue}]({ISSUE_URL_TRACKER}{issue});\n ")
+      else:
+        md.write(f" [Issue {issue}]({ISSUE_URL_MONORAIL}{issue});\n ")
     md.write(f" {this_change.subject}\n")
 
 
diff --git a/version.bzl b/version.bzl
index 82f4d12..63c4ba9 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.5.7-SNAPSHOT"
+GERRIT_VERSION = "3.6.9-SNAPSHOT"
diff --git a/yarn.lock b/yarn.lock
index 8f22ce5..406f567 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -596,7 +596,7 @@
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809"
   integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
 
-chalk@^2.0.0, chalk@^2.4.2:
+chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
   integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
@@ -747,6 +747,17 @@
   resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
   integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
 
+cross-spawn@^6.0.5:
+  version "6.0.5"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
+  integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
+  dependencies:
+    nice-try "^1.0.4"
+    path-key "^2.0.1"
+    semver "^5.5.0"
+    shebang-command "^1.2.0"
+    which "^1.2.9"
+
 cross-spawn@^7.0.2, cross-spawn@^7.0.3:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@@ -1046,6 +1057,32 @@
     string.prototype.trimstart "^1.0.4"
     unbox-primitive "^1.0.1"
 
+es-abstract@^1.19.1:
+  version "1.19.1"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.19.1.tgz#d4885796876916959de78edaa0df456627115ec3"
+  integrity sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==
+  dependencies:
+    call-bind "^1.0.2"
+    es-to-primitive "^1.2.1"
+    function-bind "^1.1.1"
+    get-intrinsic "^1.1.1"
+    get-symbol-description "^1.0.0"
+    has "^1.0.3"
+    has-symbols "^1.0.2"
+    internal-slot "^1.0.3"
+    is-callable "^1.2.4"
+    is-negative-zero "^2.0.1"
+    is-regex "^1.1.4"
+    is-shared-array-buffer "^1.0.1"
+    is-string "^1.0.7"
+    is-weakref "^1.0.1"
+    object-inspect "^1.11.0"
+    object-keys "^1.1.1"
+    object.assign "^4.1.2"
+    string.prototype.trimend "^1.0.4"
+    string.prototype.trimstart "^1.0.4"
+    unbox-primitive "^1.0.1"
+
 es-to-primitive@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
@@ -1539,6 +1576,14 @@
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
   integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
 
+get-symbol-description@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6"
+  integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==
+  dependencies:
+    call-bind "^1.0.2"
+    get-intrinsic "^1.1.1"
+
 get-value@^2.0.3, get-value@^2.0.6:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
@@ -1900,7 +1945,7 @@
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
   integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
 
-is-callable@^1.1.4, is-callable@^1.2.3:
+is-callable@^1.1.4, is-callable@^1.2.3, is-callable@^1.2.4:
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945"
   integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==
@@ -2058,7 +2103,7 @@
   dependencies:
     isobject "^3.0.1"
 
-is-regex@^1.1.3:
+is-regex@^1.1.3, is-regex@^1.1.4:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
   integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==
@@ -2066,12 +2111,17 @@
     call-bind "^1.0.2"
     has-tostringtag "^1.0.0"
 
+is-shared-array-buffer@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz#97b0c85fbdacb59c9c446fe653b82cf2b5b7cfe6"
+  integrity sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==
+
 is-stream@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
   integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
 
-is-string@^1.0.5, is-string@^1.0.6:
+is-string@^1.0.5, is-string@^1.0.6, is-string@^1.0.7:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd"
   integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==
@@ -2090,6 +2140,13 @@
   resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
   integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
 
+is-weakref@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.1.tgz#842dba4ec17fa9ac9850df2d6efbc1737274f2a2"
+  integrity sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ==
+  dependencies:
+    call-bind "^1.0.0"
+
 is-windows@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
@@ -2355,6 +2412,11 @@
   dependencies:
     object-visit "^1.0.0"
 
+memorystream@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2"
+  integrity sha1-htcJCzDORV1j+64S3aUaR93K+bI=
+
 meow@^9.0.0:
   version "9.0.0"
   resolved "https://registry.yarnpkg.com/meow/-/meow-9.0.0.tgz#cd9510bc5cac9dee7d03c73ee1f9ad959f4ea364"
@@ -2508,6 +2570,11 @@
   resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
   integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=
 
+nice-try@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
+  integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
+
 normalize-package-data@^2.3.2, normalize-package-data@^2.5.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
@@ -2533,6 +2600,21 @@
   resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a"
   integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==
 
+npm-run-all@^4.1.5:
+  version "4.1.5"
+  resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.5.tgz#04476202a15ee0e2e214080861bff12a51d98fba"
+  integrity sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==
+  dependencies:
+    ansi-styles "^3.2.1"
+    chalk "^2.4.1"
+    cross-spawn "^6.0.5"
+    memorystream "^0.3.1"
+    minimatch "^3.0.4"
+    pidtree "^0.3.0"
+    read-pkg "^3.0.0"
+    shell-quote "^1.6.1"
+    string.prototype.padend "^3.0.0"
+
 npm-run-path@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
@@ -2757,6 +2839,11 @@
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
   integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
 
+path-key@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
+  integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
+
 path-key@^3.0.0, path-key@^3.1.0:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
@@ -2784,6 +2871,11 @@
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
   integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
 
+pidtree@^0.3.0:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.3.1.tgz#ef09ac2cc0533df1f3250ccf2c4d366b0d12114a"
+  integrity sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==
+
 pify@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
@@ -3119,7 +3211,7 @@
   dependencies:
     semver "^6.3.0"
 
-"semver@2 || 3 || 4 || 5":
+"semver@2 || 3 || 4 || 5", semver@^5.5.0:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
   integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
@@ -3156,6 +3248,13 @@
     is-plain-object "^2.0.3"
     split-string "^3.0.1"
 
+shebang-command@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
+  integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=
+  dependencies:
+    shebang-regex "^1.0.0"
+
 shebang-command@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
@@ -3163,11 +3262,21 @@
   dependencies:
     shebang-regex "^3.0.0"
 
+shebang-regex@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
+  integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
+
 shebang-regex@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
   integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
 
+shell-quote@^1.6.1:
+  version "1.7.3"
+  resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.3.tgz#aa40edac170445b9a431e17bb62c0b881b9c4123"
+  integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==
+
 side-channel@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
@@ -3337,6 +3446,15 @@
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.0"
 
+string.prototype.padend@^3.0.0:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/string.prototype.padend/-/string.prototype.padend-3.1.3.tgz#997a6de12c92c7cb34dc8a201a6c53d9bd88a5f1"
+  integrity sha512-jNIIeokznm8SD/TZISQsZKYu7RJyheFNt84DUPrh482GC8RVp2MKqm2O5oBRdGxbDQoXrhhWtPIWQOiy20svUg==
+  dependencies:
+    call-bind "^1.0.2"
+    define-properties "^1.1.3"
+    es-abstract "^1.19.1"
+
 string.prototype.trimend@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80"
@@ -3751,6 +3869,13 @@
   resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
   integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
 
+which@^1.2.9:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
+  integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
+  dependencies:
+    isexe "^2.0.0"
+
 which@^2.0.1:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"